diff --git a/biome.jsonc b/biome.jsonc index 0f269bb4..006da53a 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -5,8 +5,7 @@ "**", "!**/*.snap.cjs", "!**/fixtures", - "!**/expected", - "!**/input" + "!**/tests" ] }, "assist": { "actions": { "source": { "organizeImports": "off" } } }, diff --git a/package-lock.json b/package-lock.json index bd620faf..4cba130a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1552,6 +1552,10 @@ "resolved": "recipes/slow-buffer-to-buffer-alloc-unsafe-slow", "link": true }, + "node_modules/@nodejs/tape-to-node-test": { + "resolved": "recipes/tape-to-node-test", + "link": true + }, "node_modules/@nodejs/tmpdir-to-tmpdir": { "resolved": "recipes/tmpdir-to-tmpdir", "link": true @@ -4485,6 +4489,14 @@ "@codemod.com/jssg-types": "^1.3.0" } }, + "recipes/tape-to-node-test": { + "name": "@nodejs/tape-to-node-test", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@nodejs/codemod-utils": "*" + } + }, "recipes/tmpdir-to-tmpdir": { "name": "@nodejs/tmpdir-to-tmpdir", "version": "1.0.0", diff --git a/recipes/tape-to-node-test/README.md b/recipes/tape-to-node-test/README.md new file mode 100644 index 00000000..42e9d88b --- /dev/null +++ b/recipes/tape-to-node-test/README.md @@ -0,0 +1,143 @@ +# Tape to Node.js Test Runner Codemod + +This codemod migrates tests written using [`tape`](https://github.com/tape-testing/tape) v5 to the native Node.js test runner ([`node:test`](https://nodejs.org/api/test.html)). + +## Features + +- Replaces `tape` imports with `node:test` and `node:assert`. +- Converts `test(name, (t) => ...)` to `test(name, async (t) => ...)`. +- Maps `tape` assertions to `node:assert` equivalents, including many aliases (e.g., `t.is`, `t.equals`, `t.deepEquals`). +- Preserves `t.plan()` calls as-is (node:test supports plan-based assertions). +- Intelligently handles `t.end()`: + - Converts to `done()` callback when used with `t.plan()` or inside nested callbacks (e.g., `setTimeout`) + - Comments out when not needed in async tests + - Automatically adds `done` parameter to test callback when needed +- Handles `t.test` subtests (adds `await` and converts to `test()`). +- Converts `t.teardown` to `t.after`. +- Converts `t.comment` to `t.diagnostic`. +- Migrates `t.timeoutAfter(ms)` to `{ timeout: ms }` test option. +- Supports `test.skip` and `test.only`. +- Handles `test.onFinish` and `test.onFailure` (comments them out with TODO and warning). +- Supports loose equality assertions (e.g., `t.looseEqual` -> `assert.equal`). + +## Example + +### Basic Equality + +```diff +- import test from "tape"; ++ import { test } from 'node:test'; ++ import assert from 'node:assert'; + +- test("basic equality", (t) => { ++ test("basic equality", async (t) => { +- t.equal(1, 1, "equal numbers"); ++ assert.strictEqual(1, 1, "equal numbers"); +- t.notEqual(1, 2, "not equal numbers"); ++ assert.notStrictEqual(1, 2, "not equal numbers"); +- t.strictEqual(true, true, "strict equality"); ++ assert.strictEqual(true, true, "strict equality"); +- t.notStrictEqual("1", 1, "not strict equality"); ++ assert.notStrictEqual("1", 1, "not strict equality"); + }); +``` + +### Plan with End (Done Style) + +```diff +- import test from "tape"; ++ import { test } from 'node:test'; ++ import assert from 'node:assert'; + +- test("plan with end", (t) => { ++ test("plan with end", (t, done) => { + t.plan(2); +- t.equal(1, 1, "first assertion"); ++ assert.strictEqual(1, 1, "first assertion"); +- t.equal(2, 2, "second assertion"); ++ assert.strictEqual(2, 2, "second assertion"); +- t.end(); ++ done(); + }); +``` + +### Async Tests + +```diff +- import test from "tape"; ++ import { test } from 'node:test'; ++ import assert from 'node:assert'; + + function someAsyncThing() { + return new Promise((resolve) => setTimeout(() => resolve(true), 50)); + } + +- test("async test with promises", async (t) => { ++ test("async test with promises", async (t) => { + t.plan(1); + const result = await someAsyncThing(); +- t.ok(result, "async result is truthy"); ++ assert.ok(result, "async result is truthy"); + }); +``` + +### Callback Style + +```diff +- import test from "tape"; ++ import { test } from 'node:test'; ++ import assert from 'node:assert'; + +- test("callback style", (t) => { ++ test("callback style", (t, done) => { + setTimeout(() => { +- t.ok(true); ++ assert.ok(true); +- t.end(); ++ done(); + }, 100); + }); +``` + +### Timeout Handling + +```diff +- import test from "tape"; ++ import { test } from 'node:test'; ++ import assert from 'node:assert'; + +- test("timeout test", (t) => { +- t.timeoutAfter(100); ++ test("timeout test", { timeout: 100 }, async (t) => { +- t.ok(true); ++ assert.ok(true); + }); +``` + +### Dynamic Import + +```diff + async function run() { +- const test = await import("tape"); ++ const { test } = await import('node:test'); ++ const { default: assert } = await import('node:assert'); + +- test("dynamic import", (t) => { ++ test("dynamic import", async (t) => { +- t.ok(true); ++ assert.ok(true); +- t.end(); ++ // t.end(); + }); + } +``` + +## Known Limitations + +- `test.onFinish()` and `test.onFailure()` have no direct equivalent in `node:test` and will be commented out with a TODO. +- When `t.timeoutAfter()` is used with a variable options object (not inline), the codemod will add a TODO comment instead of automatically migrating it. +- `t.plan()` is preserved as-is since `node:test` TestContext supports it, but be aware of behavioral differences between Tape and Node.js test runner regarding plan validation. +- CLI migration, we don't touch to your `package.json` or test scripts. You will need to update them manually to use `node --test` command instead of `tape`. + +> [!WARNING] +> This codemod only migrate main `tape` package usage. If you use some "plugins" or additional packages (like `tape-promise`), you will need to handle them manually. diff --git a/recipes/tape-to-node-test/codemod.yaml b/recipes/tape-to-node-test/codemod.yaml new file mode 100644 index 00000000..e5128dd7 --- /dev/null +++ b/recipes/tape-to-node-test/codemod.yaml @@ -0,0 +1,23 @@ +schema_version: "1.0" +name: "@nodejs/tape-to-node-test" +version: "1.0.0" +description: Migrates Tape tests to Node.js native test runner +author: Node.js +license: MIT +workflow: workflow.yaml +category: migration + +targets: + languages: + - javascript + - typescript + +keywords: + - transformation + - migration + - tape + - node:test + +capabilities: + - fs + - child_process diff --git a/recipes/tape-to-node-test/package.json b/recipes/tape-to-node-test/package.json new file mode 100644 index 00000000..0dfefeff --- /dev/null +++ b/recipes/tape-to-node-test/package.json @@ -0,0 +1,20 @@ +{ + "name": "@nodejs/tape-to-node-test", + "version": "1.0.0", + "description": "Migrates Tape tests to Node.js native test runner", + "type": "module", + "scripts": { + "test": "npx codemod jssg test -l typescript ./src/workflow.ts" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/nodejs/userland-migrations.git", + "directory": "recipes/tape-to-node-test", + "bugs": "https://github.com/nodejs/userland-migrations/issues" + }, + "author": "Node.js", + "license": "MIT", + "dependencies": { + "@nodejs/codemod-utils": "*" + } +} diff --git a/recipes/tape-to-node-test/src/remove-dependencies.ts b/recipes/tape-to-node-test/src/remove-dependencies.ts new file mode 100644 index 00000000..6c58e5dd --- /dev/null +++ b/recipes/tape-to-node-test/src/remove-dependencies.ts @@ -0,0 +1,8 @@ +import removeDependencies from '@nodejs/codemod-utils/remove-dependencies'; + +/** + * Remove `tape` and `@types/tape` dependencies from package.json + */ +export default function removeTapeDependencies(): string | null { + return removeDependencies(['tape', '@types/tape']); +} diff --git a/recipes/tape-to-node-test/src/workflow.ts b/recipes/tape-to-node-test/src/workflow.ts new file mode 100644 index 00000000..51edab9e --- /dev/null +++ b/recipes/tape-to-node-test/src/workflow.ts @@ -0,0 +1,578 @@ +import { EOL } from 'node:os'; +import { + getNodeImportStatements, + getNodeImportCalls, +} from '@nodejs/codemod-utils/ast-grep/import-statement'; +import { getNodeRequireCalls } from '@nodejs/codemod-utils/ast-grep/require-call'; +import type { SgRoot, SgNode, Edit } from '@codemod.com/jssg-types/main'; +import type Js from '@codemod.com/jssg-types/langs/javascript'; + +/** + * Mapping of Tape assertions to Node.js assert module methods + */ +const ASSERTION_MAPPING: Record = { + equal: 'strictEqual', + notEqual: 'notStrictEqual', + strictEqual: 'strictEqual', + notStrictEqual: 'notStrictEqual', + deepEqual: 'deepStrictEqual', + notDeepEqual: 'notDeepStrictEqual', + looseEqual: 'equal', + notLooseEqual: 'notEqual', + ok: 'ok', + ifError: 'ifError', + error: 'ifError', + throws: 'throws', + doesNotThrow: 'doesNotThrow', + match: 'match', + doesNotMatch: 'doesNotMatch', + fail: 'fail', + same: 'deepStrictEqual', + notSame: 'notDeepStrictEqual', + // Aliases + assert: 'ok', + ifErr: 'ifError', + iferror: 'ifError', + equals: 'strictEqual', + isEqual: 'strictEqual', + strictEquals: 'strictEqual', + is: 'strictEqual', + notEquals: 'notStrictEqual', + isNotEqual: 'notStrictEqual', + doesNotEqual: 'notStrictEqual', + isInequal: 'notStrictEqual', + notStrictEquals: 'notStrictEqual', + isNot: 'notStrictEqual', + not: 'notStrictEqual', + looseEquals: 'equal', + notLooseEquals: 'notEqual', + deepEquals: 'deepStrictEqual', + isEquivalent: 'deepStrictEqual', + notDeepEquals: 'notDeepStrictEqual', + notEquivalent: 'notDeepStrictEqual', + notDeeply: 'notDeepStrictEqual', + isNotDeepEqual: 'notDeepStrictEqual', + isNotDeeply: 'notDeepStrictEqual', + isNotEquivalent: 'notDeepStrictEqual', + isInequivalent: 'notDeepStrictEqual', + deepLooseEqual: 'deepEqual', + notDeepLooseEqual: 'notDeepEqual', +}; + +export default function transform(root: SgRoot): string | null { + const rootNode = root.root(); + const edits: Edit[] = []; + + const tapeImports = getNodeImportStatements(root, 'tape'); + const tapeRequires = getNodeRequireCalls(root, 'tape'); + const tapeImportCalls = getNodeImportCalls(root, 'tape'); + + const modDeps = [ + ...tapeImports.map((node) => ({ + node, + import: `import { test } from 'node:test';${EOL}import assert from 'node:assert';`, + })), + ...tapeRequires.map((node) => ({ + node, + import: `const { test } = require('node:test');${EOL}const assert = require('node:assert');`, + })), + ...tapeImportCalls.map((node) => ({ + node, + import: `const { test } = await import('node:test');${EOL}const { default: assert } = await import('node:assert');`, + })), + ]; + + if (!modDeps.length) return null; + + let testVarName = 'test'; + + // 1. Replace imports + for (const mod of modDeps) { + if (mod.node.kind() === 'variable_declarator') { + mod.node = mod.node.parent(); + } + + const binding = mod.node.find({ + rule: { + any: [ + { kind: 'identifier', inside: { kind: 'variable_declarator' } }, + { + kind: 'identifier', + inside: { kind: 'import_clause', stopBy: 'end' }, + }, + ], + }, + }); + + if (binding) testVarName = binding.text(); + + edits.push(mod.node.replace(mod.import)); + } + + const testCalls = rootNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + regex: `^${testVarName}(\\.(skip|only))?$`, + }, + }, + }); + + // 2. Transform test calls and assertions + for (const call of testCalls) { + const func = call.field('function'); + if (func && testVarName !== 'test') { + if (func.kind() === 'identifier' && func.text() === testVarName) { + edits.push(func.replace('test')); + } else if (func.kind() === 'member_expression') { + const obj = func.field('object'); + if (obj && obj.text() === testVarName) { + edits.push(obj.replace('test')); + } + } + } + + const args = call.field('arguments'); + if (!args) continue; + + const callback = args + .children() + .find( + (c) => + c.kind() === 'arrow_function' || c.kind() === 'function_expression', + ); + if (callback) { + const params = callback.field('parameters'); + let tName = 't'; + const paramId = params?.find({ rule: { kind: 'identifier' } }); + if (paramId) { + tName = paramId.text(); + } + + const body = callback.field('body'); + let usesEndInCallback = false; + let hasEndCall = false; + let hasPlanCall = false; + if (body) { + const endCalls = body.findAll({ + rule: { + kind: 'call_expression', + all: [ + { + has: { + field: 'function', + kind: 'member_expression', + has: { field: 'object', pattern: tName }, + }, + }, + { + has: { + field: 'function', + kind: 'member_expression', + has: { field: 'property', regex: '^end$' }, + }, + }, + ], + }, + }); + + hasEndCall = endCalls.length > 0; + + for (const endCall of endCalls) { + let curr = endCall.parent(); + while (curr && curr.id() !== body.id()) { + if ( + curr.kind() === 'arrow_function' || + curr.kind() === 'function_expression' || + curr.kind() === 'function_declaration' + ) { + usesEndInCallback = true; + break; + } + curr = curr.parent(); + } + } + + hasPlanCall = Boolean( + body.find({ + rule: { + kind: 'call_expression', + all: [ + { + has: { + field: 'function', + kind: 'member_expression', + has: { field: 'object', pattern: tName }, + }, + }, + { + has: { + field: 'function', + kind: 'member_expression', + has: { field: 'property', regex: '^plan$' }, + }, + }, + ], + }, + }), + ); + } + + const isAsync = callback.text().startsWith('async'); + const shouldUseDone = hasEndCall && (usesEndInCallback || hasPlanCall); + + if (shouldUseDone && params) { + const hasDoneParam = Boolean( + params.find({ rule: { kind: 'identifier', regex: '^done$' } }), + ); + if (!hasDoneParam) { + const text = params.text(); + if (text.startsWith('(') && text.endsWith(')')) { + edits.push({ + startPos: params.range().end.index - 1, + endPos: params.range().end.index - 1, + insertedText: ', done', + }); + } else { + edits.push(params.replace(`(${text}, done)`)); + } + } + } + + if (body) { + // Apply assertion transformations first and determine whether they introduced + // async requirements (e.g., awaiting a subtest). + const assertionsRequireAsync = transformAssertions( + body, + tName, + edits, + call, + shouldUseDone, + ); + + // Determine if the callback needs to be async. + // Only add async when the original callback was async, it already contained awaits, + // or the assertion transformations inserted awaits for subtests. + const hasAwait = Boolean( + body.find({ rule: { kind: 'await_expression' } }), + ); + const needsAsync = isAsync || hasAwait || assertionsRequireAsync; + + if (needsAsync && !isAsync && params) { + edits.push({ + startPos: callback.range().start.index, + endPos: params.range().start.index, + insertedText: 'async ', + }); + } + } + } + } + + // 3. Handle test.onFinish and test.onFailure + const lifecycleCalls = rootNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + regex: `^${testVarName}\\.(onFinish|onFailure)$`, + }, + }, + }); + + for (const call of lifecycleCalls) { + const { line, column } = call.range().start; + const fileName = root.filename(); + const methodName = + call.field('function')?.field('property')?.text() || 'lifecycle method'; + + console.warn( + `[Codemod] Warning: ${methodName} at ${fileName}:${line}:${column} has no direct equivalent in node:test. Please migrate manually.`, + ); + + const lines = call.text().split(/\r?\n/); + const newText = lines + .map((line, i) => (i === 0 ? `// TODO: ${line}` : `// ${line}`)) + .join(EOL); + edits.push(call.replace(newText)); + } + + return rootNode.commitEdits(edits); +} + +/** + * Transform Tape assertions to Node.js assert module assertions + * + * @param node the AST node to transform + * @param tName the name of the test object (usually 't') + * @param edits the list of edits to apply + * @param testCall the AST node of the test function call + * @param useDone whether to use the done callback for ending tests + */ +function transformAssertions( + node: SgNode, + tName: string, + edits: Edit[], + testCall: SgNode, + useDone = false, +): boolean { + let requiresAsync = false; + const calls = node.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'member_expression', + has: { + field: 'object', + pattern: tName, + }, + }, + }, + }); + + for (const call of calls) { + const method = call.field('function')?.field('property')?.text(); + if (!method) continue; + + const args = call.field('arguments'); + const func = call.field('function'); + + if (ASSERTION_MAPPING[method]) { + const newMethod = ASSERTION_MAPPING[method]; + if (func) { + edits.push(func.replace(`assert.${newMethod}`)); + } + continue; + } + + switch (method.toLowerCase()) { + case 'notok': + // t.notOk(val, msg) -> assert.ok(!val, msg) + if (args) { + const val = args.child(1); // child(0) is '(' + if (val) { + edits.push({ + startPos: val.range().start.index, + endPos: val.range().start.index, + insertedText: '!', + }); + const func = call.field('function'); + if (func) edits.push(func.replace('assert.ok')); + } + } + break; + case 'comment': + if (func) edits.push(func.replace(`${tName}.diagnostic`)); + break; + case 'true': + if (func) edits.push(func.replace('assert.ok')); + break; + case 'false': + if (args) { + const val = args.child(1); + if (val) { + edits.push({ + startPos: val.range().start.index, + endPos: val.range().start.index, + insertedText: '!', + }); + if (func) edits.push(func.replace('assert.ok')); + } + } + break; + case 'pass': + if (args) { + // Insert 'true' as first arg + // args text is like "('msg')" or "()" + const openParen = args.child(0); + if (openParen) { + edits.push({ + startPos: openParen.range().end.index, + endPos: openParen.range().end.index, + insertedText: args.children().length > 2 ? 'true, ' : 'true', + }); + if (func) edits.push(func.replace('assert.ok')); + } + } + break; + case 'plan': + break; + case 'end': + if (useDone) { + edits.push(call.replace('done()')); + } else { + edits.push(call.replace(`// ${call.text()}`)); + } + break; + case 'test': { + const alreadyAwaited = call.parent()?.kind() === 'await_expression'; + let shouldAwaitSubtest = false; + const cb = args + ?.children() + .find( + (c) => + c.kind() === 'arrow_function' || + c.kind() === 'function_expression', + ); + if (cb) { + const p = cb.field('parameters'); + let stName = 't'; + const paramId = p?.find({ rule: { kind: 'identifier' } }); + if (paramId) stName = paramId.text(); + + const b = cb.field('body'); + const nestedRequiresAsync = b + ? transformAssertions(b, stName, edits, call) + : false; + const subtestHasAwait = Boolean( + b?.find({ rule: { kind: 'await_expression' } }), + ); + const subtestIsAsync = cb.text().startsWith('async'); + const subtestNeedsAsync = + subtestIsAsync || subtestHasAwait || nestedRequiresAsync; + + if (subtestNeedsAsync && !subtestIsAsync && p) { + edits.push({ + startPos: cb.range().start.index, + endPos: p.range().start.index, + insertedText: 'async ', + }); + } + + shouldAwaitSubtest = subtestNeedsAsync; + } + + if (shouldAwaitSubtest && !alreadyAwaited) { + edits.push({ + startPos: call.range().start.index, + endPos: call.range().start.index, + insertedText: 'await ', + }); + } + + requiresAsync = requiresAsync || shouldAwaitSubtest; + break; + } + case 'teardown': + if (func) edits.push(func.replace(`${tName}.after`)); + break; + case 'timeoutafter': { + const timeoutArg = args?.child(1); // child(0) is '(' + if (timeoutArg) { + const timeoutVal = timeoutArg.text(); + + // Add to test options + const testArgs = testCall.field('arguments'); + if (testArgs) { + const children = testArgs.children(); + // children[0] is '(', children[last] is ')' + // args are in between. + // We expect: + // 1. test('name', cb) -> insert options + // 2. test('name', opts, cb) -> update options + + // Filter out punctuation to get actual args + const actualArgs = children.filter( + (c) => + c.kind() !== '(' && + c.kind() !== ')' && + c.kind() !== ',' && + c.kind() !== 'comment', + ); + + if (actualArgs.length === 2) { + // test('name', cb) + // Insert options as 2nd arg + const cbArg = actualArgs[1]; + edits.push({ + startPos: cbArg.range().start.index, + endPos: cbArg.range().start.index, + insertedText: `{ timeout: ${timeoutVal} }, `, + }); + // remove the original timeout call + const parent = call.parent(); + if (parent && parent.kind() === 'expression_statement') { + edits.push(parent.replace('')); + } else { + edits.push(call.replace('')); + } + } else if (actualArgs.length === 3) { + // test('name', opts, cb) + const optsArg = actualArgs[1]; + if (optsArg.kind() === 'object') { + // Add property to object + const props = optsArg + .children() + .filter((c) => c.kind() === 'pair'); + if (props.length > 0) { + const lastProp = props[props.length - 1]; + edits.push({ + startPos: lastProp.range().end.index, + endPos: lastProp.range().end.index, + insertedText: `, timeout: ${timeoutVal}`, + }); + // remove the original timeout call + const parent = call.parent(); + if (parent && parent.kind() === 'expression_statement') { + edits.push(parent.replace('')); + } else { + edits.push(call.replace('')); + } + } else { + // Empty object {} + // We need to find where to insert. + // It's safer to replace the whole object if it's empty, or find the closing brace. + const closingBrace = optsArg + .children() + .find((c) => c.text() === '}'); + if (closingBrace) { + edits.push({ + startPos: closingBrace.range().start.index, + endPos: closingBrace.range().start.index, + insertedText: ` timeout: ${timeoutVal} `, + }); + // remove the original timeout call + const parent = call.parent(); + if (parent && parent.kind() === 'expression_statement') { + edits.push(parent.replace('')); + } else { + edits.push(call.replace('')); + } + } + } + } else { + // Options is a variable or expression — replace the timeout call with a TODO comment and warning + const { line, column } = call.range().start; + const fileName = node.getRoot().filename(); + console.warn( + `[Codemod] Warning: Unable to automatically add timeout option at ${fileName}:${line}:${column}. Please add it manually.`, + ); + edits.push( + call.replace( + `// TODO(codemod@nodejs/tape-to-node-test): Add timeout: \`${timeoutVal}\` to test options manually`, + ), + ); + } + } + } else { + // If we couldn't find the test call args, remove the timeout call + const parent = call.parent(); + if (parent && parent.kind() === 'expression_statement') { + edits.push(parent.replace('')); + } else { + edits.push(call.replace('')); + } + } + } + + break; + } + default: + console.log(`Warning: Unhandled Tape assertion method: ${method}`); + } + } + + return requiresAsync; +} diff --git a/recipes/tape-to-node-test/tests/advanced-assertions/expected.js b/recipes/tape-to-node-test/tests/advanced-assertions/expected.js new file mode 100644 index 00000000..1f69d398 --- /dev/null +++ b/recipes/tape-to-node-test/tests/advanced-assertions/expected.js @@ -0,0 +1,12 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; + +test('advanced assertions', (t) => { + assert.throws(() => { throw new Error('fail'); }, /fail/); + assert.doesNotThrow(() => { }); + assert.match('string', /ring/); + assert.doesNotMatch('string', /gnirt/); + assert.fail('this should fail'); + assert.ifError(null); + assert.ifError(null); +}); diff --git a/recipes/tape-to-node-test/tests/advanced-assertions/input.js b/recipes/tape-to-node-test/tests/advanced-assertions/input.js new file mode 100644 index 00000000..56934e15 --- /dev/null +++ b/recipes/tape-to-node-test/tests/advanced-assertions/input.js @@ -0,0 +1,11 @@ +import test from 'tape'; + +test('advanced assertions', (t) => { + t.throws(() => { throw new Error('fail'); }, /fail/); + t.doesNotThrow(() => { }); + t.match('string', /ring/); + t.doesNotMatch('string', /gnirt/); + t.fail('this should fail'); + t.error(null); + t.ifError(null); +}); diff --git a/recipes/tape-to-node-test/tests/aliased-import/expected.js b/recipes/tape-to-node-test/tests/aliased-import/expected.js new file mode 100644 index 00000000..ed7fb75d --- /dev/null +++ b/recipes/tape-to-node-test/tests/aliased-import/expected.js @@ -0,0 +1,6 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; + +test('aliased test', (t) => { + assert.strictEqual(1, 1); +}); diff --git a/recipes/tape-to-node-test/tests/aliased-import/input.js b/recipes/tape-to-node-test/tests/aliased-import/input.js new file mode 100644 index 00000000..200fffc6 --- /dev/null +++ b/recipes/tape-to-node-test/tests/aliased-import/input.js @@ -0,0 +1,5 @@ +import myTest from 'tape'; + +myTest('aliased test', (t) => { + t.equal(1, 1); +}); diff --git a/recipes/tape-to-node-test/tests/async-test/expected.js b/recipes/tape-to-node-test/tests/async-test/expected.js new file mode 100644 index 00000000..2316789c --- /dev/null +++ b/recipes/tape-to-node-test/tests/async-test/expected.js @@ -0,0 +1,12 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; + +function someAsyncThing() { + return new Promise((resolve) => setTimeout(() => resolve(true), 50)); +} + +test("async test with promises", async (t) => { + t.plan(1); + const result = await someAsyncThing(); + assert.ok(result, "async result is truthy"); +}); diff --git a/recipes/tape-to-node-test/tests/async-test/input.js b/recipes/tape-to-node-test/tests/async-test/input.js new file mode 100644 index 00000000..2ff9beb7 --- /dev/null +++ b/recipes/tape-to-node-test/tests/async-test/input.js @@ -0,0 +1,11 @@ +import test from "tape"; + +function someAsyncThing() { + return new Promise((resolve) => setTimeout(() => resolve(true), 50)); +} + +test("async test with promises", async (t) => { + t.plan(1); + const result = await someAsyncThing(); + t.ok(result, "async result is truthy"); +}); diff --git a/recipes/tape-to-node-test/tests/basic-equality/expected.js b/recipes/tape-to-node-test/tests/basic-equality/expected.js new file mode 100644 index 00000000..42b2e342 --- /dev/null +++ b/recipes/tape-to-node-test/tests/basic-equality/expected.js @@ -0,0 +1,9 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; + +test("basic equality", (t) => { + assert.strictEqual(1, 1, "equal numbers"); + assert.notStrictEqual(1, 2, "not equal numbers"); + assert.strictEqual(true, true, "strict equality"); + assert.notStrictEqual("1", 1, "not strict equality"); +}); diff --git a/recipes/tape-to-node-test/tests/basic-equality/input.js b/recipes/tape-to-node-test/tests/basic-equality/input.js new file mode 100644 index 00000000..fdb3efa2 --- /dev/null +++ b/recipes/tape-to-node-test/tests/basic-equality/input.js @@ -0,0 +1,8 @@ +import test from "tape"; + +test("basic equality", (t) => { + t.equal(1, 1, "equal numbers"); + t.notEqual(1, 2, "not equal numbers"); + t.strictEqual(true, true, "strict equality"); + t.notStrictEqual("1", 1, "not strict equality"); +}); diff --git a/recipes/tape-to-node-test/tests/callback-style/expected.js b/recipes/tape-to-node-test/tests/callback-style/expected.js new file mode 100644 index 00000000..2921a722 --- /dev/null +++ b/recipes/tape-to-node-test/tests/callback-style/expected.js @@ -0,0 +1,8 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; + +test("callback style", (t) => { + setTimeout(() => { + assert.ok(true); + }, 100); +}); diff --git a/recipes/tape-to-node-test/tests/callback-style/input.js b/recipes/tape-to-node-test/tests/callback-style/input.js new file mode 100644 index 00000000..5fc7745e --- /dev/null +++ b/recipes/tape-to-node-test/tests/callback-style/input.js @@ -0,0 +1,7 @@ +import test from "tape"; + +test("callback style", (t) => { + setTimeout(() => { + t.ok(true); + }, 100); +}); diff --git a/recipes/tape-to-node-test/tests/cjs-destructuring/expected.js b/recipes/tape-to-node-test/tests/cjs-destructuring/expected.js new file mode 100644 index 00000000..40ada317 --- /dev/null +++ b/recipes/tape-to-node-test/tests/cjs-destructuring/expected.js @@ -0,0 +1,6 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); + +test('cjs destructuring', (t) => { + assert.ok(true); +}); diff --git a/recipes/tape-to-node-test/tests/cjs-destructuring/input.js b/recipes/tape-to-node-test/tests/cjs-destructuring/input.js new file mode 100644 index 00000000..d80dc155 --- /dev/null +++ b/recipes/tape-to-node-test/tests/cjs-destructuring/input.js @@ -0,0 +1,5 @@ +const { test } = require('tape'); + +test('cjs destructuring', (t) => { + t.ok(true); +}); diff --git a/recipes/tape-to-node-test/tests/deep-equality/expected.js b/recipes/tape-to-node-test/tests/deep-equality/expected.js new file mode 100644 index 00000000..3c5bb03d --- /dev/null +++ b/recipes/tape-to-node-test/tests/deep-equality/expected.js @@ -0,0 +1,8 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; + +test("deep equality", (t) => { + t.plan(2); + assert.deepStrictEqual({ a: 1 }, { a: 1 }, "objects are deeply equal"); + assert.notDeepStrictEqual({ a: 1 }, { a: 2 }, "objects are not deeply equal"); +}); diff --git a/recipes/tape-to-node-test/tests/deep-equality/input.js b/recipes/tape-to-node-test/tests/deep-equality/input.js new file mode 100644 index 00000000..9ec78c68 --- /dev/null +++ b/recipes/tape-to-node-test/tests/deep-equality/input.js @@ -0,0 +1,7 @@ +import test from "tape"; + +test("deep equality", (t) => { + t.plan(2); + t.deepEqual({ a: 1 }, { a: 1 }, "objects are deeply equal"); + t.notDeepEqual({ a: 1 }, { a: 2 }, "objects are not deeply equal"); +}); diff --git a/recipes/tape-to-node-test/tests/dynamic-import/expected.js b/recipes/tape-to-node-test/tests/dynamic-import/expected.js new file mode 100644 index 00000000..a6405d25 --- /dev/null +++ b/recipes/tape-to-node-test/tests/dynamic-import/expected.js @@ -0,0 +1,8 @@ +async function run() { + const { test } = await import('node:test'); +const { default: assert } = await import('node:assert'); + + test("dynamic import", (t) => { + assert.ok(true); + }); +} diff --git a/recipes/tape-to-node-test/tests/dynamic-import/input.js b/recipes/tape-to-node-test/tests/dynamic-import/input.js new file mode 100644 index 00000000..c1667f5b --- /dev/null +++ b/recipes/tape-to-node-test/tests/dynamic-import/input.js @@ -0,0 +1,7 @@ +async function run() { + const test = await import("tape"); + + test("dynamic import", (t) => { + t.ok(true); + }); +} diff --git a/recipes/tape-to-node-test/tests/lifecycle/expected.js b/recipes/tape-to-node-test/tests/lifecycle/expected.js new file mode 100644 index 00000000..dd9ef9f7 --- /dev/null +++ b/recipes/tape-to-node-test/tests/lifecycle/expected.js @@ -0,0 +1,10 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; + +let teardownState = 1; + +test("teardown registers and runs after test", (t) => { + t.plan(1); + t.after(() => { teardownState = 0; }); + assert.strictEqual(teardownState, 1, "state before teardown"); +}); diff --git a/recipes/tape-to-node-test/tests/lifecycle/input.js b/recipes/tape-to-node-test/tests/lifecycle/input.js new file mode 100644 index 00000000..3116d00c --- /dev/null +++ b/recipes/tape-to-node-test/tests/lifecycle/input.js @@ -0,0 +1,9 @@ +import test from "tape"; + +let teardownState = 1; + +test("teardown registers and runs after test", (t) => { + t.plan(1); + t.teardown(() => { teardownState = 0; }); + t.equal(teardownState, 1, "state before teardown"); +}); diff --git a/recipes/tape-to-node-test/tests/nested-test-async/expected.js b/recipes/tape-to-node-test/tests/nested-test-async/expected.js new file mode 100644 index 00000000..825456ea --- /dev/null +++ b/recipes/tape-to-node-test/tests/nested-test-async/expected.js @@ -0,0 +1,15 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; + +async function fetchValue() { + return Promise.resolve(1); +} + +test("nested async tests", async (t) => { + const value = await fetchValue(); + assert.strictEqual(value, 1, "outer assertion"); + await t.test("inner async", async (st) => { + const inner = await fetchValue(); + assert.strictEqual(inner, 1, "inner assertion"); + }); +}); diff --git a/recipes/tape-to-node-test/tests/nested-test-async/input.js b/recipes/tape-to-node-test/tests/nested-test-async/input.js new file mode 100644 index 00000000..760b6c8b --- /dev/null +++ b/recipes/tape-to-node-test/tests/nested-test-async/input.js @@ -0,0 +1,14 @@ +import test from "tape"; + +async function fetchValue() { + return Promise.resolve(1); +} + +test("nested async tests", async (t) => { + const value = await fetchValue(); + t.equal(value, 1, "outer assertion"); + await t.test("inner async", async (st) => { + const inner = await fetchValue(); + st.equal(inner, 1, "inner assertion"); + }); +}); diff --git a/recipes/tape-to-node-test/tests/nested-test/expected.js b/recipes/tape-to-node-test/tests/nested-test/expected.js new file mode 100644 index 00000000..81cf3835 --- /dev/null +++ b/recipes/tape-to-node-test/tests/nested-test/expected.js @@ -0,0 +1,10 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; + +test("nested tests", (t) => { + t.plan(1); + t.test("inner test 1", (st) => { + st.plan(1); + assert.strictEqual(1, 1, "inner assertion"); + }); +}); diff --git a/recipes/tape-to-node-test/tests/nested-test/input.js b/recipes/tape-to-node-test/tests/nested-test/input.js new file mode 100644 index 00000000..1981e73a --- /dev/null +++ b/recipes/tape-to-node-test/tests/nested-test/input.js @@ -0,0 +1,9 @@ +import test from "tape"; + +test("nested tests", (t) => { + t.plan(1); + t.test("inner test 1", (st) => { + st.plan(1); + st.equal(1, 1, "inner assertion"); + }); +}); diff --git a/recipes/tape-to-node-test/tests/new-features/expected.js b/recipes/tape-to-node-test/tests/new-features/expected.js new file mode 100644 index 00000000..b7e9d040 --- /dev/null +++ b/recipes/tape-to-node-test/tests/new-features/expected.js @@ -0,0 +1,17 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); + +test('new features', (t) => { + assert.strictEqual(1, 1, 'equals alias'); + assert.strictEqual(1, 1, 'is alias'); + assert.notStrictEqual(1, 2, 'notEquals alias'); + assert.equal(1, '1', 'looseEqual'); + assert.notEqual(1, '2', 'notLooseEqual'); + assert.deepEqual({ a: 1 }, { a: '1' }, 'deepLooseEqual'); + t.diagnostic('this is a comment'); + assert.ok(!false, 'notOk'); +}); + +// TODO: test.onFinish(() => { +// console.log('finished'); +// }); diff --git a/recipes/tape-to-node-test/tests/new-features/input.js b/recipes/tape-to-node-test/tests/new-features/input.js new file mode 100644 index 00000000..9513ebea --- /dev/null +++ b/recipes/tape-to-node-test/tests/new-features/input.js @@ -0,0 +1,16 @@ +const test = require('tape'); + +test('new features', (t) => { + t.equals(1, 1, 'equals alias'); + t.is(1, 1, 'is alias'); + t.notEquals(1, 2, 'notEquals alias'); + t.looseEqual(1, '1', 'looseEqual'); + t.notLooseEqual(1, '2', 'notLooseEqual'); + t.deepLooseEqual({ a: 1 }, { a: '1' }, 'deepLooseEqual'); + t.comment('this is a comment'); + t.notOk(false, 'notOk'); +}); + +test.onFinish(() => { + console.log('finished'); +}); diff --git a/recipes/tape-to-node-test/tests/no-callback-args/expected.js b/recipes/tape-to-node-test/tests/no-callback-args/expected.js new file mode 100644 index 00000000..8f5d2d8e --- /dev/null +++ b/recipes/tape-to-node-test/tests/no-callback-args/expected.js @@ -0,0 +1,10 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; + +test('sync test with no args', () => { + const a = 1; +}); + +test('async test with no args', async () => { + const a = 1; +}); diff --git a/recipes/tape-to-node-test/tests/no-callback-args/input.js b/recipes/tape-to-node-test/tests/no-callback-args/input.js new file mode 100644 index 00000000..a91e3219 --- /dev/null +++ b/recipes/tape-to-node-test/tests/no-callback-args/input.js @@ -0,0 +1,9 @@ +import test from 'tape'; + +test('sync test with no args', () => { + const a = 1; +}); + +test('async test with no args', async () => { + const a = 1; +}); diff --git a/recipes/tape-to-node-test/tests/on-failure/expected.js b/recipes/tape-to-node-test/tests/on-failure/expected.js new file mode 100644 index 00000000..e8916e7e --- /dev/null +++ b/recipes/tape-to-node-test/tests/on-failure/expected.js @@ -0,0 +1,10 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; + +test('failing test', (t) => { + assert.strictEqual(1, 2, 'this will fail'); +}); + +// TODO: test.onFailure(() => { +// console.error('Test suite has failures'); +// }); diff --git a/recipes/tape-to-node-test/tests/on-failure/input.js b/recipes/tape-to-node-test/tests/on-failure/input.js new file mode 100644 index 00000000..6bf6b585 --- /dev/null +++ b/recipes/tape-to-node-test/tests/on-failure/input.js @@ -0,0 +1,9 @@ +import test from 'tape'; + +test('failing test', (t) => { + t.equal(1, 2, 'this will fail'); +}); + +test.onFailure(() => { + console.error('Test suite has failures'); +}); diff --git a/recipes/tape-to-node-test/tests/on-finish/expected.js b/recipes/tape-to-node-test/tests/on-finish/expected.js new file mode 100644 index 00000000..6320ad43 --- /dev/null +++ b/recipes/tape-to-node-test/tests/on-finish/expected.js @@ -0,0 +1,10 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; + +test('some test', (t) => { + assert.ok(true, 'assertion passes'); +}); + +// TODO: test.onFinish(() => { +// console.log('All tests finished'); +// }); diff --git a/recipes/tape-to-node-test/tests/on-finish/input.js b/recipes/tape-to-node-test/tests/on-finish/input.js new file mode 100644 index 00000000..e68f176c --- /dev/null +++ b/recipes/tape-to-node-test/tests/on-finish/input.js @@ -0,0 +1,9 @@ +import test from 'tape'; + +test('some test', (t) => { + t.ok(true, 'assertion passes'); +}); + +test.onFinish(() => { + console.log('All tests finished'); +}); diff --git a/recipes/tape-to-node-test/tests/plan-and-end/expected.js b/recipes/tape-to-node-test/tests/plan-and-end/expected.js new file mode 100644 index 00000000..3b775745 --- /dev/null +++ b/recipes/tape-to-node-test/tests/plan-and-end/expected.js @@ -0,0 +1,10 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; + +test("basic equality", (t) => { + t.plan(4); + assert.strictEqual(1, 1, "equal numbers"); + assert.notStrictEqual(1, 2, "not equal numbers"); + assert.strictEqual(true, true, "strict equality"); + assert.notStrictEqual("1", 1, "not strict equality"); +}); diff --git a/recipes/tape-to-node-test/tests/plan-and-end/input.js b/recipes/tape-to-node-test/tests/plan-and-end/input.js new file mode 100644 index 00000000..60dc73c2 --- /dev/null +++ b/recipes/tape-to-node-test/tests/plan-and-end/input.js @@ -0,0 +1,9 @@ +import test from "tape"; + +test("basic equality", (t) => { + t.plan(4); + t.equal(1, 1, "equal numbers"); + t.notEqual(1, 2, "not equal numbers"); + t.strictEqual(true, true, "strict equality"); + t.notStrictEqual("1", 1, "not strict equality"); +}); diff --git a/recipes/tape-to-node-test/tests/plan-only/expected.js b/recipes/tape-to-node-test/tests/plan-only/expected.js new file mode 100644 index 00000000..6958de70 --- /dev/null +++ b/recipes/tape-to-node-test/tests/plan-only/expected.js @@ -0,0 +1,7 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; + +test('plan only', (t) => { + t.plan(1); + assert.strictEqual(1, 1, 'keeps assert'); +}); diff --git a/recipes/tape-to-node-test/tests/plan-only/input.js b/recipes/tape-to-node-test/tests/plan-only/input.js new file mode 100644 index 00000000..6170f9c9 --- /dev/null +++ b/recipes/tape-to-node-test/tests/plan-only/input.js @@ -0,0 +1,6 @@ +import test from 'tape'; + +test('plan only', (t) => { + t.plan(1); + t.equal(1, 1, 'keeps assert'); +}); diff --git a/recipes/tape-to-node-test/tests/require-import/expected.js b/recipes/tape-to-node-test/tests/require-import/expected.js new file mode 100644 index 00000000..861ba02b --- /dev/null +++ b/recipes/tape-to-node-test/tests/require-import/expected.js @@ -0,0 +1,6 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); + +test("require test", (t) => { + assert.strictEqual(1, 1); +}); diff --git a/recipes/tape-to-node-test/tests/require-import/input.js b/recipes/tape-to-node-test/tests/require-import/input.js new file mode 100644 index 00000000..54af3384 --- /dev/null +++ b/recipes/tape-to-node-test/tests/require-import/input.js @@ -0,0 +1,5 @@ +const test = require("tape"); + +test("require test", (t) => { + t.equal(1, 1); +}); diff --git a/recipes/tape-to-node-test/tests/test-options/expected.js b/recipes/tape-to-node-test/tests/test-options/expected.js new file mode 100644 index 00000000..862a2799 --- /dev/null +++ b/recipes/tape-to-node-test/tests/test-options/expected.js @@ -0,0 +1,10 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; + +test.skip('skipped test', (t) => { + assert.fail('should not run'); +}); + +test.only('only test', (t) => { + assert.ok(true, 'should run'); +}); diff --git a/recipes/tape-to-node-test/tests/test-options/input.js b/recipes/tape-to-node-test/tests/test-options/input.js new file mode 100644 index 00000000..a62fe370 --- /dev/null +++ b/recipes/tape-to-node-test/tests/test-options/input.js @@ -0,0 +1,9 @@ +import test from 'tape'; + +test.skip('skipped test', (t) => { + t.fail('should not run'); +}); + +test.only('only test', (t) => { + t.pass('should run'); +}); diff --git a/recipes/tape-to-node-test/tests/timeout-non-object/expected.js b/recipes/tape-to-node-test/tests/timeout-non-object/expected.js new file mode 100644 index 00000000..78ad093c --- /dev/null +++ b/recipes/tape-to-node-test/tests/timeout-non-object/expected.js @@ -0,0 +1,9 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; + +const opts = { skip: false }; + +test('timeout with variable opts', opts, (t) => { + // TODO(codemod@nodejs/tape-to-node-test): Add timeout: `123` to test options manually; + assert.ok(true); +}); diff --git a/recipes/tape-to-node-test/tests/timeout-non-object/input.js b/recipes/tape-to-node-test/tests/timeout-non-object/input.js new file mode 100644 index 00000000..77d0454e --- /dev/null +++ b/recipes/tape-to-node-test/tests/timeout-non-object/input.js @@ -0,0 +1,8 @@ +import test from 'tape'; + +const opts = { skip: false }; + +test('timeout with variable opts', opts, (t) => { + t.timeoutAfter(123); + t.ok(true); +}); diff --git a/recipes/tape-to-node-test/tests/timeout/expected.js b/recipes/tape-to-node-test/tests/timeout/expected.js new file mode 100644 index 00000000..b0efd174 --- /dev/null +++ b/recipes/tape-to-node-test/tests/timeout/expected.js @@ -0,0 +1,19 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; + +test('timeout test', { timeout: 100 }, (t) => { + + // t.end(); +}); + +test('timeout test with options', { skip: false, timeout: 200 }, (t) => { + + // t.end(); +}); + +test('nested timeout', (t) => { + t.test('inner', { timeout: 50 }, (st) => { + + // st.end(); + }); +}); diff --git a/recipes/tape-to-node-test/tests/timeout/input.js b/recipes/tape-to-node-test/tests/timeout/input.js new file mode 100644 index 00000000..7e055130 --- /dev/null +++ b/recipes/tape-to-node-test/tests/timeout/input.js @@ -0,0 +1,18 @@ +import test from 'tape'; + +test('timeout test', (t) => { + t.timeoutAfter(100); + t.end(); +}); + +test('timeout test with options', { skip: false }, (t) => { + t.timeoutAfter(200); + t.end(); +}); + +test('nested timeout', (t) => { + t.test('inner', (st) => { + st.timeoutAfter(50); + st.end(); + }); +}); diff --git a/recipes/tape-to-node-test/tests/truthiness/expected.js b/recipes/tape-to-node-test/tests/truthiness/expected.js new file mode 100644 index 00000000..ff50545d --- /dev/null +++ b/recipes/tape-to-node-test/tests/truthiness/expected.js @@ -0,0 +1,11 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; + +test("truthiness", (t) => { + t.plan(4); + assert.ok(true, "true is ok"); + assert.ok(!false, "false is not ok"); + assert.ok(true, "explicitly true"); + assert.ok(!false, "explicitly false"); + assert.ok(true, "this passed"); +}); diff --git a/recipes/tape-to-node-test/tests/truthiness/input.js b/recipes/tape-to-node-test/tests/truthiness/input.js new file mode 100644 index 00000000..c951686c --- /dev/null +++ b/recipes/tape-to-node-test/tests/truthiness/input.js @@ -0,0 +1,10 @@ +import test from "tape"; + +test("truthiness", (t) => { + t.plan(4); + t.ok(true, "true is ok"); + t.notOk(false, "false is not ok"); + t.true(true, "explicitly true"); + t.false(false, "explicitly false"); + t.pass("this passed"); +}); diff --git a/recipes/tape-to-node-test/workflow.yaml b/recipes/tape-to-node-test/workflow.yaml new file mode 100644 index 00000000..7b331fdb --- /dev/null +++ b/recipes/tape-to-node-test/workflow.yaml @@ -0,0 +1,39 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod-com/codemod/refs/heads/main/schemas/workflow.json + +version: "1" + +nodes: + - id: apply-transforms + name: Apply AST Transformations + type: automatic + runtime: + type: direct + steps: + - name: Migrates Tape tests to Node.js native test runner + js-ast-grep: + js_file: src/workflow.ts + base_path: . + include: + - "**/*.cjs" + - "**/*.js" + - "**/*.jsx" + - "**/*.mjs" + - "**/*.ts" + - "**/*.tsx" + + - id: remove-dependencies + name: Remove chalk dependency + type: automatic + steps: + - name: Detect package manager and remove chalk dependency + js-ast-grep: + js_file: src/remove-dependencies.ts + base_path: . + include: + - "**/package.json" + exclude: + - "**/node_modules/**" + language: typescript + capabilities: + - child_process + - fs