From 61d3eb334e8c070a21064b8fcfef72fa1e756247 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Thu, 14 May 2026 17:02:46 -0400 Subject: [PATCH 1/2] =?UTF-8?q?CS-10346:=20vitest=20PoC=20=E2=80=94=20asyn?= =?UTF-8?q?c-semaphore=20test=20runs=20under=20vitest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recovers Ian's QUnit→Vitest codemod (995 lines, AST-based via the TypeScript compiler API) from the 9-months-stale branch and rewires it against current realm-server tests: - Codemod writes `tests-vitest/` as a parallel tree of `.test.ts` files; the QUnit suite under `tests/` is untouched. Running the codemod transforms 111 tests + copies 77 support files; one file (card-reference-resolver-test.ts) flags 11 `assert.throws(..., /regex/, ...)` calls for manual follow-up. - `tests-vitest/.gitignore` allowlists files only as they graduate to vitest, so the auto-generated tree stays out of git. - `vitest.config.ts` runs singleThread to match QUnit's serial model while parallelism is deferred to a later phase. - `async-semaphore.test.ts` is the first graduate: 18/18 passing in 3ms via `pnpm run test:vitest`. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/realm-server/package.json | 3 + .../scripts/codemods/qunit-to-vitest.ts | 995 ++++++++++++++++++ packages/realm-server/tests-vitest/.gitignore | 7 + .../tests-vitest/async-semaphore.test.ts | 331 ++++++ packages/realm-server/vitest.config.ts | 20 + pnpm-lock.yaml | 51 + 6 files changed, 1407 insertions(+) create mode 100644 packages/realm-server/scripts/codemods/qunit-to-vitest.ts create mode 100644 packages/realm-server/tests-vitest/.gitignore create mode 100644 packages/realm-server/tests-vitest/async-semaphore.test.ts create mode 100644 packages/realm-server/vitest.config.ts diff --git a/packages/realm-server/package.json b/packages/realm-server/package.json index 10534df4137..89ab69ed6a8 100644 --- a/packages/realm-server/package.json +++ b/packages/realm-server/package.json @@ -86,6 +86,7 @@ "typescript-memoize": "catalog:", "undici": "catalog:", "uuid": "catalog:", + "vitest": "catalog:", "wait-for-localhost-cli": "catalog:", "wait-on": "^9.0.4", "yaml": "catalog:", @@ -94,6 +95,8 @@ "scripts": { "test": "./tests/scripts/run-qunit-with-test-pg.sh", "test-module": "./tests/scripts/run-qunit-with-test-pg.sh --module ${TEST_MODULE}", + "test:vitest": "vitest run", + "codemod:qunit-to-vitest": "NODE_NO_WARNINGS=1 ts-node --transpileOnly scripts/codemods/qunit-to-vitest.ts", "bench:realm": "LOG_LEVELS='*=warn,prerenderer-chrome=none' TEST_HARNESS_REALM_LOG_LEVELS='*=warn,prerenderer-chrome=none' TEST_HARNESS_PRERENDER_LOG_LEVELS='*=warn,prerenderer-chrome=none' NODE_NO_WARNINGS=1 ts-node --transpileOnly scripts/bench-realm/bench.ts", "bench:realm:check": "LOG_LEVELS='*=warn,prerenderer-chrome=none' TEST_HARNESS_REALM_LOG_LEVELS='*=warn,prerenderer-chrome=none' TEST_HARNESS_PRERENDER_LOG_LEVELS='*=warn,prerenderer-chrome=none' NODE_NO_WARNINGS=1 ts-node --transpileOnly scripts/bench-realm/check.ts", "migrate": "NODE_NO_WARNINGS=1 ts-node --transpileOnly scripts/run-migrations.ts", diff --git a/packages/realm-server/scripts/codemods/qunit-to-vitest.ts b/packages/realm-server/scripts/codemods/qunit-to-vitest.ts new file mode 100644 index 00000000000..6e2eb1c3537 --- /dev/null +++ b/packages/realm-server/scripts/codemods/qunit-to-vitest.ts @@ -0,0 +1,995 @@ +import { ensureDirSync, readFileSync, writeFileSync, copyFileSync } from 'fs-extra'; +import { basename, dirname, join, relative } from 'path'; +import { glob } from 'glob'; +import ts from 'typescript'; + +const projectRoot = join(__dirname, '..', '..'); +const sourceRoot = join(projectRoot, 'tests'); +const targetRoot = join(projectRoot, 'tests-vitest'); +const generatedHeader = + '// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand.\n'; + +type TransformResult = { + code: string; + needsVitestHookImport: boolean; + needsDirnamePolyfill: boolean; + needsFilenamePolyfill: boolean; + unsupportedCalls: string[]; +}; + +function callText(node: ts.CallExpression, sourceFile: ts.SourceFile): string { + return node.getText(sourceFile).replace(/\s+/g, ' '); +} + +function isBasenameFilenameCall(node: ts.Expression): boolean { + return ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'basename' && + node.arguments.length === 1 && + ts.isIdentifier(node.arguments[0]) && + node.arguments[0].text === '__filename' + ); +} + +function replaceBasenameFilenameArg( + node: ts.Expression, + fileNameLiteral: string, +): ts.Expression { + if (isBasenameFilenameCall(node)) { + return ts.factory.createStringLiteral(fileNameLiteral); + } + if (ts.isTemplateExpression(node)) { + let value = node.head.text; + for (let span of node.templateSpans) { + if (!isBasenameFilenameCall(span.expression)) { + return node; + } + value += fileNameLiteral + span.literal.text; + } + return ts.factory.createStringLiteral(value); + } + return node; +} + +function convertAssertCall( + method: string, + node: ts.CallExpression, +): ts.CallExpression | undefined { + let args = [...node.arguments]; + let expectCall = (value: ts.Expression) => + ts.factory.createCallExpression(ts.factory.createIdentifier('expect'), undefined, [value]); + + switch (method) { + case 'strictEqual': + if (args.length >= 2) { + return ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression(expectCall(args[0]), 'toBe'), + undefined, + [args[1]], + ); + } + return undefined; + case 'notStrictEqual': + if (args.length >= 2) { + return ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression( + ts.factory.createPropertyAccessExpression(expectCall(args[0]), 'not'), + 'toBe', + ), + undefined, + [args[1]], + ); + } + return undefined; + case 'deepEqual': + if (args.length >= 2) { + return ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression(expectCall(args[0]), 'toEqual'), + undefined, + [args[1]], + ); + } + return undefined; + case 'notEqual': + if (args.length >= 2) { + return ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression( + ts.factory.createPropertyAccessExpression(expectCall(args[0]), 'not'), + 'toEqual', + ), + undefined, + [args[1]], + ); + } + return undefined; + case 'ok': + if (args.length >= 1) { + return ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression(expectCall(args[0]), 'toBeTruthy'), + undefined, + [], + ); + } + return undefined; + case 'notOk': + if (args.length >= 1) { + return ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression(expectCall(args[0]), 'toBeFalsy'), + undefined, + [], + ); + } + return undefined; + case 'true': + if (args.length >= 1) { + return ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression(expectCall(args[0]), 'toBe'), + undefined, + [ts.factory.createTrue()], + ); + } + return undefined; + case 'false': + if (args.length >= 1) { + return ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression(expectCall(args[0]), 'toBe'), + undefined, + [ts.factory.createFalse()], + ); + } + return undefined; + case 'rejects': { + if (args.length >= 1) { + return ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression( + ts.factory.createIdentifier('assert'), + 'rejects', + ), + undefined, + args, + ); + } + return undefined; + } + case 'codeEqual': { + if (args.length >= 2) { + return ts.factory.createCallExpression( + ts.factory.createPropertyAccessExpression( + ts.factory.createIdentifier('assert'), + 'codeEqual', + ), + undefined, + args, + ); + } + return undefined; + } + default: + return undefined; + } +} + +function addNamedImport( + statements: ts.Statement[], + moduleName: string, + importName: string, +): ts.Statement[] { + let importDecl = statements.find( + (s): s is ts.ImportDeclaration => + ts.isImportDeclaration(s) && + ts.isStringLiteral(s.moduleSpecifier) && + s.moduleSpecifier.text === moduleName, + ); + + if (!importDecl) { + let newImport = ts.factory.createImportDeclaration( + undefined, + ts.factory.createImportClause( + false, + undefined, + ts.factory.createNamedImports([ + ts.factory.createImportSpecifier(false, undefined, ts.factory.createIdentifier(importName)), + ]), + ), + ts.factory.createStringLiteral(moduleName), + undefined, + ); + return [newImport, ...statements]; + } + + let clause = importDecl.importClause; + let namedBindings = clause?.namedBindings; + if (!clause || !namedBindings || !ts.isNamedImports(namedBindings)) { + return statements; + } + + let alreadyImported = namedBindings.elements.some((e) => e.name.text === importName); + if (alreadyImported) { + return statements; + } + + let updatedNamedBindings = ts.factory.updateNamedImports(namedBindings, [ + ...namedBindings.elements, + ts.factory.createImportSpecifier(false, undefined, ts.factory.createIdentifier(importName)), + ]); + let updatedClause = ts.factory.updateImportClause( + clause, + clause.isTypeOnly, + clause.name, + updatedNamedBindings, + ); + let updatedDecl = ts.factory.updateImportDeclaration( + importDecl, + importDecl.modifiers, + updatedClause, + importDecl.moduleSpecifier, + importDecl.attributes, + ); + + return statements.map((s) => (s === importDecl ? updatedDecl : s)); +} + +function ensureVitestImport( + statements: ts.Statement[], + additionalImports: string[] = [], +): ts.Statement[] { + let vitestImport = statements.find( + (s): s is ts.ImportDeclaration => + ts.isImportDeclaration(s) && + ts.isStringLiteral(s.moduleSpecifier) && + s.moduleSpecifier.text === 'vitest', + ); + + const needed = [...new Set(['describe', 'test', 'expect', ...additionalImports])]; + if (!vitestImport) { + let importDecl = ts.factory.createImportDeclaration( + undefined, + ts.factory.createImportClause( + false, + undefined, + ts.factory.createNamedImports( + needed.map((n) => + ts.factory.createImportSpecifier(false, undefined, ts.factory.createIdentifier(n)), + ), + ), + ), + ts.factory.createStringLiteral('vitest'), + undefined, + ); + return [importDecl, ...statements]; + } + + let clause = vitestImport.importClause; + let namedBindings = clause?.namedBindings; + if (!clause || !namedBindings || !ts.isNamedImports(namedBindings)) { + return statements; + } + + let existing = new Set(namedBindings.elements.map((e) => e.name.text)); + let missing = needed.filter((n) => !existing.has(n)); + if (!missing.length) { + return statements; + } + + let updatedNamedBindings = ts.factory.updateNamedImports(namedBindings, [ + ...namedBindings.elements, + ...missing.map((n) => + ts.factory.createImportSpecifier(false, undefined, ts.factory.createIdentifier(n)), + ), + ]); + let updatedClause = ts.factory.updateImportClause( + clause, + clause.isTypeOnly, + clause.name, + updatedNamedBindings, + ); + let updatedDecl = ts.factory.updateImportDeclaration( + vitestImport, + vitestImport.modifiers, + updatedClause, + vitestImport.moduleSpecifier, + vitestImport.attributes, + ); + + return statements.map((s) => (s === vitestImport ? updatedDecl : s)); +} + +function ensureFilenameDirnamePrelude( + statements: ts.Statement[], + needsFilenamePolyfill: boolean, + needsDirnamePolyfill: boolean, +): ts.Statement[] { + if (!needsFilenamePolyfill && !needsDirnamePolyfill) { + return statements; + } + + let updatedStatements = statements; + + if (needsFilenamePolyfill) { + updatedStatements = addNamedImport(updatedStatements, 'url', 'fileURLToPath'); + } + if (needsDirnamePolyfill) { + updatedStatements = addNamedImport(updatedStatements, 'path', 'dirname'); + } + + let hasFilenameDecl = updatedStatements.some( + (s) => + ts.isVariableStatement(s) && + s.declarationList.declarations.some( + (d) => ts.isIdentifier(d.name) && d.name.text === '__filename', + ), + ); + let hasDirnameDecl = updatedStatements.some( + (s) => + ts.isVariableStatement(s) && + s.declarationList.declarations.some( + (d) => ts.isIdentifier(d.name) && d.name.text === '__dirname', + ), + ); + + let prelude: ts.Statement[] = []; + if (needsFilenamePolyfill && !hasFilenameDecl) { + prelude.push( + ts.factory.createVariableStatement( + undefined, + ts.factory.createVariableDeclarationList( + [ + ts.factory.createVariableDeclaration( + ts.factory.createIdentifier('__filename'), + undefined, + undefined, + ts.factory.createCallExpression(ts.factory.createIdentifier('fileURLToPath'), undefined, [ + ts.factory.createPropertyAccessExpression( + ts.factory.createMetaProperty( + ts.SyntaxKind.ImportKeyword, + ts.factory.createIdentifier('meta'), + ), + 'url', + ), + ]), + ), + ], + ts.NodeFlags.Const, + ), + ), + ); + } + if (needsDirnamePolyfill && !hasDirnameDecl) { + prelude.push( + ts.factory.createVariableStatement( + undefined, + ts.factory.createVariableDeclarationList( + [ + ts.factory.createVariableDeclaration( + ts.factory.createIdentifier('__dirname'), + undefined, + undefined, + ts.factory.createCallExpression(ts.factory.createIdentifier('dirname'), undefined, [ + ts.factory.createIdentifier('__filename'), + ]), + ), + ], + ts.NodeFlags.Const, + ), + ), + ); + } + + if (!prelude.length) { + return updatedStatements; + } + + let lastImportIndex = -1; + for (let i = 0; i < updatedStatements.length; i++) { + if (ts.isImportDeclaration(updatedStatements[i])) { + lastImportIndex = i; + } + } + if (lastImportIndex === -1) { + return [...prelude, ...updatedStatements]; + } + return [ + ...updatedStatements.slice(0, lastImportIndex + 1), + ...prelude, + ...updatedStatements.slice(lastImportIndex + 1), + ]; +} + +function collectUsedIdentifiers(statements: ts.Statement[]): Set { + let used = new Set(); + function walk(node: ts.Node) { + if (ts.isImportDeclaration(node)) { + return; + } + if (ts.isIdentifier(node)) { + used.add(node.text); + } + ts.forEachChild(node, walk); + } + for (let statement of statements) { + walk(statement); + } + return used; +} + +function pruneUnusedPathLikeImports(statements: ts.Statement[]): ts.Statement[] { + let used = collectUsedIdentifiers(statements); + return statements.flatMap((statement) => { + if (!ts.isImportDeclaration(statement)) { + return [statement]; + } + if (!ts.isStringLiteral(statement.moduleSpecifier)) { + return [statement]; + } + let moduleName = statement.moduleSpecifier.text; + if (moduleName !== 'path' && moduleName !== 'url') { + return [statement]; + } + + let clause = statement.importClause; + let namedBindings = clause?.namedBindings; + if (!clause || !namedBindings || !ts.isNamedImports(namedBindings)) { + return [statement]; + } + + let kept = namedBindings.elements.filter((specifier) => { + return used.has(specifier.name.text); + }); + + if (kept.length === namedBindings.elements.length) { + return [statement]; + } + + if (!kept.length && !clause.name) { + return []; + } + + let updatedBindings = kept.length + ? ts.factory.updateNamedImports(namedBindings, kept) + : undefined; + let updatedClause = ts.factory.updateImportClause( + clause, + clause.isTypeOnly, + clause.name, + updatedBindings, + ); + return [ + ts.factory.updateImportDeclaration( + statement, + statement.modifiers, + updatedClause, + statement.moduleSpecifier, + statement.attributes, + ), + ]; + }); +} + +function transformQUnitToVitest( + sourceCode: string, + targetFile: string, + sourceFileName: string, +): TransformResult { + let needsVitestHookImport = false; + let unsupportedCalls: string[] = []; + + let sourceFile = ts.createSourceFile( + targetFile, + sourceCode, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + + let transformer: ts.TransformerFactory = (context) => { + function isAssertAsyncCall(node: ts.Expression): boolean { + return ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + ts.isIdentifier(node.expression.expression) && + node.expression.expression.text === 'assert' && + node.expression.name.text === 'async' + ); + } + + function isWaitRetryTimeoutCall(stmt: ts.Statement): boolean { + if (!ts.isExpressionStatement(stmt) || !ts.isCallExpression(stmt.expression)) { + return false; + } + let call = stmt.expression; + if (!ts.isIdentifier(call.expression) || call.expression.text !== 'setTimeout') { + return false; + } + if (call.arguments.length < 1) { + return false; + } + let callback = call.arguments[0]; + if ( + !( + ts.isArrowFunction(callback) || + ts.isFunctionExpression(callback) + ) + ) { + return false; + } + let bodyExpr: ts.Expression | undefined; + if (ts.isCallExpression(callback.body)) { + bodyExpr = callback.body; + } else if ( + ts.isBlock(callback.body) && + callback.body.statements.length === 1 && + ts.isExpressionStatement(callback.body.statements[0]) && + ts.isCallExpression(callback.body.statements[0].expression) + ) { + bodyExpr = callback.body.statements[0].expression; + } + if (!bodyExpr) { + return false; + } + return ( + ts.isIdentifier(bodyExpr.expression) && + bodyExpr.expression.text === 'waitForBillingNotification' + ); + } + + function rewriteWaitRetryFunction( + fn: + | ts.FunctionExpression + | ts.ArrowFunction, + ): ts.FunctionExpression | ts.ArrowFunction { + if (!ts.isBlock(fn.body)) { + return fn; + } + function rewriteStatement(statement: ts.Statement): ts.Statement[] { + if ( + ts.isExpressionStatement(statement) && + ts.isCallExpression(statement.expression) && + ts.isIdentifier(statement.expression.expression) && + statement.expression.expression.text === 'done' + ) { + return [ts.factory.createReturnStatement()]; + } + + if (ts.isIfStatement(statement)) { + let thenStatements = ts.isBlock(statement.thenStatement) + ? statement.thenStatement.statements.flatMap(rewriteStatement) + : rewriteStatement(statement.thenStatement); + let rewrittenThen = ts.factory.createBlock(thenStatements, true); + + let rewrittenElse: ts.Statement | undefined; + if (statement.elseStatement) { + if (ts.isBlock(statement.elseStatement)) { + let elseStatements: ts.Statement[] = []; + for (let elseStmt of statement.elseStatement.statements) { + if (isWaitRetryTimeoutCall(elseStmt)) { + elseStatements.push( + ts.factory.createExpressionStatement( + ts.factory.createAwaitExpression( + ts.factory.createNewExpression( + ts.factory.createIdentifier('Promise'), + [ts.factory.createKeywordTypeNode(ts.SyntaxKind.VoidKeyword)], + [ + ts.factory.createArrowFunction( + undefined, + undefined, + [ + ts.factory.createParameterDeclaration( + undefined, + undefined, + ts.factory.createIdentifier('resolve'), + undefined, + undefined, + undefined, + ), + ], + undefined, + ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + ts.factory.createCallExpression( + ts.factory.createIdentifier('setTimeout'), + undefined, + [ + ts.factory.createIdentifier('resolve'), + ts.factory.createNumericLiteral(1), + ], + ), + ), + ], + ), + ), + ), + ); + elseStatements.push( + ts.factory.createReturnStatement( + ts.factory.createAwaitExpression( + ts.factory.createCallExpression( + ts.factory.createIdentifier('waitForBillingNotification'), + undefined, + [], + ), + ), + ), + ); + } else { + elseStatements.push(...rewriteStatement(elseStmt)); + } + } + rewrittenElse = ts.factory.createBlock(elseStatements, true); + } else { + let elseStatements = rewriteStatement(statement.elseStatement); + rewrittenElse = + elseStatements.length === 1 + ? elseStatements[0] + : ts.factory.createBlock(elseStatements, true); + } + } + + return [ + ts.factory.updateIfStatement( + statement, + statement.expression, + rewrittenThen, + rewrittenElse, + ), + ]; + } + + return [statement]; + } + + let rewrittenStatements = fn.body.statements.flatMap(rewriteStatement); + + let rewrittenBody = ts.factory.updateBlock(fn.body, rewrittenStatements); + if (ts.isFunctionExpression(fn)) { + return ts.factory.updateFunctionExpression( + fn, + fn.modifiers, + fn.asteriskToken, + fn.name, + fn.typeParameters, + [], + fn.type, + rewrittenBody, + ); + } + return ts.factory.updateArrowFunction( + fn, + fn.modifiers, + fn.typeParameters, + [], + fn.type, + fn.equalsGreaterThanToken, + rewrittenBody, + ); + } + + const visit: ts.Visitor = (node) => { + if (ts.isExpressionStatement(node)) { + if ( + ts.isCallExpression(node.expression) && + ts.isIdentifier(node.expression.expression) && + node.expression.expression.text === 'waitForBillingNotification' && + node.expression.arguments.some((a) => isAssertAsyncCall(a)) + ) { + return ts.factory.createExpressionStatement( + ts.factory.createAwaitExpression( + ts.factory.createCallExpression( + ts.factory.createIdentifier('waitForBillingNotification'), + undefined, + [], + ), + ), + ); + } + if ( + ts.isCallExpression(node.expression) && + ts.isPropertyAccessExpression(node.expression.expression) && + ts.isIdentifier(node.expression.expression.expression) && + node.expression.expression.expression.text === 'assert' && + node.expression.expression.name.text === 'expect' + ) { + return ts.factory.createEmptyStatement(); + } + } + + if (ts.isCallExpression(node)) { + let visitedNode = ts.visitEachChild(node, visit, context) as ts.CallExpression; + + if ( + ts.isPropertyAccessExpression(visitedNode.expression) && + ts.isIdentifier(visitedNode.expression.expression) && + visitedNode.expression.expression.text === 'assert' + ) { + let method = visitedNode.expression.name.text; + let converted = convertAssertCall(method, visitedNode); + if (converted) { + return converted; + } + + if ( + method !== 'beforeEach' && + method !== 'afterEach' && + method !== 'before' && + method !== 'after' && + method !== 'expect' + ) { + unsupportedCalls.push(callText(node, sourceFile)); + } + } + + if (ts.isIdentifier(visitedNode.expression) && (visitedNode.expression.text === 'module' || visitedNode.expression.text === 'test')) { + let replacementName = visitedNode.expression.text === 'module' ? 'describe' : 'test'; + let updatedArgs = [...visitedNode.arguments]; + + if (visitedNode.expression.text === 'module' && updatedArgs.length >= 1) { + updatedArgs[0] = replaceBasenameFilenameArg( + updatedArgs[0], + sourceFileName, + ); + } + + if (visitedNode.expression.text === 'module' && updatedArgs.length >= 2) { + let callback = updatedArgs[1]; + if ( + (ts.isFunctionExpression(callback) || ts.isArrowFunction(callback)) && + callback.parameters.length === 1 && + ts.isBlock(callback.body) && + ts.isIdentifier(callback.parameters[0].name) + ) { + let paramName = callback.parameters[0].name.text; + needsVitestHookImport = true; + let hooksDecl = ts.factory.createVariableStatement( + undefined, + ts.factory.createVariableDeclarationList( + [ + ts.factory.createVariableDeclaration( + ts.factory.createIdentifier(paramName), + undefined, + undefined, + ts.factory.createObjectLiteralExpression( + [ + ts.factory.createPropertyAssignment( + ts.factory.createIdentifier('before'), + ts.factory.createIdentifier('beforeAll'), + ), + ts.factory.createPropertyAssignment( + ts.factory.createIdentifier('after'), + ts.factory.createIdentifier('afterAll'), + ), + ts.factory.createShorthandPropertyAssignment( + ts.factory.createIdentifier('beforeEach'), + undefined, + ), + ts.factory.createShorthandPropertyAssignment( + ts.factory.createIdentifier('afterEach'), + undefined, + ), + ], + true, + ), + ), + ], + ts.NodeFlags.Const, + ), + ); + let updatedBody = ts.factory.updateBlock(callback.body, [ + hooksDecl, + ...callback.body.statements, + ]); + + if (ts.isFunctionExpression(callback)) { + updatedArgs[1] = ts.factory.updateFunctionExpression( + callback, + callback.modifiers, + callback.asteriskToken, + callback.name, + callback.typeParameters, + [], + callback.type, + updatedBody, + ); + } else { + updatedArgs[1] = ts.factory.updateArrowFunction( + callback, + callback.modifiers, + callback.typeParameters, + [], + callback.type, + callback.equalsGreaterThanToken, + updatedBody, + ); + } + } + } + + if (visitedNode.expression.text === 'test' && updatedArgs.length >= 2) { + let callback = updatedArgs[1]; + if ( + (ts.isFunctionExpression(callback) || ts.isArrowFunction(callback)) && + callback.parameters.length === 1 && + ts.isIdentifier(callback.parameters[0].name) && + callback.parameters[0].name.text === 'assert' + ) { + if (ts.isFunctionExpression(callback)) { + updatedArgs[1] = ts.factory.updateFunctionExpression( + callback, + callback.modifiers, + callback.asteriskToken, + callback.name, + callback.typeParameters, + [], + callback.type, + callback.body, + ); + } else { + updatedArgs[1] = ts.factory.updateArrowFunction( + callback, + callback.modifiers, + callback.typeParameters, + [], + callback.type, + callback.equalsGreaterThanToken, + callback.body, + ); + } + } + } + + return ts.factory.updateCallExpression( + visitedNode, + ts.factory.createIdentifier(replacementName), + visitedNode.typeArguments, + updatedArgs, + ); + } + + return visitedNode; + } + + if (ts.isVariableDeclaration(node)) { + if ( + ts.isIdentifier(node.name) && + node.name.text === 'waitForBillingNotification' && + node.initializer && + (ts.isFunctionExpression(node.initializer) || + ts.isArrowFunction(node.initializer)) && + node.initializer.parameters.length >= 2 && + ts.isIdentifier(node.initializer.parameters[0].name) && + node.initializer.parameters[0].name.text === 'assert' && + ts.isIdentifier(node.initializer.parameters[1].name) && + node.initializer.parameters[1].name.text === 'done' + ) { + let rewritten = rewriteWaitRetryFunction(node.initializer); + let visitedRewritten = ts.visitEachChild(rewritten, visit, context) as + | ts.FunctionExpression + | ts.ArrowFunction; + return ts.factory.updateVariableDeclaration( + node, + node.name, + node.exclamationToken, + node.type, + visitedRewritten, + ); + } + } + + return ts.visitEachChild(node, visit, context); + }; + + return (node) => ts.visitNode(node, visit) as ts.SourceFile; + }; + + let transformed = ts.transform(sourceFile, [transformer]).transformed[0]; + let statements = [...transformed.statements].filter((s) => { + return !( + ts.isImportDeclaration(s) && + ts.isStringLiteral(s.moduleSpecifier) && + s.moduleSpecifier.text === 'qunit' + ); + }); + + statements = ensureVitestImport( + statements, + needsVitestHookImport ? ['beforeAll', 'beforeEach', 'afterAll', 'afterEach'] : [], + ); + + let usedBeforePrelude = collectUsedIdentifiers(statements); + let needsFilenamePolyfill = usedBeforePrelude.has('__filename'); + let needsDirnamePolyfill = usedBeforePrelude.has('__dirname'); + if (needsDirnamePolyfill) { + needsFilenamePolyfill = true; + } + statements = ensureFilenameDirnamePrelude( + statements, + needsFilenamePolyfill, + needsDirnamePolyfill, + ); + statements = pruneUnusedPathLikeImports(statements); + + let updatedSourceFile = ts.factory.updateSourceFile(transformed, statements); + let printer = ts.createPrinter({ + newLine: ts.NewLineKind.LineFeed, + removeComments: false, + }); + let printed = printer.printFile(updatedSourceFile).trimEnd() + '\n'; + printed = printed.replace(/(['"`])\.\.\/tests\//g, '$1./'); + + return { + code: `${generatedHeader}${printed}`, + needsVitestHookImport, + needsDirnamePolyfill, + needsFilenamePolyfill, + unsupportedCalls: [...new Set(unsupportedCalls)].sort(), + }; +} + +function isTestFile(relPath: string): boolean { + return relPath.endsWith('-test.ts'); +} + +function targetPathForTest(relPath: string): string { + return join(targetRoot, relPath.replace(/-test\.ts$/, '.test.ts')); +} + +function copySupportFiles(allFiles: string[]): number { + let copied = 0; + for (let absPath of allFiles) { + let relPath = relative(sourceRoot, absPath); + if (isTestFile(relPath)) { + continue; + } + let outPath = join(targetRoot, relPath); + ensureDirSync(dirname(outPath)); + copyFileSync(absPath, outPath); + copied++; + } + return copied; +} + +function run() { + let allFiles = glob.sync('**/*', { + cwd: sourceRoot, + absolute: true, + nodir: true, + dot: true, + }); + + ensureDirSync(targetRoot); + + let copied = copySupportFiles(allFiles); + let transformedCount = 0; + let unsupportedByFile = new Map(); + + for (let absPath of allFiles) { + let relPath = relative(sourceRoot, absPath); + if (!isTestFile(relPath)) { + continue; + } + let sourceCode = readFileSync(absPath, 'utf8'); + let outPath = targetPathForTest(relPath); + let result = transformQUnitToVitest(sourceCode, outPath, basename(relPath)); + + ensureDirSync(dirname(outPath)); + writeFileSync(outPath, result.code, 'utf8'); + transformedCount++; + + if (result.unsupportedCalls.length) { + unsupportedByFile.set(relPath, result.unsupportedCalls); + } + } + + console.log( + `QUnit -> Vitest codemod complete: transformed ${transformedCount} test files, copied ${copied} support files.`, + ); + if (unsupportedByFile.size) { + console.log('Files with unsupported assert calls (manual follow-up):'); + for (let [file, calls] of unsupportedByFile.entries()) { + console.log(`- ${file}`); + for (let call of calls) { + console.log(` - ${call}`); + } + } + } +} + +run(); diff --git a/packages/realm-server/tests-vitest/.gitignore b/packages/realm-server/tests-vitest/.gitignore new file mode 100644 index 00000000000..93728c24ccb --- /dev/null +++ b/packages/realm-server/tests-vitest/.gitignore @@ -0,0 +1,7 @@ +# tests-vitest/ is auto-generated by scripts/codemods/qunit-to-vitest.ts from +# the QUnit suite in tests/. Files graduate to vitest one at a time: each one +# gets allowlisted here and added to vitest.config.ts include[] once it passes. +# Everything else stays out of git until proven to run. +/* +!.gitignore +!async-semaphore.test.ts diff --git a/packages/realm-server/tests-vitest/async-semaphore.test.ts b/packages/realm-server/tests-vitest/async-semaphore.test.ts new file mode 100644 index 00000000000..55956182717 --- /dev/null +++ b/packages/realm-server/tests-vitest/async-semaphore.test.ts @@ -0,0 +1,331 @@ +// AUTO-GENERATED by scripts/codemods/qunit-to-vitest.ts. Run codemod again instead of editing by hand. +import { describe, test, expect } from "vitest"; +import { AsyncSemaphore } from '../prerender/async-semaphore'; +import { isPrerenderCancellation } from '../prerender/prerender-cancel'; +// Tests for AsyncSemaphore's resize-aware behaviour. The cancellation +// tests live in prerender-cancellation-test.ts; this file owns the +// contract of `setCapacity(n)` plus the in-flight tracking the resize +// requires. +// +// What we pin down here: +// 1. The basic invariants (capacity / inUseCount / pendingCount) +// remain correct when in-flight slots cross the cap because of a +// shrink — i.e. release() decrements inUseCount monotonically and +// doesn't admit new waiters while inUse > capacity. +// 2. setCapacity(grow) wakes queued waiters up to the new cap, in +// FIFO order, in a single pass — not one wake per future release. +// 3. setCapacity(shrink) is best-effort: never preempts in-flight, +// stalls future admissions until inUse falls under the new cap. +// 4. Edge cases: clamping to 1, no-op resize, resize while empty, +// cancelled waiters mixed with grow. +describe("async-semaphore-test.ts", function () { + describe('AsyncSemaphore basic state', function () { + test('reports correct counts at construction', function () { + let sem = new AsyncSemaphore(3); + expect(sem.capacity).toBe(3); + expect(sem.inUseCount).toBe(0); + expect(sem.pendingCount).toBe(0); + }); + test('clamps construction capacity to 1 minimum', function () { + let sem = new AsyncSemaphore(0); + expect(sem.capacity).toBe(1); + let sem2 = new AsyncSemaphore(-5); + expect(sem2.capacity).toBe(1); + }); + test('rejects non-finite construction capacity (NaN / Infinity)', function () { + // Addresses Codex P2 + Copilot review on PR 4589: `Math.max(1, NaN) + // === NaN` would have permanently stalled every future acquire + // because comparisons against NaN are always false. + let nanSem = new AsyncSemaphore(NaN); + expect(nanSem.capacity).toBe(1); + let infSem = new AsyncSemaphore(Infinity); + expect(infSem.capacity).toBe(1); + let negInfSem = new AsyncSemaphore(-Infinity); + expect(negInfSem.capacity).toBe(1); + }); + test('floors fractional construction capacity', function () { + // `#inUse` is an integer counter, so a fractional cap (e.g. 2.7) + // would let `2 < 2.7` admit a 3rd holder despite operator intent + // of "two slots". + let sem = new AsyncSemaphore(2.7); + expect(sem.capacity).toBe(2); + let sem2 = new AsyncSemaphore(0.9); + expect(sem2.capacity).toBe(1); + }); + test('inUseCount tracks acquire / release directly', async function () { + let sem = new AsyncSemaphore(2); + let r1 = await sem.acquire(); + expect(sem.inUseCount).toBe(1); + let r2 = await sem.acquire(); + expect(sem.inUseCount).toBe(2); + r1(); + expect(sem.inUseCount).toBe(1); + r2(); + expect(sem.inUseCount).toBe(0); + }); + test('pendingCount reflects queued waiters', async function () { + let sem = new AsyncSemaphore(1); + let r1 = await sem.acquire(); + let p2 = sem.acquire(); + let p3 = sem.acquire(); + // Allow microtask flush so the queueing has settled. + await Promise.resolve(); + expect(sem.pendingCount).toBe(2); + r1(); + let r2 = await p2; + expect(sem.pendingCount).toBe(1); + r2(); + let r3 = await p3; + expect(sem.pendingCount).toBe(0); + r3(); + }); + }); + describe('setCapacity grow', function () { + test('grow wakes queued waiters up to the new cap', async function () { + let sem = new AsyncSemaphore(1); + let r1 = await sem.acquire(); + let acquired: number[] = []; + let p2 = sem.acquire().then((r) => { + acquired.push(2); + return r; + }); + let p3 = sem.acquire().then((r) => { + acquired.push(3); + return r; + }); + let p4 = sem.acquire().then((r) => { + acquired.push(4); + return r; + }); + await Promise.resolve(); + expect(sem.pendingCount).toBe(3); + expect(acquired).toEqual([]); + // Grow to 3: should wake exactly 2 waiters (1 in-flight + 2 new + // = 3 total). + sem.setCapacity(3); + let r2 = await p2; + let r3 = await p3; + expect(acquired).toEqual([2, 3]); + expect(sem.inUseCount).toBe(3); + expect(sem.pendingCount).toBe(1); + // Grow further: should wake the last one. + sem.setCapacity(4); + let r4 = await p4; + expect(acquired).toEqual([2, 3, 4]); + expect(sem.inUseCount).toBe(4); + expect(sem.pendingCount).toBe(0); + r1(); + r2(); + r3(); + r4(); + expect(sem.inUseCount).toBe(0); + }); + test('grow with empty queue is a no-op against state', function () { + let sem = new AsyncSemaphore(2); + sem.setCapacity(5); + expect(sem.capacity).toBe(5); + expect(sem.inUseCount).toBe(0); + expect(sem.pendingCount).toBe(0); + }); + test('grow when not saturated does not over-admit', async function () { + let sem = new AsyncSemaphore(3); + let r1 = await sem.acquire(); + sem.setCapacity(5); + expect(sem.inUseCount).toBe(1); + expect(sem.capacity).toBe(5); + // Verify we can still acquire up to the new cap. + let r2 = await sem.acquire(); + let r3 = await sem.acquire(); + let r4 = await sem.acquire(); + let r5 = await sem.acquire(); + expect(sem.inUseCount).toBe(5); + r1(); + r2(); + r3(); + r4(); + r5(); + }); + }); + describe('setCapacity shrink', function () { + test('shrink does not preempt in-flight slots', async function () { + let sem = new AsyncSemaphore(5); + let r1 = await sem.acquire(); + let r2 = await sem.acquire(); + let r3 = await sem.acquire(); + let r4 = await sem.acquire(); + let r5 = await sem.acquire(); + expect(sem.inUseCount).toBe(5); + sem.setCapacity(2); + expect(sem.capacity).toBe(2); + expect(sem.inUseCount).toBe(5); + r1(); + r2(); + r3(); + expect(sem.inUseCount).toBe(2); + r4(); + r5(); + expect(sem.inUseCount).toBe(0); + }); + test('shrink stalls new acquires until inUse drops under the new cap', async function () { + let sem = new AsyncSemaphore(4); + let r1 = await sem.acquire(); + let r2 = await sem.acquire(); + let r3 = await sem.acquire(); + // Shrink to 2 with 3 in-flight (over-cap by 1). + sem.setCapacity(2); + let admitted = false; + let p4 = sem.acquire().then((r) => { + admitted = true; + return r; + }); + await Promise.resolve(); + expect(admitted).toBe(false); + expect(sem.pendingCount).toBe(1); + // Drop one in-flight: still over-cap (2 in-flight, cap 2). Should + // not admit. + r1(); + await Promise.resolve(); + expect(admitted).toBe(false); + // Drop another: now under-cap (1 in-flight, cap 2). Waiter wakes. + r2(); + let r4 = await p4; + expect(admitted).toBe(true); + expect(sem.inUseCount).toBe(2); + r3(); + r4(); + expect(sem.inUseCount).toBe(0); + }); + test('grow then shrink in same tick preserves correct count', async function () { + let sem = new AsyncSemaphore(2); + let r1 = await sem.acquire(); + let r2 = await sem.acquire(); + let p3 = sem.acquire(); + let p4 = sem.acquire(); + await Promise.resolve(); + expect(sem.pendingCount).toBe(2); + sem.setCapacity(4); + let r3 = await p3; + let r4 = await p4; + expect(sem.inUseCount).toBe(4); + expect(sem.pendingCount).toBe(0); + sem.setCapacity(1); + expect(sem.capacity).toBe(1); + expect(sem.inUseCount).toBe(4); + r1(); + r2(); + r3(); + expect(sem.inUseCount).toBe(1); + r4(); + expect(sem.inUseCount).toBe(0); + }); + test('shrink to 0 clamps to 1', function () { + let sem = new AsyncSemaphore(3); + sem.setCapacity(0); + expect(sem.capacity).toBe(1); + sem.setCapacity(-2); + expect(sem.capacity).toBe(1); + }); + test('rejects non-finite + fractional resize values', function () { + // Same Codex/Copilot concern as the constructor — protected here + // because PagePool dynamic resize (PR 7) reads env-vars and + // arithmetic results, both of which can produce NaN/floats. + let sem = new AsyncSemaphore(3); + sem.setCapacity(NaN); + expect(sem.capacity).toBe(1); + sem.setCapacity(Infinity); + expect(sem.capacity).toBe(1); + sem.setCapacity(4.7); + expect(sem.capacity).toBe(4); + }); + test('no-op when new capacity equals current', function () { + let sem = new AsyncSemaphore(3); + sem.setCapacity(3); + expect(sem.capacity).toBe(3); + expect(sem.inUseCount).toBe(0); + }); + }); + describe('setCapacity interactions with cancellation', function () { + test('cancelled waiter is skipped during grow wake', async function () { + let sem = new AsyncSemaphore(1); + let r1 = await sem.acquire(); + let ac = new AbortController(); + let pCancelled = sem.acquire(ac.signal).then(() => 'acquired', (e) => (isPrerenderCancellation(e) ? 'cancelled' : 'other')); + let acquiredAfter: number[] = []; + let pNext = sem.acquire().then((r) => { + acquiredAfter.push(1); + return r; + }); + await Promise.resolve(); + expect(sem.pendingCount).toBe(2); + // Cancel the first waiter: it gets spliced out of the queue. + ac.abort('test'); + expect(await pCancelled).toBe('cancelled'); + expect(sem.pendingCount).toBe(1); + // Grow: the surviving waiter wakes. + sem.setCapacity(2); + let r2 = await pNext; + expect(acquiredAfter).toEqual([1]); + expect(sem.inUseCount).toBe(2); + r1(); + r2(); + }); + test('cancellation while slot in-flight does not corrupt count after a shrink', async function () { + let sem = new AsyncSemaphore(3); + let r1 = await sem.acquire(); + let r2 = await sem.acquire(); + let r3 = await sem.acquire(); + // Shrink to 1 — over-cap by 2. + sem.setCapacity(1); + expect(sem.inUseCount).toBe(3); + // Queue a waiter under a signal we'll abort before any release. + let ac = new AbortController(); + let pCancelled = sem.acquire(ac.signal).then(() => 'acquired', (e) => (isPrerenderCancellation(e) ? 'cancelled' : 'other')); + await Promise.resolve(); + expect(sem.pendingCount).toBe(1); + ac.abort(); + expect(await pCancelled).toBe('cancelled'); + expect(sem.pendingCount).toBe(0); + // Drain: no waiters should magically appear. + r1(); + r2(); + r3(); + expect(sem.inUseCount).toBe(0); + expect(sem.pendingCount).toBe(0); + }); + }); + describe('AsyncSemaphore concurrent operations', function () { + test('many concurrent acquires + setCapacity admits exactly N', async function () { + let sem = new AsyncSemaphore(1); + let acquired = 0; + let releases: Array<() => void> = []; + // Queue 10 acquirers. Only the first one will fit at cap=1. + let acquirers = Array.from({ length: 10 }, () => sem.acquire().then((r) => { + acquired++; + releases.push(r); + })); + await Promise.resolve(); + expect(acquired).toBe(1); + expect(sem.pendingCount).toBe(9); + // Grow to 5: 4 more should wake (5 - 1 already in flight = 4 + // immediate hand-offs). + sem.setCapacity(5); + // Allow promise resolutions to flush. + await Promise.resolve(); + await Promise.resolve(); + expect(acquired).toBe(5); + expect(sem.pendingCount).toBe(5); + // Drain by releasing one at a time. Each release should wake one + // waiter until queue empties. + while (releases.length > 0) { + let r = releases.shift()!; + r(); + await Promise.resolve(); + await Promise.resolve(); + } + await Promise.all(acquirers); + expect(acquired).toBe(10); + expect(sem.inUseCount).toBe(0); + expect(sem.pendingCount).toBe(0); + }); + }); +}); diff --git a/packages/realm-server/vitest.config.ts b/packages/realm-server/vitest.config.ts new file mode 100644 index 00000000000..6515f7df8df --- /dev/null +++ b/packages/realm-server/vitest.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vitest/config'; + +// CS-10346 proof-of-concept: a single leaf test runs under vitest while the +// rest of the realm-server suite stays on QUnit. The codemod regenerates +// tests-vitest/ from tests/, so include only what's been vetted to pass and +// extend the list as files graduate. +export default defineConfig({ + test: { + include: ['tests-vitest/async-semaphore.test.ts'], + globals: false, + testTimeout: 60000, + pool: 'threads', + poolOptions: { + threads: { singleThread: true }, + }, + sequence: { + hooks: 'list', + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc3021a284a..cc8f5a582fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2832,6 +2832,9 @@ importers: uuid: specifier: 'catalog:' version: 9.0.1 + vitest: + specifier: 'catalog:' + version: 2.1.9(@types/node@24.12.4)(jsdom@21.1.2)(lightningcss@1.32.0)(terser@5.47.1) wait-for-localhost-cli: specifier: 'catalog:' version: 3.2.0 @@ -18716,7 +18719,10 @@ snapshots: dependencies: '@percy/cli-command': 1.31.13(typescript@5.9.3) transitivePeerDependencies: + - bufferutil + - supports-color - typescript + - utf-8-validate '@percy/cli-command@1.31.13(typescript@5.9.3)': dependencies: @@ -18733,7 +18739,10 @@ snapshots: dependencies: '@percy/cli-command': 1.31.13(typescript@5.9.3) transitivePeerDependencies: + - bufferutil + - supports-color - typescript + - utf-8-validate '@percy/cli-doctor@1.31.13(typescript@5.9.3)': dependencies: @@ -18769,7 +18778,10 @@ snapshots: '@percy/cli-command': 1.31.13(typescript@5.9.3) yaml: 2.9.0 transitivePeerDependencies: + - bufferutil + - supports-color - typescript + - utf-8-validate '@percy/cli-upload@1.31.13(typescript@5.9.3)': dependencies: @@ -18777,7 +18789,10 @@ snapshots: fast-glob: 3.3.3 image-size: 1.2.1 transitivePeerDependencies: + - bufferutil + - supports-color - typescript + - utf-8-validate '@percy/cli@1.31.13(typescript@5.9.3)': dependencies: @@ -30469,6 +30484,42 @@ snapshots: tsx: 4.21.0 yaml: 2.9.0 + vitest@2.1.9(@types/node@24.12.4)(jsdom@21.1.2)(lightningcss@1.32.0)(terser@5.47.1): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@24.12.4)(lightningcss@1.32.0)(terser@5.47.1)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3(supports-color@8.1.1) + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@24.12.4)(lightningcss@1.32.0)(terser@5.47.1) + vite-node: 2.1.9(@types/node@24.12.4)(lightningcss@1.32.0)(terser@5.47.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.12.4 + jsdom: 21.1.2 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vitest@2.1.9(@types/node@24.12.4)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.47.1): dependencies: '@vitest/expect': 2.1.9 From c193b2d83dc1a899a955de7fcc4e01097b0d52b3 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Thu, 14 May 2026 17:23:48 -0400 Subject: [PATCH 2/2] CS-10346: wire vitest job into CI; document workspace-dep resolver issue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adds a `realm-server-vitest-test` job gated on the same path filter as the main realm-server-test matrix. Currently runs the single async-semaphore test (18 assertions, ~100ms) — the harness is the point, not the coverage. As more files graduate, the job picks them up via vitest.config.ts include[]. - Documents in vitest.config.ts why other leaf candidates can't graduate yet: vite eager-walks @cardstack/runtime-common's TS source graph and can't resolve transitive pnpm-hoisted deps (acorn, magic-string) that aren't direct deps of realm-server. server.deps.external / ssr.external don't intercept the transform path. Stage 1.b will resolve this; until then only tests that don't import runtime-common can move. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yaml | 15 +++++++++++++++ packages/realm-server/vitest.config.ts | 12 ++++++++++++ 2 files changed, 27 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 81d0e82d5fc..27094d491ae 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -671,6 +671,21 @@ jobs: path: /tmp/host-dist.log retention-days: 30 + realm-server-vitest-test: + name: Realm Server Vitest Tests + needs: [change-check] + if: needs.change-check.outputs.realm-server == 'true' || github.ref == 'refs/heads/main' || needs.change-check.outputs.run_all == 'true' + runs-on: ubuntu-latest + concurrency: + group: realm-server-vitest-test-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/init + - name: Vitest suite (CS-10346 migration) + run: pnpm test:vitest + working-directory: packages/realm-server + realm-server-merge-reports: name: Merge Realm Server reports and publish if: ${{ !cancelled() && (needs.change-check.outputs.realm-server == 'true' || github.ref == 'refs/heads/main' || needs.change-check.outputs.run_all == 'true') }} diff --git a/packages/realm-server/vitest.config.ts b/packages/realm-server/vitest.config.ts index 6515f7df8df..e142bbb76a0 100644 --- a/packages/realm-server/vitest.config.ts +++ b/packages/realm-server/vitest.config.ts @@ -4,6 +4,18 @@ import { defineConfig } from 'vitest/config'; // rest of the realm-server suite stays on QUnit. The codemod regenerates // tests-vitest/ from tests/, so include only what's been vetted to pass and // extend the list as files graduate. +// +// Stage 1.b TODO: vite's SSR transform eager-walks @cardstack/runtime-common's +// export graph and fails to resolve transitive node_module deps (acorn, +// magic-string, ...) that are pnpm-hoisted under runtime-common but not direct +// deps of realm-server. Externalizing via server.deps.external / ssr.external +// doesn't intercept the transform path. Solutions to evaluate: +// - declare acorn/magic-string as devDependencies of realm-server (matches +// what Node + ts-node see today via pnpm's hoisted layout); +// - a custom resolve plugin that delegates to Node's require.resolve; +// - precompile runtime-common to dist/ so vite reads JS, not TS source. +// Until this is solved, only tests that don't import @cardstack/runtime-common +// can graduate to vitest. export default defineConfig({ test: { include: ['tests-vitest/async-semaphore.test.ts'],