From 0832a9037b89ac0b9d5098ae0711fb1612fa9053 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Wed, 10 Dec 2025 11:47:16 +0100 Subject: [PATCH 01/23] feat(`tape-to-node-test`): first draft --- package-lock.json | 12 + recipes/tape-to-node-test/README.md | 19 ++ recipes/tape-to-node-test/codemod.yaml | 19 ++ recipes/tape-to-node-test/package.json | 20 ++ recipes/tape-to-node-test/src/index.ts | 318 ++++++++++++++++++ .../tests/async-test/expected.js | 12 + .../tests/async-test/input.js | 11 + .../tests/basic-equality/expected.js | 11 + .../tests/basic-equality/input.js | 10 + .../tests/callback-style/expected.js | 9 + .../tests/callback-style/input.js | 8 + .../tests/deep-equality/expected.js | 8 + .../tests/deep-equality/input.js | 7 + .../tests/lifecycle/expected.js | 10 + .../tests/lifecycle/input.js | 9 + .../tests/nested-test/expected.js | 10 + .../tests/nested-test/input.js | 9 + .../tests/require-import/expected.js | 7 + .../tests/require-import/input.js | 6 + .../tests/truthiness/expected.js | 11 + .../tests/truthiness/input.js | 10 + recipes/tape-to-node-test/workflow.yaml | 22 ++ 22 files changed, 558 insertions(+) create mode 100644 recipes/tape-to-node-test/README.md create mode 100644 recipes/tape-to-node-test/codemod.yaml create mode 100644 recipes/tape-to-node-test/package.json create mode 100644 recipes/tape-to-node-test/src/index.ts create mode 100644 recipes/tape-to-node-test/tests/async-test/expected.js create mode 100644 recipes/tape-to-node-test/tests/async-test/input.js create mode 100644 recipes/tape-to-node-test/tests/basic-equality/expected.js create mode 100644 recipes/tape-to-node-test/tests/basic-equality/input.js create mode 100644 recipes/tape-to-node-test/tests/callback-style/expected.js create mode 100644 recipes/tape-to-node-test/tests/callback-style/input.js create mode 100644 recipes/tape-to-node-test/tests/deep-equality/expected.js create mode 100644 recipes/tape-to-node-test/tests/deep-equality/input.js create mode 100644 recipes/tape-to-node-test/tests/lifecycle/expected.js create mode 100644 recipes/tape-to-node-test/tests/lifecycle/input.js create mode 100644 recipes/tape-to-node-test/tests/nested-test/expected.js create mode 100644 recipes/tape-to-node-test/tests/nested-test/input.js create mode 100644 recipes/tape-to-node-test/tests/require-import/expected.js create mode 100644 recipes/tape-to-node-test/tests/require-import/input.js create mode 100644 recipes/tape-to-node-test/tests/truthiness/expected.js create mode 100644 recipes/tape-to-node-test/tests/truthiness/input.js create mode 100644 recipes/tape-to-node-test/workflow.yaml diff --git a/package-lock.json b/package-lock.json index fa72f85e..0cee1406 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1544,6 +1544,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 @@ -4451,6 +4455,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..a7fbc2e7 --- /dev/null +++ b/recipes/tape-to-node-test/README.md @@ -0,0 +1,19 @@ +# Tape to Node.js Test Runner Codemod + +This codemod migrates tests written using `tape` to the native Node.js test runner (`node:test`). + +## Features + +- Replaces `tape` imports with `node:test` and `node:assert/strict`. +- Converts `test(name, (t) => ...)` to `test(name, async (t) => ...)`. +- Maps `tape` assertions to `node:assert` equivalents. +- Handles `t.plan` (by commenting it out). +- Handles `t.end` (removes it for async tests, converts to `done` callback for callback-style tests). +- Handles `t.test` subtests (adds `await`). +- Converts `t.teardown` to `t.after`. + +## Usage + +```bash +npx codemod @nodejs/tape-to-node-test +``` diff --git a/recipes/tape-to-node-test/codemod.yaml b/recipes/tape-to-node-test/codemod.yaml new file mode 100644 index 00000000..35191120 --- /dev/null +++ b/recipes/tape-to-node-test/codemod.yaml @@ -0,0 +1,19 @@ +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 diff --git a/recipes/tape-to-node-test/package.json b/recipes/tape-to-node-test/package.json new file mode 100644 index 00000000..6dabb83d --- /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/index.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/index.ts b/recipes/tape-to-node-test/src/index.ts new file mode 100644 index 00000000..21c278b7 --- /dev/null +++ b/recipes/tape-to-node-test/src/index.ts @@ -0,0 +1,318 @@ +import type { SgRoot, SgNode, Edit } from '@codemod.com/jssg-types/main'; +import { getNodeImportStatements } from '@nodejs/codemod-utils/ast-grep/import-statement'; +import { getNodeRequireCalls } from '@nodejs/codemod-utils/ast-grep/require-call'; +import { resolveBindingPath } from '@nodejs/codemod-utils/ast-grep/resolve-binding-path'; +import type Js from '@codemod.com/jssg-types/langs/javascript'; + +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', +}; + +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'); + + if (tapeImports.length === 0 && tapeRequires.length === 0) { + return null; + } + + let testVarName = 'test'; + + // Replace imports + for (const imp of tapeImports) { + const defaultImport = imp.find({ + rule: { kind: 'import_clause', has: { kind: 'identifier' } }, + }); + if (defaultImport) { + const id = defaultImport.find({ rule: { kind: 'identifier' } }); + if (id) testVarName = id.text(); + edits.push( + imp.replace( + `import { test } from 'node:test';\nimport assert from 'node:assert/strict';`, + ), + ); + } + } + + for (const req of tapeRequires) { + const id = req.find({ + rule: { kind: 'identifier', inside: { kind: 'variable_declarator' } }, + }); + if (id) testVarName = id.text(); + const declaration = req + .ancestors() + .find( + (a) => + a.kind() === 'variable_declaration' || + a.kind() === 'lexical_declaration', + ); + if (declaration) { + edits.push( + declaration.replace( + `const { test } = require('node:test');\nconst assert = require('node:assert/strict');`, + ), + ); + } + } + + const testCalls = rootNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + regex: `^${testVarName}$`, + }, + }, + }); + + for (const call of testCalls) { + 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; + if (body) { + const endCalls = body.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'member_expression', + has: { + field: 'object', + regex: `^${tName}$`, + }, + }, + }, + }); + + for (const endCall of endCalls) { + let isNested = false; + let curr = endCall.parent(); + while (curr && curr.id() !== body.id()) { + if ( + curr.kind() === 'arrow_function' || + curr.kind() === 'function_expression' || + curr.kind() === 'function_declaration' + ) { + isNested = true; + break; + } + curr = curr.parent(); + } + + if (isNested) { + usesEndInCallback = true; + } + } + } + + const isAsync = callback.text().startsWith('async'); + let useDone = false; + + if (usesEndInCallback && !isAsync) { + useDone = true; + if (params) { + 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)`)); + } + } + } else { + if (!isAsync) { + if (params) { + edits.push({ + startPos: callback.range().start.index, + endPos: params.range().start.index, + insertedText: 'async ', + }); + } + } + } + + if (body) { + transformAssertions(body, tName, edits, useDone); + } + } + } + + return rootNode.commitEdits(edits); +} + +function transformAssertions( + node: SgNode, + tName: string, + edits: Edit[], + useDone = false, +) { + const calls = node.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'member_expression', + has: { + field: 'object', + regex: `^${tName}$`, + }, + }, + }, + }); + + for (const call of calls) { + const method = call.field('function')?.field('property')?.text(); + if (!method) continue; + + if (ASSERTION_MAPPING[method]) { + const newMethod = ASSERTION_MAPPING[method]; + const func = call.field('function'); + if (func) { + edits.push(func.replace(`assert.${newMethod}`)); + } + } else if (method === 'notOk') { + // t.notOk(val, msg) -> assert.ok(!val, msg) + const args = call.field('arguments'); + 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')); + } + } + } else if (method === 'true') { + // t.true(val, msg) -> assert.ok(val, msg) + const func = call.field('function'); + if (func) edits.push(func.replace('assert.ok')); + } else if (method === 'false') { + // t.false(val, msg) -> assert.ok(!val, msg) + const args = call.field('arguments'); + if (args) { + const val = args.child(1); + 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')); + } + } + } else if (method === 'pass') { + // t.pass(msg) -> assert.ok(true, msg) + const args = call.field('arguments'); + 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', + }); + const func = call.field('function'); + if (func) edits.push(func.replace('assert.ok')); + } + } + } else if (method === 'plan') { + edits.push(call.replace(`// ${call.text()}`)); + } else if (method === 'end') { + if (useDone) { + edits.push(call.replace('done()')); + } else { + edits.push(call.replace(`// ${call.text()}`)); + } + } else if (method === 'test') { + edits.push({ + startPos: call.range().start.index, + endPos: call.range().start.index, + insertedText: 'await ', + }); + + const args = call.field('arguments'); + const cb = args + ?.children() + .find( + (c) => + c.kind() === 'arrow_function' || c.kind() === 'function_expression', + ); + if (cb) { + if (!cb.text().startsWith('async')) { + const p = cb.field('parameters'); + if (p) { + edits.push({ + startPos: cb.range().start.index, + endPos: p.range().start.index, + insertedText: 'async ', + }); + } + } + 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'); + if (b) transformAssertions(b, stName, edits); + } + } else if (method === 'teardown') { + const func = call.field('function'); + if (func) { + edits.push(func.replace(`${tName}.after`)); + } + } else if (method === 'timeoutAfter') { + // t.timeoutAfter(200) -> remove and add to test options? + // This is hard because we need to modify the parent test call arguments. + // For now, let's just comment it out and add a TODO. + edits.push( + call.replace(`// TODO: Move timeout to test options: ${call.text()}`), + ); + } + } +} 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..545a9c5a --- /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/strict'; + +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..bb442c69 --- /dev/null +++ b/recipes/tape-to-node-test/tests/basic-equality/expected.js @@ -0,0 +1,11 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +test("basic equality", async (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"); + // t.end(); +}); 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..3d3a7533 --- /dev/null +++ b/recipes/tape-to-node-test/tests/basic-equality/input.js @@ -0,0 +1,10 @@ +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"); + t.end(); +}); 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..007e8b71 --- /dev/null +++ b/recipes/tape-to-node-test/tests/callback-style/expected.js @@ -0,0 +1,9 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +test("callback style", (t, done) => { + setTimeout(() => { + assert.ok(true); + done(); + }, 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..bbbe722e --- /dev/null +++ b/recipes/tape-to-node-test/tests/callback-style/input.js @@ -0,0 +1,8 @@ +import test from "tape"; + +test("callback style", (t) => { + setTimeout(() => { + t.ok(true); + t.end(); + }, 100); +}); 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..a76f03dc --- /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/strict'; + +test("deep equality", async (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/lifecycle/expected.js b/recipes/tape-to-node-test/tests/lifecycle/expected.js new file mode 100644 index 00000000..b37e3c9f --- /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/strict'; + +let teardownState = 1; + +test("teardown registers and runs after test", async (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/expected.js b/recipes/tape-to-node-test/tests/nested-test/expected.js new file mode 100644 index 00000000..0e0ed2ad --- /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/strict'; + +test("nested tests", async (t) => { + // t.plan(1); + await t.test("inner test 1", async (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..4b585062 --- /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/require-import/expected.js b/recipes/tape-to-node-test/tests/require-import/expected.js new file mode 100644 index 00000000..13e98997 --- /dev/null +++ b/recipes/tape-to-node-test/tests/require-import/expected.js @@ -0,0 +1,7 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); + +test("require test", async (t) => { + assert.strictEqual(1, 1); + // t.end(); +}); 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..ce8e9b3e --- /dev/null +++ b/recipes/tape-to-node-test/tests/require-import/input.js @@ -0,0 +1,6 @@ +const test = require("tape"); + +test("require test", (t) => { + t.equal(1, 1); + t.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..b47ca52c --- /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/strict'; + +test("truthiness", async (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..5ee7ced6 --- /dev/null +++ b/recipes/tape-to-node-test/workflow.yaml @@ -0,0 +1,22 @@ +# 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/index.ts + base_path: . + include: + - "**/*.cjs" + - "**/*.js" + - "**/*.jsx" + - "**/*.mjs" + - "**/*.ts" + - "**/*.tsx" From 527acf4b923b88a884d0f4b364093fec5363ac5f Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Wed, 10 Dec 2025 11:53:12 +0100 Subject: [PATCH 02/23] fix: use `workflow` convention --- recipes/tape-to-node-test/package.json | 2 +- recipes/tape-to-node-test/src/{index.ts => workflow.ts} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename recipes/tape-to-node-test/src/{index.ts => workflow.ts} (100%) diff --git a/recipes/tape-to-node-test/package.json b/recipes/tape-to-node-test/package.json index 6dabb83d..0dfefeff 100644 --- a/recipes/tape-to-node-test/package.json +++ b/recipes/tape-to-node-test/package.json @@ -4,7 +4,7 @@ "description": "Migrates Tape tests to Node.js native test runner", "type": "module", "scripts": { - "test": "npx codemod jssg test -l typescript ./src/index.ts ./" + "test": "npx codemod jssg test -l typescript ./src/workflow.ts" }, "repository": { "type": "git", diff --git a/recipes/tape-to-node-test/src/index.ts b/recipes/tape-to-node-test/src/workflow.ts similarity index 100% rename from recipes/tape-to-node-test/src/index.ts rename to recipes/tape-to-node-test/src/workflow.ts From c5ca0a59316f5dae66be08dfc7b209aa98ac5825 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Wed, 10 Dec 2025 11:54:45 +0100 Subject: [PATCH 03/23] fix: use `EOL` --- recipes/tape-to-node-test/src/workflow.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/recipes/tape-to-node-test/src/workflow.ts b/recipes/tape-to-node-test/src/workflow.ts index 21c278b7..ecc5f5a6 100644 --- a/recipes/tape-to-node-test/src/workflow.ts +++ b/recipes/tape-to-node-test/src/workflow.ts @@ -1,7 +1,8 @@ -import type { SgRoot, SgNode, Edit } from '@codemod.com/jssg-types/main'; +import { EOL } from 'node:os'; import { getNodeImportStatements } from '@nodejs/codemod-utils/ast-grep/import-statement'; import { getNodeRequireCalls } from '@nodejs/codemod-utils/ast-grep/require-call'; import { resolveBindingPath } from '@nodejs/codemod-utils/ast-grep/resolve-binding-path'; +import type { SgRoot, SgNode, Edit } from '@codemod.com/jssg-types/main'; import type Js from '@codemod.com/jssg-types/langs/javascript'; const ASSERTION_MAPPING: Record = { @@ -48,7 +49,7 @@ export default function transform(root: SgRoot): string | null { if (id) testVarName = id.text(); edits.push( imp.replace( - `import { test } from 'node:test';\nimport assert from 'node:assert/strict';`, + `import { test } from 'node:test';${EOL}import assert from 'node:assert/strict';`, ), ); } @@ -69,7 +70,7 @@ export default function transform(root: SgRoot): string | null { if (declaration) { edits.push( declaration.replace( - `const { test } = require('node:test');\nconst assert = require('node:assert/strict');`, + `const { test } = require('node:test');${EOL}const assert = require('node:assert/strict');`, ), ); } From 59cf844a5357d826c49e4dc97587e22343a7d7dd Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Wed, 10 Dec 2025 11:56:30 +0100 Subject: [PATCH 04/23] chore: update readme --- recipes/tape-to-node-test/README.md | 66 +++++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/recipes/tape-to-node-test/README.md b/recipes/tape-to-node-test/README.md index a7fbc2e7..468a1e65 100644 --- a/recipes/tape-to-node-test/README.md +++ b/recipes/tape-to-node-test/README.md @@ -12,8 +12,68 @@ This codemod migrates tests written using `tape` to the native Node.js test runn - Handles `t.test` subtests (adds `await`). - Converts `t.teardown` to `t.after`. -## Usage +## Example -```bash -npx codemod @nodejs/tape-to-node-test +### Basic Equality + +```diff +- import test from "tape"; ++ import { test } from 'node:test'; ++ import assert from 'node:assert/strict'; + +- test("basic equality", (t) => { ++ test("basic equality", async (t) => { +- t.plan(4); ++ // t.plan(4); +- 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"); +- t.end(); ++ // t.end(); + }); +``` + +### Async Tests + +```diff +- import test from "tape"; ++ import { test } from 'node:test'; ++ import assert from 'node:assert/strict'; + + 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); ++ // 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/strict'; + +- test("callback style", (t) => { ++ test("callback style", (t, done) => { + setTimeout(() => { +- t.ok(true); ++ assert.ok(true); +- t.end(); ++ done(); + }, 100); + }); ``` + From af14a96f7a5605bda9fd478f33c36b9dcde04552 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Thu, 11 Dec 2025 21:40:13 +0100 Subject: [PATCH 05/23] feat(tape-to-node-test): support dynamic import --- recipes/tape-to-node-test/README.md | 18 ++++++++++ recipes/tape-to-node-test/src/workflow.ts | 33 +++++++++++++++++-- .../tests/dynamic-import/expected.js | 9 +++++ .../tests/dynamic-import/input.js | 8 +++++ recipes/tape-to-node-test/workflow.yaml | 2 +- 5 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 recipes/tape-to-node-test/tests/dynamic-import/expected.js create mode 100644 recipes/tape-to-node-test/tests/dynamic-import/input.js diff --git a/recipes/tape-to-node-test/README.md b/recipes/tape-to-node-test/README.md index 468a1e65..01715e9b 100644 --- a/recipes/tape-to-node-test/README.md +++ b/recipes/tape-to-node-test/README.md @@ -77,3 +77,21 @@ This codemod migrates tests written using `tape` to the native Node.js test runn }); ``` +### Dynamic Import + +```diff + async function run() { +- const test = await import("tape"); ++ const { test } = await import('node:test'); ++ const { default: assert } = await import('node:assert/strict'); + +- test("dynamic import", (t) => { ++ test("dynamic import", async (t) => { +- t.ok(true); ++ assert.ok(true); +- t.end(); ++ // t.end(); + }); + } +``` + diff --git a/recipes/tape-to-node-test/src/workflow.ts b/recipes/tape-to-node-test/src/workflow.ts index ecc5f5a6..e9623838 100644 --- a/recipes/tape-to-node-test/src/workflow.ts +++ b/recipes/tape-to-node-test/src/workflow.ts @@ -1,5 +1,8 @@ import { EOL } from 'node:os'; -import { getNodeImportStatements } from '@nodejs/codemod-utils/ast-grep/import-statement'; +import { + getNodeImportStatements, + getNodeImportCalls, +} from '@nodejs/codemod-utils/ast-grep/import-statement'; import { getNodeRequireCalls } from '@nodejs/codemod-utils/ast-grep/require-call'; import { resolveBindingPath } from '@nodejs/codemod-utils/ast-grep/resolve-binding-path'; import type { SgRoot, SgNode, Edit } from '@codemod.com/jssg-types/main'; @@ -32,8 +35,13 @@ export default function transform(root: SgRoot): string | null { const tapeImports = getNodeImportStatements(root, 'tape'); const tapeRequires = getNodeRequireCalls(root, 'tape'); + const tapeImportCalls = getNodeImportCalls(root, 'tape'); - if (tapeImports.length === 0 && tapeRequires.length === 0) { + if ( + tapeImports.length === 0 && + tapeRequires.length === 0 && + tapeImportCalls.length === 0 + ) { return null; } @@ -76,6 +84,27 @@ export default function transform(root: SgRoot): string | null { } } + for (const call of tapeImportCalls) { + const id = call.find({ + rule: { kind: 'identifier', inside: { kind: 'variable_declarator' } }, + }); + if (id) testVarName = id.text(); + const declaration = call + .ancestors() + .find( + (a) => + a.kind() === 'variable_declaration' || + a.kind() === 'lexical_declaration', + ); + if (declaration) { + edits.push( + declaration.replace( + `const { test } = await import('node:test');${EOL}const { default: assert } = await import('node:assert/strict');`, + ), + ); + } + } + const testCalls = rootNode.findAll({ rule: { kind: 'call_expression', 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..8021c863 --- /dev/null +++ b/recipes/tape-to-node-test/tests/dynamic-import/expected.js @@ -0,0 +1,9 @@ +async function run() { + const { test } = await import('node:test'); +const { default: assert } = await import('node:assert/strict'); + + test("dynamic import", async (t) => { + assert.ok(true); + // t.end(); + }); +} 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..055e41ae --- /dev/null +++ b/recipes/tape-to-node-test/tests/dynamic-import/input.js @@ -0,0 +1,8 @@ +async function run() { + const test = await import("tape"); + + test("dynamic import", (t) => { + t.ok(true); + t.end(); + }); +} diff --git a/recipes/tape-to-node-test/workflow.yaml b/recipes/tape-to-node-test/workflow.yaml index 5ee7ced6..99196f59 100644 --- a/recipes/tape-to-node-test/workflow.yaml +++ b/recipes/tape-to-node-test/workflow.yaml @@ -11,7 +11,7 @@ nodes: steps: - name: Migrates Tape tests to Node.js native test runner js-ast-grep: - js_file: src/index.ts + js_file: src/workflow.ts base_path: . include: - "**/*.cjs" From f665ea8129ca5ff03312c496b58369e002811028 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Thu, 11 Dec 2025 22:09:11 +0100 Subject: [PATCH 06/23] feat(`tape-to-node-test`): support more cases --- recipes/tape-to-node-test/src/workflow.ts | 30 ++++++++++++++++--- .../tests/advanced-assertions/expected.js | 13 ++++++++ .../tests/advanced-assertions/input.js | 12 ++++++++ .../tests/aliased-import/expected.js | 7 +++++ .../tests/aliased-import/input.js | 6 ++++ .../tests/cjs-destructuring/expected.js | 7 +++++ .../tests/cjs-destructuring/input.js | 6 ++++ .../tests/no-callback-args/expected.js | 10 +++++++ .../tests/no-callback-args/input.js | 9 ++++++ .../tests/test-options/expected.js | 12 ++++++++ .../tests/test-options/input.js | 11 +++++++ 11 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 recipes/tape-to-node-test/tests/advanced-assertions/expected.js create mode 100644 recipes/tape-to-node-test/tests/advanced-assertions/input.js create mode 100644 recipes/tape-to-node-test/tests/aliased-import/expected.js create mode 100644 recipes/tape-to-node-test/tests/aliased-import/input.js create mode 100644 recipes/tape-to-node-test/tests/cjs-destructuring/expected.js create mode 100644 recipes/tape-to-node-test/tests/cjs-destructuring/input.js create mode 100644 recipes/tape-to-node-test/tests/no-callback-args/expected.js create mode 100644 recipes/tape-to-node-test/tests/no-callback-args/input.js create mode 100644 recipes/tape-to-node-test/tests/test-options/expected.js create mode 100644 recipes/tape-to-node-test/tests/test-options/input.js diff --git a/recipes/tape-to-node-test/src/workflow.ts b/recipes/tape-to-node-test/src/workflow.ts index e9623838..0b181ddd 100644 --- a/recipes/tape-to-node-test/src/workflow.ts +++ b/recipes/tape-to-node-test/src/workflow.ts @@ -4,10 +4,11 @@ import { getNodeImportCalls, } from '@nodejs/codemod-utils/ast-grep/import-statement'; import { getNodeRequireCalls } from '@nodejs/codemod-utils/ast-grep/require-call'; -import { resolveBindingPath } from '@nodejs/codemod-utils/ast-grep/resolve-binding-path'; 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', @@ -47,7 +48,7 @@ export default function transform(root: SgRoot): string | null { let testVarName = 'test'; - // Replace imports + // 1. Replace imports for (const imp of tapeImports) { const defaultImport = imp.find({ rule: { kind: 'import_clause', has: { kind: 'identifier' } }, @@ -110,12 +111,25 @@ export default function transform(root: SgRoot): string | null { kind: 'call_expression', has: { field: 'function', - regex: `^${testVarName}$`, + 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; @@ -209,6 +223,14 @@ export default function transform(root: SgRoot): string | null { 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 useDone whether to use the done callback for ending tests + */ function transformAssertions( node: SgNode, tName: string, 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..a395b1f3 --- /dev/null +++ b/recipes/tape-to-node-test/tests/advanced-assertions/expected.js @@ -0,0 +1,13 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +test('advanced assertions', async (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); + // t.end(); +}); 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..2be9e2ea --- /dev/null +++ b/recipes/tape-to-node-test/tests/advanced-assertions/input.js @@ -0,0 +1,12 @@ +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); + t.end(); +}); 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..9c22652c --- /dev/null +++ b/recipes/tape-to-node-test/tests/aliased-import/expected.js @@ -0,0 +1,7 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +test('aliased test', async (t) => { + assert.strictEqual(1, 1); + // t.end(); +}); 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..e00f6825 --- /dev/null +++ b/recipes/tape-to-node-test/tests/aliased-import/input.js @@ -0,0 +1,6 @@ +import myTest from 'tape'; + +myTest('aliased test', (t) => { + t.equal(1, 1); + t.end(); +}); 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..63dc1a02 --- /dev/null +++ b/recipes/tape-to-node-test/tests/cjs-destructuring/expected.js @@ -0,0 +1,7 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); + +test('cjs destructuring', async (t) => { + assert.ok(true); + // t.end(); +}); 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..36d7268c --- /dev/null +++ b/recipes/tape-to-node-test/tests/cjs-destructuring/input.js @@ -0,0 +1,6 @@ +const { test } = require('tape'); + +test('cjs destructuring', (t) => { + t.ok(true); + t.end(); +}); 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..24b21f56 --- /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/strict'; + +test('sync test with no args', async () => { + 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/test-options/expected.js b/recipes/tape-to-node-test/tests/test-options/expected.js new file mode 100644 index 00000000..bca22464 --- /dev/null +++ b/recipes/tape-to-node-test/tests/test-options/expected.js @@ -0,0 +1,12 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +test.skip('skipped test', async (t) => { + assert.fail('should not run'); + // t.end(); +}); + +test.only('only test', async (t) => { + assert.ok(true, 'should run'); + // t.end(); +}); 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..3a5424f0 --- /dev/null +++ b/recipes/tape-to-node-test/tests/test-options/input.js @@ -0,0 +1,11 @@ +import test from 'tape'; + +test.skip('skipped test', (t) => { + t.fail('should not run'); + t.end(); +}); + +test.only('only test', (t) => { + t.pass('should run'); + t.end(); +}); From 08e74b0121c0574d793b62603e286b76fcba4e5b Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Thu, 11 Dec 2025 22:09:33 +0100 Subject: [PATCH 07/23] fix: code format --- recipes/tape-to-node-test/src/workflow.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/recipes/tape-to-node-test/src/workflow.ts b/recipes/tape-to-node-test/src/workflow.ts index 0b181ddd..3e93b845 100644 --- a/recipes/tape-to-node-test/src/workflow.ts +++ b/recipes/tape-to-node-test/src/workflow.ts @@ -6,6 +6,7 @@ import { 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 */ From e51e443b807f1f78c15cddb5cc0f2d897f3066b7 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Thu, 11 Dec 2025 22:26:31 +0100 Subject: [PATCH 08/23] feat(tape-to-node-test): support timeout --- recipes/tape-to-node-test/README.md | 24 +++- recipes/tape-to-node-test/src/workflow.ts | 129 ++++++++++++++---- .../tests/timeout/expected.js | 19 +++ .../tape-to-node-test/tests/timeout/input.js | 18 +++ 4 files changed, 162 insertions(+), 28 deletions(-) create mode 100644 recipes/tape-to-node-test/tests/timeout/expected.js create mode 100644 recipes/tape-to-node-test/tests/timeout/input.js diff --git a/recipes/tape-to-node-test/README.md b/recipes/tape-to-node-test/README.md index 01715e9b..5ba85330 100644 --- a/recipes/tape-to-node-test/README.md +++ b/recipes/tape-to-node-test/README.md @@ -1,6 +1,6 @@ # Tape to Node.js Test Runner Codemod -This codemod migrates tests written using `tape` to the native Node.js test runner (`node:test`). +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 @@ -11,6 +11,8 @@ This codemod migrates tests written using `tape` to the native Node.js test runn - Handles `t.end` (removes it for async tests, converts to `done` callback for callback-style tests). - Handles `t.test` subtests (adds `await`). - Converts `t.teardown` to `t.after`. +- Migrates `t.timeoutAfter(ms)` to `{ timeout: ms }` test option. +- Supports `test.skip` and `test.only`. ## Example @@ -77,6 +79,23 @@ This codemod migrates tests written using `tape` to the native Node.js test runn }); ``` +### Timeout Handling + +```diff +- import test from "tape"; ++ import { test } from 'node:test'; ++ import assert from 'node:assert/strict'; + +- test("timeout test", (t) => { ++ test("timeout test", { timeout: 100 }, async (t) => { +- t.timeoutAfter(100); +- t.ok(true); ++ assert.ok(true); +- t.end(); ++ // t.end(); + }); +``` + ### Dynamic Import ```diff @@ -84,7 +103,7 @@ This codemod migrates tests written using `tape` to the native Node.js test runn - const test = await import("tape"); + const { test } = await import('node:test'); + const { default: assert } = await import('node:assert/strict'); - + - test("dynamic import", (t) => { + test("dynamic import", async (t) => { - t.ok(true); @@ -94,4 +113,3 @@ This codemod migrates tests written using `tape` to the native Node.js test runn }); } ``` - diff --git a/recipes/tape-to-node-test/src/workflow.ts b/recipes/tape-to-node-test/src/workflow.ts index 3e93b845..bdb871ca 100644 --- a/recipes/tape-to-node-test/src/workflow.ts +++ b/recipes/tape-to-node-test/src/workflow.ts @@ -203,20 +203,20 @@ export default function transform(root: SgRoot): string | null { edits.push(params.replace(`(${text}, done)`)); } } - } else { - if (!isAsync) { - if (params) { - edits.push({ - startPos: callback.range().start.index, - endPos: params.range().start.index, - insertedText: 'async ', - }); - } - } } if (body) { - transformAssertions(body, tName, edits, useDone); + transformAssertions(body, tName, edits, call, useDone); + } + + if (!usesEndInCallback && !isAsync) { + if (params) { + edits.push({ + startPos: callback.range().start.index, + endPos: params.range().start.index, + insertedText: 'async ', + }); + } } } } @@ -230,12 +230,14 @@ export default function transform(root: SgRoot): string | null { * @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, ) { const calls = node.findAll({ @@ -336,8 +338,15 @@ function transformAssertions( 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'); + if (b) transformAssertions(b, stName, edits, call); + if (!cb.text().startsWith('async')) { - const p = cb.field('parameters'); if (p) { edits.push({ startPos: cb.range().start.index, @@ -346,13 +355,6 @@ function transformAssertions( }); } } - 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'); - if (b) transformAssertions(b, stName, edits); } } else if (method === 'teardown') { const func = call.field('function'); @@ -360,12 +362,89 @@ function transformAssertions( edits.push(func.replace(`${tName}.after`)); } } else if (method === 'timeoutAfter') { - // t.timeoutAfter(200) -> remove and add to test options? - // This is hard because we need to modify the parent test call arguments. - // For now, let's just comment it out and add a TODO. - edits.push( - call.replace(`// TODO: Move timeout to test options: ${call.text()}`), - ); + const args = call.field('arguments'); + const timeoutArg = args?.child(1); // child(0) is '(' + if (timeoutArg) { + const timeoutVal = timeoutArg.text(); + // Remove the call + const parent = call.parent(); + if (parent && parent.kind() === 'expression_statement') { + edits.push(parent.replace('')); + } else { + edits.push(call.replace('')); + } + + // 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} }, `, + }); + } 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}`, + }); + } 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} `, + }); + } + } + } else { + // Options is a variable or expression + // TODO: Handle this case? + // For now, maybe just log a comment + edits.push( + call.replace( + `// TODO: Add timeout: ${timeoutVal} to test options manually`, + ), + ); + } + } + } + } } } } 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..6fac34ea --- /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/strict'; + +test('timeout test', { timeout: 100 }, async (t) => { + + // t.end(); +}); + +test('timeout test with options', { skip: false, timeout: 200 }, async (t) => { + + // t.end(); +}); + +test('nested timeout', async (t) => { + await t.test('inner', { timeout: 50 }, async (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(); + }); +}); From 7cd866be7968967362b3118a3e1fc7b88c7be22c Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Thu, 11 Dec 2025 22:38:05 +0100 Subject: [PATCH 09/23] feat(tape-to-node-test): improve --- recipes/tape-to-node-test/README.md | 11 +-- recipes/tape-to-node-test/src/workflow.ts | 67 +++++++++++++++++-- .../tests/advanced-assertions/expected.js | 2 +- .../tests/aliased-import/expected.js | 2 +- .../tests/async-test/expected.js | 2 +- .../tests/basic-equality/expected.js | 2 +- .../tests/callback-style/expected.js | 2 +- .../tests/cjs-destructuring/expected.js | 2 +- .../tests/deep-equality/expected.js | 2 +- .../tests/dynamic-import/expected.js | 2 +- .../tests/lifecycle/expected.js | 2 +- .../tests/nested-test/expected.js | 2 +- .../tests/new-features/expected.js | 18 +++++ .../tests/new-features/input.js | 17 +++++ .../tests/no-callback-args/expected.js | 2 +- .../tests/require-import/expected.js | 2 +- .../tests/test-options/expected.js | 2 +- .../tests/timeout/expected.js | 8 +-- .../tests/truthiness/expected.js | 2 +- 19 files changed, 123 insertions(+), 26 deletions(-) create mode 100644 recipes/tape-to-node-test/tests/new-features/expected.js create mode 100644 recipes/tape-to-node-test/tests/new-features/input.js diff --git a/recipes/tape-to-node-test/README.md b/recipes/tape-to-node-test/README.md index 5ba85330..4dcbebef 100644 --- a/recipes/tape-to-node-test/README.md +++ b/recipes/tape-to-node-test/README.md @@ -4,15 +4,18 @@ This codemod migrates tests written using [`tape`](https://github.com/tape-testi ## Features -- Replaces `tape` imports with `node:test` and `node:assert/strict`. +- 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. +- Maps `tape` assertions to `node:assert` equivalents, including many aliases (e.g., `t.is`, `t.equals`, `t.deepEquals`). - Handles `t.plan` (by commenting it out). - Handles `t.end` (removes it for async tests, converts to `done` callback for callback-style tests). - Handles `t.test` subtests (adds `await`). - 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` (by commenting them out with a TODO). +- Supports loose equality assertions (e.g., `t.looseEqual` -> `assert.equal`). ## Example @@ -21,7 +24,7 @@ This codemod migrates tests written using [`tape`](https://github.com/tape-testi ```diff - import test from "tape"; + import { test } from 'node:test'; -+ import assert from 'node:assert/strict'; ++ import assert from 'node:assert'; - test("basic equality", (t) => { + test("basic equality", async (t) => { @@ -45,7 +48,7 @@ This codemod migrates tests written using [`tape`](https://github.com/tape-testi ```diff - import test from "tape"; + import { test } from 'node:test'; -+ import assert from 'node:assert/strict'; ++ import assert from 'node:assert'; function someAsyncThing() { return new Promise((resolve) => setTimeout(() => resolve(true), 50)); diff --git a/recipes/tape-to-node-test/src/workflow.ts b/recipes/tape-to-node-test/src/workflow.ts index bdb871ca..b5cc2545 100644 --- a/recipes/tape-to-node-test/src/workflow.ts +++ b/recipes/tape-to-node-test/src/workflow.ts @@ -29,6 +29,34 @@ const ASSERTION_MAPPING: Record = { 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 { @@ -59,7 +87,7 @@ export default function transform(root: SgRoot): string | null { if (id) testVarName = id.text(); edits.push( imp.replace( - `import { test } from 'node:test';${EOL}import assert from 'node:assert/strict';`, + `import { test } from 'node:test';${EOL}import assert from 'node:assert';`, ), ); } @@ -80,7 +108,7 @@ export default function transform(root: SgRoot): string | null { if (declaration) { edits.push( declaration.replace( - `const { test } = require('node:test');${EOL}const assert = require('node:assert/strict');`, + `const { test } = require('node:test');${EOL}const assert = require('node:assert');`, ), ); } @@ -101,7 +129,7 @@ export default function transform(root: SgRoot): string | null { if (declaration) { edits.push( declaration.replace( - `const { test } = await import('node:test');${EOL}const { default: assert } = await import('node:assert/strict');`, + `const { test } = await import('node:test');${EOL}const { default: assert } = await import('node:assert');`, ), ); } @@ -221,6 +249,33 @@ export default function transform(root: SgRoot): string | null { } } + // 3. Handle test.onFinish and test.onFailure + const lifecycleCalls = rootNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'member_expression', + has: { + field: 'object', + regex: `^${testVarName}$`, + }, + has: { + field: 'property', + regex: '^(onFinish|onFailure)$', + }, + }, + }, + }); + + for (const call of lifecycleCalls) { + 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); } @@ -264,7 +319,7 @@ function transformAssertions( if (func) { edits.push(func.replace(`assert.${newMethod}`)); } - } else if (method === 'notOk') { + } else if (method === 'notOk' || method === 'notok') { // t.notOk(val, msg) -> assert.ok(!val, msg) const args = call.field('arguments'); if (args) { @@ -279,6 +334,10 @@ function transformAssertions( if (func) edits.push(func.replace('assert.ok')); } } + } else if (method === 'comment') { + // t.comment(msg) -> t.diagnostic(msg) + const func = call.field('function'); + if (func) edits.push(func.replace(`${tName}.diagnostic`)); } else if (method === 'true') { // t.true(val, msg) -> assert.ok(val, msg) const func = call.field('function'); diff --git a/recipes/tape-to-node-test/tests/advanced-assertions/expected.js b/recipes/tape-to-node-test/tests/advanced-assertions/expected.js index a395b1f3..44681e4f 100644 --- a/recipes/tape-to-node-test/tests/advanced-assertions/expected.js +++ b/recipes/tape-to-node-test/tests/advanced-assertions/expected.js @@ -1,5 +1,5 @@ import { test } from 'node:test'; -import assert from 'node:assert/strict'; +import assert from 'node:assert'; test('advanced assertions', async (t) => { assert.throws(() => { throw new Error('fail'); }, /fail/); diff --git a/recipes/tape-to-node-test/tests/aliased-import/expected.js b/recipes/tape-to-node-test/tests/aliased-import/expected.js index 9c22652c..2a9c33a5 100644 --- a/recipes/tape-to-node-test/tests/aliased-import/expected.js +++ b/recipes/tape-to-node-test/tests/aliased-import/expected.js @@ -1,5 +1,5 @@ import { test } from 'node:test'; -import assert from 'node:assert/strict'; +import assert from 'node:assert'; test('aliased test', async (t) => { assert.strictEqual(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 index 545a9c5a..40cb081e 100644 --- a/recipes/tape-to-node-test/tests/async-test/expected.js +++ b/recipes/tape-to-node-test/tests/async-test/expected.js @@ -1,5 +1,5 @@ import { test } from 'node:test'; -import assert from 'node:assert/strict'; +import assert from 'node:assert'; function someAsyncThing() { return new Promise((resolve) => setTimeout(() => resolve(true), 50)); diff --git a/recipes/tape-to-node-test/tests/basic-equality/expected.js b/recipes/tape-to-node-test/tests/basic-equality/expected.js index bb442c69..67f743fc 100644 --- a/recipes/tape-to-node-test/tests/basic-equality/expected.js +++ b/recipes/tape-to-node-test/tests/basic-equality/expected.js @@ -1,5 +1,5 @@ import { test } from 'node:test'; -import assert from 'node:assert/strict'; +import assert from 'node:assert'; test("basic equality", async (t) => { // t.plan(4); diff --git a/recipes/tape-to-node-test/tests/callback-style/expected.js b/recipes/tape-to-node-test/tests/callback-style/expected.js index 007e8b71..d68f60fa 100644 --- a/recipes/tape-to-node-test/tests/callback-style/expected.js +++ b/recipes/tape-to-node-test/tests/callback-style/expected.js @@ -1,5 +1,5 @@ import { test } from 'node:test'; -import assert from 'node:assert/strict'; +import assert from 'node:assert'; test("callback style", (t, done) => { setTimeout(() => { diff --git a/recipes/tape-to-node-test/tests/cjs-destructuring/expected.js b/recipes/tape-to-node-test/tests/cjs-destructuring/expected.js index 63dc1a02..b8fced1e 100644 --- a/recipes/tape-to-node-test/tests/cjs-destructuring/expected.js +++ b/recipes/tape-to-node-test/tests/cjs-destructuring/expected.js @@ -1,5 +1,5 @@ const { test } = require('node:test'); -const assert = require('node:assert/strict'); +const assert = require('node:assert'); test('cjs destructuring', async (t) => { assert.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 index a76f03dc..33e66855 100644 --- a/recipes/tape-to-node-test/tests/deep-equality/expected.js +++ b/recipes/tape-to-node-test/tests/deep-equality/expected.js @@ -1,5 +1,5 @@ import { test } from 'node:test'; -import assert from 'node:assert/strict'; +import assert from 'node:assert'; test("deep equality", async (t) => { // t.plan(2); diff --git a/recipes/tape-to-node-test/tests/dynamic-import/expected.js b/recipes/tape-to-node-test/tests/dynamic-import/expected.js index 8021c863..30a80504 100644 --- a/recipes/tape-to-node-test/tests/dynamic-import/expected.js +++ b/recipes/tape-to-node-test/tests/dynamic-import/expected.js @@ -1,6 +1,6 @@ async function run() { const { test } = await import('node:test'); -const { default: assert } = await import('node:assert/strict'); +const { default: assert } = await import('node:assert'); test("dynamic import", async (t) => { assert.ok(true); diff --git a/recipes/tape-to-node-test/tests/lifecycle/expected.js b/recipes/tape-to-node-test/tests/lifecycle/expected.js index b37e3c9f..fd056b67 100644 --- a/recipes/tape-to-node-test/tests/lifecycle/expected.js +++ b/recipes/tape-to-node-test/tests/lifecycle/expected.js @@ -1,5 +1,5 @@ import { test } from 'node:test'; -import assert from 'node:assert/strict'; +import assert from 'node:assert'; let teardownState = 1; diff --git a/recipes/tape-to-node-test/tests/nested-test/expected.js b/recipes/tape-to-node-test/tests/nested-test/expected.js index 0e0ed2ad..3fa5ea21 100644 --- a/recipes/tape-to-node-test/tests/nested-test/expected.js +++ b/recipes/tape-to-node-test/tests/nested-test/expected.js @@ -1,5 +1,5 @@ import { test } from 'node:test'; -import assert from 'node:assert/strict'; +import assert from 'node:assert'; test("nested tests", async (t) => { // t.plan(1); 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..f123825d --- /dev/null +++ b/recipes/tape-to-node-test/tests/new-features/expected.js @@ -0,0 +1,18 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); + +test('new features', async (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'); + // t.end(); +}); + +// 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..c463782b --- /dev/null +++ b/recipes/tape-to-node-test/tests/new-features/input.js @@ -0,0 +1,17 @@ +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'); + t.end(); +}); + +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 index 24b21f56..50dc6bfc 100644 --- a/recipes/tape-to-node-test/tests/no-callback-args/expected.js +++ b/recipes/tape-to-node-test/tests/no-callback-args/expected.js @@ -1,5 +1,5 @@ import { test } from 'node:test'; -import assert from 'node:assert/strict'; +import assert from 'node:assert'; test('sync test with no args', async () => { const a = 1; diff --git a/recipes/tape-to-node-test/tests/require-import/expected.js b/recipes/tape-to-node-test/tests/require-import/expected.js index 13e98997..630eed4f 100644 --- a/recipes/tape-to-node-test/tests/require-import/expected.js +++ b/recipes/tape-to-node-test/tests/require-import/expected.js @@ -1,5 +1,5 @@ const { test } = require('node:test'); -const assert = require('node:assert/strict'); +const assert = require('node:assert'); test("require test", async (t) => { assert.strictEqual(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 index bca22464..0dd1bc4a 100644 --- a/recipes/tape-to-node-test/tests/test-options/expected.js +++ b/recipes/tape-to-node-test/tests/test-options/expected.js @@ -1,5 +1,5 @@ import { test } from 'node:test'; -import assert from 'node:assert/strict'; +import assert from 'node:assert'; test.skip('skipped test', async (t) => { assert.fail('should not run'); diff --git a/recipes/tape-to-node-test/tests/timeout/expected.js b/recipes/tape-to-node-test/tests/timeout/expected.js index 6fac34ea..62d486eb 100644 --- a/recipes/tape-to-node-test/tests/timeout/expected.js +++ b/recipes/tape-to-node-test/tests/timeout/expected.js @@ -1,19 +1,19 @@ import { test } from 'node:test'; -import assert from 'node:assert/strict'; +import assert from 'node:assert'; test('timeout test', { timeout: 100 }, async (t) => { - + // t.end(); }); test('timeout test with options', { skip: false, timeout: 200 }, async (t) => { - + // t.end(); }); test('nested timeout', async (t) => { await t.test('inner', { timeout: 50 }, async (st) => { - + // st.end(); }); }); diff --git a/recipes/tape-to-node-test/tests/truthiness/expected.js b/recipes/tape-to-node-test/tests/truthiness/expected.js index b47ca52c..fc7b2962 100644 --- a/recipes/tape-to-node-test/tests/truthiness/expected.js +++ b/recipes/tape-to-node-test/tests/truthiness/expected.js @@ -1,5 +1,5 @@ import { test } from 'node:test'; -import assert from 'node:assert/strict'; +import assert from 'node:assert'; test("truthiness", async (t) => { // t.plan(4); From 506db4875bca771c4094c2b26ae145a68c3020a8 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Fri, 12 Dec 2025 11:29:17 +0100 Subject: [PATCH 10/23] fix: linting/issue --- biome.jsonc | 3 +-- index.ts | 14 ++++++++++++++ recipes/tape-to-node-test/src/workflow.ts | 10 +--------- 3 files changed, 16 insertions(+), 11 deletions(-) create mode 100644 index.ts 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/index.ts b/index.ts new file mode 100644 index 00000000..d8f0eea7 --- /dev/null +++ b/index.ts @@ -0,0 +1,14 @@ +console.log({ + has: { + field: 'function', + kind: 'member_expression', + has: { + field: 'object', + regex: `^foo$`, + }, + has: { + field: 'property', + regex: '^(onFinish|onFailure)$', + }, + }, +}); diff --git a/recipes/tape-to-node-test/src/workflow.ts b/recipes/tape-to-node-test/src/workflow.ts index b5cc2545..8f4d75ab 100644 --- a/recipes/tape-to-node-test/src/workflow.ts +++ b/recipes/tape-to-node-test/src/workflow.ts @@ -255,15 +255,7 @@ export default function transform(root: SgRoot): string | null { kind: 'call_expression', has: { field: 'function', - kind: 'member_expression', - has: { - field: 'object', - regex: `^${testVarName}$`, - }, - has: { - field: 'property', - regex: '^(onFinish|onFailure)$', - }, + regex: `^${testVarName}\\.(onFinish|onFailure)$`, }, }, }); From 256c540c09575fdae4acd11136e12c2c6c64c8cc Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Fri, 12 Dec 2025 11:45:04 +0100 Subject: [PATCH 11/23] test,fix: handling of timeout --- recipes/tape-to-node-test/src/workflow.ts | 40 ++++++++++++++----- .../tests/timeout-non-object/expected.js | 9 +++++ .../tests/timeout-non-object/input.js | 8 ++++ 3 files changed, 47 insertions(+), 10 deletions(-) create mode 100644 recipes/tape-to-node-test/tests/timeout-non-object/expected.js create mode 100644 recipes/tape-to-node-test/tests/timeout-non-object/input.js diff --git a/recipes/tape-to-node-test/src/workflow.ts b/recipes/tape-to-node-test/src/workflow.ts index 8f4d75ab..4d8bfd0f 100644 --- a/recipes/tape-to-node-test/src/workflow.ts +++ b/recipes/tape-to-node-test/src/workflow.ts @@ -417,13 +417,6 @@ function transformAssertions( const timeoutArg = args?.child(1); // child(0) is '(' if (timeoutArg) { const timeoutVal = timeoutArg.text(); - // Remove the call - const parent = call.parent(); - if (parent && parent.kind() === 'expression_statement') { - edits.push(parent.replace('')); - } else { - edits.push(call.replace('')); - } // Add to test options const testArgs = testCall.field('arguments'); @@ -453,6 +446,13 @@ function transformAssertions( 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]; @@ -468,6 +468,13 @@ function transformAssertions( 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. @@ -481,12 +488,17 @@ function transformAssertions( 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 - // TODO: Handle this case? - // For now, maybe just log a comment + // Options is a variable or expression — replace the timeout call with a TODO comment edits.push( call.replace( `// TODO: Add timeout: ${timeoutVal} to test options manually`, @@ -494,6 +506,14 @@ function transformAssertions( ); } } + } 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('')); + } } } } 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..7990c57a --- /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, async (t) => { + // TODO: Add timeout: 123 to test options manually; + // t.end(); +}); 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..8a4141f5 --- /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.end(); +}); From a6caff6ca75e902e1a5bce9825987221f758426c Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Fri, 12 Dec 2025 11:46:07 +0100 Subject: [PATCH 12/23] Delete index.ts --- index.ts | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 index.ts diff --git a/index.ts b/index.ts deleted file mode 100644 index d8f0eea7..00000000 --- a/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -console.log({ - has: { - field: 'function', - kind: 'member_expression', - has: { - field: 'object', - regex: `^foo$`, - }, - has: { - field: 'property', - regex: '^(onFinish|onFailure)$', - }, - }, -}); From c0037e1c8347f7f4d06ab2d2ba3e8bfba365f3ae Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Mon, 15 Dec 2025 20:28:13 +0100 Subject: [PATCH 13/23] remove dep --- recipes/tape-to-node-test/codemod.yaml | 4 ++++ .../src/remove-dependencies.ts | 8 ++++++++ recipes/tape-to-node-test/workflow.yaml | 17 +++++++++++++++++ 3 files changed, 29 insertions(+) create mode 100644 recipes/tape-to-node-test/src/remove-dependencies.ts diff --git a/recipes/tape-to-node-test/codemod.yaml b/recipes/tape-to-node-test/codemod.yaml index 35191120..e5128dd7 100644 --- a/recipes/tape-to-node-test/codemod.yaml +++ b/recipes/tape-to-node-test/codemod.yaml @@ -17,3 +17,7 @@ keywords: - migration - tape - node:test + +capabilities: + - fs + - child_process 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..5c0ed0c6 --- /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/workflow.yaml b/recipes/tape-to-node-test/workflow.yaml index 99196f59..7b331fdb 100644 --- a/recipes/tape-to-node-test/workflow.yaml +++ b/recipes/tape-to-node-test/workflow.yaml @@ -20,3 +20,20 @@ nodes: - "**/*.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 From 5b9ce43e2f071e829e2de6b566cad0d65320d15c Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Thu, 18 Dec 2025 22:35:58 +0100 Subject: [PATCH 14/23] Apply suggestions from code review Co-authored-by: Bruno Rodrigues --- recipes/tape-to-node-test/src/workflow.ts | 372 +++++++++++----------- 1 file changed, 185 insertions(+), 187 deletions(-) diff --git a/recipes/tape-to-node-test/src/workflow.ts b/recipes/tape-to-node-test/src/workflow.ts index 4d8bfd0f..39cc5fc0 100644 --- a/recipes/tape-to-node-test/src/workflow.ts +++ b/recipes/tape-to-node-test/src/workflow.ts @@ -187,14 +187,13 @@ export default function transform(root: SgRoot): string | null { kind: 'member_expression', has: { field: 'object', - regex: `^${tName}$`, + pattern: tName, }, }, }, }); for (const endCall of endCalls) { - let isNested = false; let curr = endCall.parent(); while (curr && curr.id() !== body.id()) { if ( @@ -202,15 +201,11 @@ export default function transform(root: SgRoot): string | null { curr.kind() === 'function_expression' || curr.kind() === 'function_declaration' ) { - isNested = true; + usesEndInCallback = true; break; } curr = curr.parent(); } - - if (isNested) { - usesEndInCallback = true; - } } } @@ -295,7 +290,7 @@ function transformAssertions( kind: 'member_expression', has: { field: 'object', - regex: `^${tName}$`, + pattern: tName, }, }, }, @@ -305,188 +300,167 @@ function transformAssertions( 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]; - const func = call.field('function'); if (func) { edits.push(func.replace(`assert.${newMethod}`)); } - } else if (method === 'notOk' || method === 'notok') { - // t.notOk(val, msg) -> assert.ok(!val, msg) - const args = call.field('arguments'); - 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')); - } - } - } else if (method === 'comment') { - // t.comment(msg) -> t.diagnostic(msg) - const func = call.field('function'); - if (func) edits.push(func.replace(`${tName}.diagnostic`)); - } else if (method === 'true') { - // t.true(val, msg) -> assert.ok(val, msg) - const func = call.field('function'); - if (func) edits.push(func.replace('assert.ok')); - } else if (method === 'false') { - // t.false(val, msg) -> assert.ok(!val, msg) - const args = call.field('arguments'); - if (args) { - const val = args.child(1); - 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')); + 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')); + } } - } - } else if (method === 'pass') { - // t.pass(msg) -> assert.ok(true, msg) - const args = call.field('arguments'); - 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', - }); - 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')); + } } - } - } else if (method === 'plan') { - edits.push(call.replace(`// ${call.text()}`)); - } else if (method === 'end') { - if (useDone) { - edits.push(call.replace('done()')); - } else { - edits.push(call.replace(`// ${call.text()}`)); - } - } else if (method === 'test') { - edits.push({ - startPos: call.range().start.index, - endPos: call.range().start.index, - insertedText: 'await ', - }); - - const args = call.field('arguments'); - 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'); - if (b) transformAssertions(b, stName, edits, call); - - if (!cb.text().startsWith('async')) { - if (p) { + 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: cb.range().start.index, - endPos: p.range().start.index, - insertedText: 'async ', + 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')); } } - } - } else if (method === 'teardown') { - const func = call.field('function'); - if (func) { - edits.push(func.replace(`${tName}.after`)); - } - } else if (method === 'timeoutAfter') { - const args = call.field('arguments'); - 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( + break; + case 'plan': + edits.push(call.replace(`// ${call.text()}`)); + break; + case 'end': + if (useDone) { + edits.push(call.replace('done()')); + } else { + edits.push(call.replace(`// ${call.text()}`)); + } + break; + case 'test': + edits.push({ + startPos: call.range().start.index, + endPos: call.range().start.index, + insertedText: 'await ', + }); + const cb = args + ?.children() + .find( (c) => - c.kind() !== '(' && - c.kind() !== ')' && - c.kind() !== ',' && - c.kind() !== 'comment', + 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(); - 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('')); + const b = cb.field('body'); + if (b) transformAssertions(b, stName, edits, call); + + if (!cb.text().startsWith('async')) { + if (p) { + edits.push({ + startPos: cb.range().start.index, + endPos: p.range().start.index, + insertedText: 'async ', + }); } - } 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('')); - } + } + } + 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 { - // 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 + 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() - .find((c) => c.text() === '}'); - if (closingBrace) { + .filter((c) => c.kind() === 'pair'); + if (props.length > 0) { + const lastProp = props[props.length - 1]; edits.push({ - startPos: closingBrace.range().start.index, - endPos: closingBrace.range().start.index, - insertedText: ` timeout: ${timeoutVal} `, + startPos: lastProp.range().end.index, + endPos: lastProp.range().end.index, + insertedText: `, timeout: ${timeoutVal}`, }); // remove the original timeout call const parent = call.parent(); @@ -495,27 +469,51 @@ function transformAssertions( } 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 + edits.push( + call.replace( + `// TODO: Add timeout: ${timeoutVal} to test options manually`, + ), + ); } - } else { - // Options is a variable or expression — replace the timeout call with a TODO comment - edits.push( - call.replace( - `// TODO: 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('')); + // 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('method not handled'); } } } From 5e5d232045af1d7be6013baedb4dc881867ffa8b Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Thu, 18 Dec 2025 22:38:34 +0100 Subject: [PATCH 15/23] fix: code style --- recipes/tape-to-node-test/src/workflow.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/recipes/tape-to-node-test/src/workflow.ts b/recipes/tape-to-node-test/src/workflow.ts index 39cc5fc0..eae05673 100644 --- a/recipes/tape-to-node-test/src/workflow.ts +++ b/recipes/tape-to-node-test/src/workflow.ts @@ -371,7 +371,7 @@ function transformAssertions( edits.push(call.replace(`// ${call.text()}`)); } break; - case 'test': + case 'test': { edits.push({ startPos: call.range().start.index, endPos: call.range().start.index, @@ -404,10 +404,11 @@ function transformAssertions( } } break; + } case 'teardown': if (func) edits.push(func.replace(`${tName}.after`)); break; - case 'timeoutafter': + case 'timeoutafter': { const timeoutArg = args?.child(1); // child(0) is '(' if (timeoutArg) { const timeoutVal = timeoutArg.text(); @@ -512,6 +513,7 @@ function transformAssertions( } break; + } default: console.log('method not handled'); } From 8e0e92c4f8a30683d8fd08fa851d725907f10ad9 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Sat, 17 Jan 2026 17:20:20 +0100 Subject: [PATCH 16/23] Update recipes/tape-to-node-test/src/workflow.ts Co-authored-by: Bruno Rodrigues --- recipes/tape-to-node-test/src/workflow.ts | 88 +++++++++-------------- 1 file changed, 32 insertions(+), 56 deletions(-) diff --git a/recipes/tape-to-node-test/src/workflow.ts b/recipes/tape-to-node-test/src/workflow.ts index eae05673..5e33a9c1 100644 --- a/recipes/tape-to-node-test/src/workflow.ts +++ b/recipes/tape-to-node-test/src/workflow.ts @@ -67,72 +67,48 @@ export default function transform(root: SgRoot): string | null { const tapeRequires = getNodeRequireCalls(root, 'tape'); const tapeImportCalls = getNodeImportCalls(root, 'tape'); - if ( - tapeImports.length === 0 && - tapeRequires.length === 0 && - tapeImportCalls.length === 0 - ) { + 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 === 0) { return null; } let testVarName = 'test'; // 1. Replace imports - for (const imp of tapeImports) { - const defaultImport = imp.find({ - rule: { kind: 'import_clause', has: { kind: 'identifier' } }, - }); - if (defaultImport) { - const id = defaultImport.find({ rule: { kind: 'identifier' } }); - if (id) testVarName = id.text(); - edits.push( - imp.replace( - `import { test } from 'node:test';${EOL}import assert from 'node:assert';`, - ), - ); + for (const mod of modDeps) { + if (mod.node.kind() === 'variable_declarator') { + mod.node = mod.node.parent(); } - } - for (const req of tapeRequires) { - const id = req.find({ - rule: { kind: 'identifier', inside: { kind: 'variable_declarator' } }, + const binding = mod.node.find({ + rule: { + any: [ + { kind: 'identifier', inside: { kind: 'variable_declarator' } }, + { + kind: 'identifier', + inside: { kind: 'import_clause', stopBy: 'end' }, + }, + ], + }, }); - if (id) testVarName = id.text(); - const declaration = req - .ancestors() - .find( - (a) => - a.kind() === 'variable_declaration' || - a.kind() === 'lexical_declaration', - ); - if (declaration) { - edits.push( - declaration.replace( - `const { test } = require('node:test');${EOL}const assert = require('node:assert');`, - ), - ); - } - } - for (const call of tapeImportCalls) { - const id = call.find({ - rule: { kind: 'identifier', inside: { kind: 'variable_declarator' } }, - }); - if (id) testVarName = id.text(); - const declaration = call - .ancestors() - .find( - (a) => - a.kind() === 'variable_declaration' || - a.kind() === 'lexical_declaration', - ); - if (declaration) { - edits.push( - declaration.replace( - `const { test } = await import('node:test');${EOL}const { default: assert } = await import('node:assert');`, - ), - ); - } + if (binding) testVarName = binding.text(); + + edits.push(mod.node.replace(mod.import)); } const testCalls = rootNode.findAll({ From 630c116e8b8a947dc1834f2960a95542eb84a200 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Sat, 17 Jan 2026 17:29:55 +0100 Subject: [PATCH 17/23] better async cb handling --- .../src/remove-dependencies.ts | 2 +- recipes/tape-to-node-test/src/workflow.ts | 61 ++++++++++++++++--- .../tests/no-callback-args/expected.js | 2 +- 3 files changed, 55 insertions(+), 10 deletions(-) diff --git a/recipes/tape-to-node-test/src/remove-dependencies.ts b/recipes/tape-to-node-test/src/remove-dependencies.ts index 5c0ed0c6..6c58e5dd 100644 --- a/recipes/tape-to-node-test/src/remove-dependencies.ts +++ b/recipes/tape-to-node-test/src/remove-dependencies.ts @@ -1,7 +1,7 @@ import removeDependencies from '@nodejs/codemod-utils/remove-dependencies'; /** - * Remove tape and @types/tape dependencies from package.json + * 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 index 5e33a9c1..b68201f7 100644 --- a/recipes/tape-to-node-test/src/workflow.ts +++ b/recipes/tape-to-node-test/src/workflow.ts @@ -205,16 +205,61 @@ export default function transform(root: SgRoot): string | null { } if (body) { + // Apply assertion transformations first transformAssertions(body, tName, edits, call, useDone); - } - if (!usesEndInCallback && !isAsync) { - if (params) { - edits.push({ - startPos: callback.range().start.index, - endPos: params.range().start.index, - insertedText: 'async ', - }); + // Determine if the callback needs to be async. + // It must be async if it already is, or if the body contains any await expressions, + // or if there are subtests (t.test(...)) which we convert to 'await test(...)'. + const hasAwait = Boolean( + body.find({ rule: { kind: 'await_expression' } }), + ); + const hasSubtestCall = Boolean( + body.find({ + rule: { + kind: 'call_expression', + all: [ + { + has: { + field: 'function', + kind: 'member_expression', + }, + }, + { + has: { + field: 'function', + kind: 'member_expression', + has: { field: 'object', pattern: tName }, + }, + }, + { + has: { + field: 'function', + kind: 'member_expression', + has: { field: 'property', regex: '^test$' }, + }, + }, + ], + }, + }), + ); + + // If the callback has a parameter (e.g., TestContext `t`), + // we keep it async to align with expected behavior unless it already uses done style. + // For zero-arg callbacks, only add async when truly needed (awaits or subtests). + const hasParam = Boolean(paramId); + const needsAsync = hasParam + ? true + : isAsync || hasAwait || hasSubtestCall; + + if (!usesEndInCallback && !isAsync && needsAsync) { + if (params) { + edits.push({ + startPos: callback.range().start.index, + endPos: params.range().start.index, + insertedText: 'async ', + }); + } } } } 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 index 50dc6bfc..8f5d2d8e 100644 --- a/recipes/tape-to-node-test/tests/no-callback-args/expected.js +++ b/recipes/tape-to-node-test/tests/no-callback-args/expected.js @@ -1,7 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert'; -test('sync test with no args', async () => { +test('sync test with no args', () => { const a = 1; }); From 654294a549333f5285316eb24dcd8b044322b91c Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Sat, 17 Jan 2026 17:32:14 +0100 Subject: [PATCH 18/23] wip --- recipes/tape-to-node-test/src/workflow.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/recipes/tape-to-node-test/src/workflow.ts b/recipes/tape-to-node-test/src/workflow.ts index b68201f7..df7609fa 100644 --- a/recipes/tape-to-node-test/src/workflow.ts +++ b/recipes/tape-to-node-test/src/workflow.ts @@ -536,7 +536,7 @@ function transformAssertions( break; } default: - console.log('method not handled'); + console.log(`Warning: Unhandled Tape assertion method: ${method}`); } } } From ddf833add5d7a459fc52f8689640ccd2d039ab47 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Sat, 17 Jan 2026 17:52:14 +0100 Subject: [PATCH 19/23] handle `test` and `plan` --- recipes/tape-to-node-test/src/workflow.ts | 74 +++++++++++++++---- .../tests/basic-equality/expected.js | 10 +-- .../tests/basic-equality/input.js | 10 +-- .../tests/plan-and-end/expected.js | 11 +++ .../tests/plan-and-end/input.js | 10 +++ .../tests/timeout-non-object/expected.js | 4 +- .../tests/timeout-non-object/input.js | 2 +- 7 files changed, 92 insertions(+), 29 deletions(-) create mode 100644 recipes/tape-to-node-test/tests/plan-and-end/expected.js create mode 100644 recipes/tape-to-node-test/tests/plan-and-end/input.js diff --git a/recipes/tape-to-node-test/src/workflow.ts b/recipes/tape-to-node-test/src/workflow.ts index df7609fa..b0b2ba5f 100644 --- a/recipes/tape-to-node-test/src/workflow.ts +++ b/recipes/tape-to-node-test/src/workflow.ts @@ -154,21 +154,33 @@ export default function transform(root: SgRoot): string | null { const body = callback.field('body'); let usesEndInCallback = false; + let hasEndCall = false; + let hasPlanCall = false; if (body) { const endCalls = body.findAll({ rule: { kind: 'call_expression', - has: { - field: 'function', - kind: 'member_expression', - has: { - field: 'object', - pattern: tName, + 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()) { @@ -183,14 +195,41 @@ export default function transform(root: SgRoot): string | null { 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'); - let useDone = false; + const shouldUseDone = hasEndCall && (usesEndInCallback || hasPlanCall); + let useDone = shouldUseDone; - if (usesEndInCallback && !isAsync) { - useDone = true; - if (params) { + 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({ @@ -383,7 +422,9 @@ function transformAssertions( } break; case 'plan': - edits.push(call.replace(`// ${call.text()}`)); + if (!useDone) { + edits.push(call.replace(`// ${call.text()}`)); + } break; case 'end': if (useDone) { @@ -514,10 +555,15 @@ function transformAssertions( } } } else { - // Options is a variable or expression — replace the timeout call with a TODO comment + // 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: Add timeout: ${timeoutVal} to test options manually`, + `// TODO: Add timeout: \`${timeoutVal}\` to test options manually`, ), ); } diff --git a/recipes/tape-to-node-test/tests/basic-equality/expected.js b/recipes/tape-to-node-test/tests/basic-equality/expected.js index 67f743fc..16939ee1 100644 --- a/recipes/tape-to-node-test/tests/basic-equality/expected.js +++ b/recipes/tape-to-node-test/tests/basic-equality/expected.js @@ -2,10 +2,8 @@ import { test } from 'node:test'; import assert from 'node:assert'; test("basic equality", async (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"); - // t.end(); + 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 index 3d3a7533..fdb3efa2 100644 --- a/recipes/tape-to-node-test/tests/basic-equality/input.js +++ b/recipes/tape-to-node-test/tests/basic-equality/input.js @@ -1,10 +1,8 @@ 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"); - t.end(); + 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-and-end/expected.js b/recipes/tape-to-node-test/tests/plan-and-end/expected.js new file mode 100644 index 00000000..7437066b --- /dev/null +++ b/recipes/tape-to-node-test/tests/plan-and-end/expected.js @@ -0,0 +1,11 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; + +test("basic equality", async (t, done) => { + 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"); + done(); +}); 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..d422ae6b --- /dev/null +++ b/recipes/tape-to-node-test/tests/plan-and-end/input.js @@ -0,0 +1,10 @@ +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"); + t.end(); +}); 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 index 7990c57a..3553e12e 100644 --- a/recipes/tape-to-node-test/tests/timeout-non-object/expected.js +++ b/recipes/tape-to-node-test/tests/timeout-non-object/expected.js @@ -4,6 +4,6 @@ import assert from 'node:assert'; const opts = { skip: false }; test('timeout with variable opts', opts, async (t) => { - // TODO: Add timeout: 123 to test options manually; - // t.end(); + // TODO: 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 index 8a4141f5..77d0454e 100644 --- a/recipes/tape-to-node-test/tests/timeout-non-object/input.js +++ b/recipes/tape-to-node-test/tests/timeout-non-object/input.js @@ -4,5 +4,5 @@ const opts = { skip: false }; test('timeout with variable opts', opts, (t) => { t.timeoutAfter(123); - t.end(); + t.ok(true); }); From bfcb5eb4f9d32c7665c09e8ce26485161442aa3e Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Sat, 17 Jan 2026 18:17:26 +0100 Subject: [PATCH 20/23] update --- recipes/tape-to-node-test/README.md | 53 ++++++++++++++----- recipes/tape-to-node-test/src/workflow.ts | 19 ++++--- .../tests/async-test/expected.js | 2 +- .../tests/deep-equality/expected.js | 2 +- .../tests/lifecycle/expected.js | 2 +- .../tests/nested-test/expected.js | 10 ++-- .../tests/nested-test/input.js | 10 ++-- .../tests/on-failure/expected.js | 11 ++++ .../tests/on-failure/input.js | 10 ++++ .../tests/on-finish/expected.js | 11 ++++ .../tests/on-finish/input.js | 10 ++++ .../tests/plan-only/expected.js | 7 +++ .../tests/plan-only/input.js | 6 +++ .../tests/truthiness/expected.js | 2 +- 14 files changed, 120 insertions(+), 35 deletions(-) create mode 100644 recipes/tape-to-node-test/tests/on-failure/expected.js create mode 100644 recipes/tape-to-node-test/tests/on-failure/input.js create mode 100644 recipes/tape-to-node-test/tests/on-finish/expected.js create mode 100644 recipes/tape-to-node-test/tests/on-finish/input.js create mode 100644 recipes/tape-to-node-test/tests/plan-only/expected.js create mode 100644 recipes/tape-to-node-test/tests/plan-only/input.js diff --git a/recipes/tape-to-node-test/README.md b/recipes/tape-to-node-test/README.md index 4dcbebef..76c5dd56 100644 --- a/recipes/tape-to-node-test/README.md +++ b/recipes/tape-to-node-test/README.md @@ -7,14 +7,17 @@ This codemod migrates tests written using [`tape`](https://github.com/tape-testi - 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`). -- Handles `t.plan` (by commenting it out). -- Handles `t.end` (removes it for async tests, converts to `done` callback for callback-style tests). -- Handles `t.test` subtests (adds `await`). +- 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` (by commenting them out with a TODO). +- Handles `test.onFinish` and `test.onFailure` (comments them out with TODO and warning). - Supports loose equality assertions (e.g., `t.looseEqual` -> `assert.equal`). ## Example @@ -28,8 +31,6 @@ This codemod migrates tests written using [`tape`](https://github.com/tape-testi - test("basic equality", (t) => { + test("basic equality", async (t) => { -- t.plan(4); -+ // t.plan(4); - t.equal(1, 1, "equal numbers"); + assert.strictEqual(1, 1, "equal numbers"); - t.notEqual(1, 2, "not equal numbers"); @@ -38,8 +39,25 @@ This codemod migrates tests written using [`tape`](https://github.com/tape-testi + 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", async (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(); -+ // t.end(); ++ done(); }); ``` @@ -56,8 +74,7 @@ This codemod migrates tests written using [`tape`](https://github.com/tape-testi - test("async test with promises", async (t) => { + test("async test with promises", async (t) => { -- t.plan(1); -+ // t.plan(1); + t.plan(1); const result = await someAsyncThing(); - t.ok(result, "async result is truthy"); + assert.ok(result, "async result is truthy"); @@ -69,7 +86,7 @@ This codemod migrates tests written using [`tape`](https://github.com/tape-testi ```diff - import test from "tape"; + import { test } from 'node:test'; -+ import assert from 'node:assert/strict'; ++ import assert from 'node:assert'; - test("callback style", (t) => { + test("callback style", (t, done) => { @@ -87,11 +104,11 @@ This codemod migrates tests written using [`tape`](https://github.com/tape-testi ```diff - import test from "tape"; + import { test } from 'node:test'; -+ import assert from 'node:assert/strict'; ++ import assert from 'node:assert'; - test("timeout test", (t) => { -+ test("timeout test", { timeout: 100 }, async (t) => { - t.timeoutAfter(100); ++ test("timeout test", { timeout: 100 }, async (t) => { - t.ok(true); + assert.ok(true); - t.end(); @@ -105,7 +122,7 @@ This codemod migrates tests written using [`tape`](https://github.com/tape-testi async function run() { - const test = await import("tape"); + const { test } = await import('node:test'); -+ const { default: assert } = await import('node:assert/strict'); ++ const { default: assert } = await import('node:assert'); - test("dynamic import", (t) => { + test("dynamic import", async (t) => { @@ -116,3 +133,13 @@ This codemod migrates tests written using [`tape`](https://github.com/tape-testi }); } ``` + +## 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/src/workflow.ts b/recipes/tape-to-node-test/src/workflow.ts index b0b2ba5f..03d59aff 100644 --- a/recipes/tape-to-node-test/src/workflow.ts +++ b/recipes/tape-to-node-test/src/workflow.ts @@ -82,9 +82,7 @@ export default function transform(root: SgRoot): string | null { })), ]; - if (modDeps.length === 0) { - return null; - } + if (!modDeps.length) return null; let testVarName = 'test'; @@ -223,7 +221,6 @@ export default function transform(root: SgRoot): string | null { const isAsync = callback.text().startsWith('async'); const shouldUseDone = hasEndCall && (usesEndInCallback || hasPlanCall); - let useDone = shouldUseDone; if (shouldUseDone && params) { const hasDoneParam = Boolean( @@ -245,7 +242,7 @@ export default function transform(root: SgRoot): string | null { if (body) { // Apply assertion transformations first - transformAssertions(body, tName, edits, call, useDone); + transformAssertions(body, tName, edits, call, shouldUseDone); // Determine if the callback needs to be async. // It must be async if it already is, or if the body contains any await expressions, @@ -316,6 +313,15 @@ export default function transform(root: SgRoot): string | null { }); 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}`)) @@ -422,9 +428,6 @@ function transformAssertions( } break; case 'plan': - if (!useDone) { - edits.push(call.replace(`// ${call.text()}`)); - } break; case 'end': if (useDone) { diff --git a/recipes/tape-to-node-test/tests/async-test/expected.js b/recipes/tape-to-node-test/tests/async-test/expected.js index 40cb081e..2316789c 100644 --- a/recipes/tape-to-node-test/tests/async-test/expected.js +++ b/recipes/tape-to-node-test/tests/async-test/expected.js @@ -6,7 +6,7 @@ function someAsyncThing() { } test("async test with promises", async (t) => { - // t.plan(1); + t.plan(1); const result = await someAsyncThing(); assert.ok(result, "async result is truthy"); }); diff --git a/recipes/tape-to-node-test/tests/deep-equality/expected.js b/recipes/tape-to-node-test/tests/deep-equality/expected.js index 33e66855..78b11f65 100644 --- a/recipes/tape-to-node-test/tests/deep-equality/expected.js +++ b/recipes/tape-to-node-test/tests/deep-equality/expected.js @@ -2,7 +2,7 @@ import { test } from 'node:test'; import assert from 'node:assert'; test("deep equality", async (t) => { - // t.plan(2); + 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/lifecycle/expected.js b/recipes/tape-to-node-test/tests/lifecycle/expected.js index fd056b67..f8d00a50 100644 --- a/recipes/tape-to-node-test/tests/lifecycle/expected.js +++ b/recipes/tape-to-node-test/tests/lifecycle/expected.js @@ -4,7 +4,7 @@ import assert from 'node:assert'; let teardownState = 1; test("teardown registers and runs after test", async (t) => { - // t.plan(1); + t.plan(1); t.after(() => { teardownState = 0; }); assert.strictEqual(teardownState, 1, "state before teardown"); }); diff --git a/recipes/tape-to-node-test/tests/nested-test/expected.js b/recipes/tape-to-node-test/tests/nested-test/expected.js index 3fa5ea21..36364376 100644 --- a/recipes/tape-to-node-test/tests/nested-test/expected.js +++ b/recipes/tape-to-node-test/tests/nested-test/expected.js @@ -2,9 +2,9 @@ import { test } from 'node:test'; import assert from 'node:assert'; test("nested tests", async (t) => { - // t.plan(1); - await t.test("inner test 1", async (st) => { - // st.plan(1); - assert.strictEqual(1, 1, "inner assertion"); - }); + t.plan(1); + await t.test("inner test 1", async (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 index 4b585062..1981e73a 100644 --- a/recipes/tape-to-node-test/tests/nested-test/input.js +++ b/recipes/tape-to-node-test/tests/nested-test/input.js @@ -1,9 +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"); - }); + 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/on-failure/expected.js b/recipes/tape-to-node-test/tests/on-failure/expected.js new file mode 100644 index 00000000..dcb03262 --- /dev/null +++ b/recipes/tape-to-node-test/tests/on-failure/expected.js @@ -0,0 +1,11 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; + +test('failing test', async (t) => { + assert.strictEqual(1, 2, 'this will fail'); + // t.end(); +}); + +// 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..3bef5b0c --- /dev/null +++ b/recipes/tape-to-node-test/tests/on-failure/input.js @@ -0,0 +1,10 @@ +import test from 'tape'; + +test('failing test', (t) => { + t.equal(1, 2, 'this will fail'); + t.end(); +}); + +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..f5604614 --- /dev/null +++ b/recipes/tape-to-node-test/tests/on-finish/expected.js @@ -0,0 +1,11 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; + +test('some test', async (t) => { + assert.ok(true, 'assertion passes'); + // t.end(); +}); + +// 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..b4f69aa3 --- /dev/null +++ b/recipes/tape-to-node-test/tests/on-finish/input.js @@ -0,0 +1,10 @@ +import test from 'tape'; + +test('some test', (t) => { + t.ok(true, 'assertion passes'); + t.end(); +}); + +test.onFinish(() => { + console.log('All tests finished'); +}); 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..12af95bb --- /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', async (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/truthiness/expected.js b/recipes/tape-to-node-test/tests/truthiness/expected.js index fc7b2962..e1e92a80 100644 --- a/recipes/tape-to-node-test/tests/truthiness/expected.js +++ b/recipes/tape-to-node-test/tests/truthiness/expected.js @@ -2,7 +2,7 @@ import { test } from 'node:test'; import assert from 'node:assert'; test("truthiness", async (t) => { - // t.plan(4); + t.plan(4); assert.ok(true, "true is ok"); assert.ok(!false, "false is not ok"); assert.ok(true, "explicitly true"); From a44f92e16ef29fbe79ae5eb8aa611d03593dda8a Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Wed, 21 Jan 2026 21:00:29 +0100 Subject: [PATCH 21/23] WIP --- recipes/tape-to-node-test/README.md | 4 +--- recipes/tape-to-node-test/src/workflow.ts | 2 +- .../tests/advanced-assertions/expected.js | 15 +++++++-------- .../tests/advanced-assertions/input.js | 15 +++++++-------- .../tests/aliased-import/expected.js | 3 +-- .../tests/aliased-import/input.js | 3 +-- .../tests/callback-style/expected.js | 9 ++++----- .../tests/callback-style/input.js | 7 +++---- .../tests/cjs-destructuring/expected.js | 3 +-- .../tests/cjs-destructuring/input.js | 3 +-- .../tests/dynamic-import/expected.js | 11 +++++------ .../tests/dynamic-import/input.js | 11 +++++------ .../tests/new-features/expected.js | 19 +++++++++---------- .../tests/new-features/input.js | 19 +++++++++---------- .../tests/on-failure/expected.js | 1 - .../tests/on-failure/input.js | 1 - .../tests/on-finish/expected.js | 1 - .../tests/on-finish/input.js | 1 - .../tests/plan-and-end/expected.js | 3 +-- .../tests/plan-and-end/input.js | 1 - .../tests/require-import/expected.js | 3 +-- .../tests/require-import/input.js | 3 +-- .../tests/test-options/expected.js | 6 ++---- .../tests/test-options/input.js | 6 ++---- .../tests/timeout-non-object/expected.js | 2 +- 25 files changed, 63 insertions(+), 89 deletions(-) diff --git a/recipes/tape-to-node-test/README.md b/recipes/tape-to-node-test/README.md index 76c5dd56..42e9d88b 100644 --- a/recipes/tape-to-node-test/README.md +++ b/recipes/tape-to-node-test/README.md @@ -50,7 +50,7 @@ This codemod migrates tests written using [`tape`](https://github.com/tape-testi + import assert from 'node:assert'; - test("plan with end", (t) => { -+ test("plan with end", async (t, done) => { ++ test("plan with end", (t, done) => { t.plan(2); - t.equal(1, 1, "first assertion"); + assert.strictEqual(1, 1, "first assertion"); @@ -111,8 +111,6 @@ This codemod migrates tests written using [`tape`](https://github.com/tape-testi + test("timeout test", { timeout: 100 }, async (t) => { - t.ok(true); + assert.ok(true); -- t.end(); -+ // t.end(); }); ``` diff --git a/recipes/tape-to-node-test/src/workflow.ts b/recipes/tape-to-node-test/src/workflow.ts index 03d59aff..58e30b82 100644 --- a/recipes/tape-to-node-test/src/workflow.ts +++ b/recipes/tape-to-node-test/src/workflow.ts @@ -566,7 +566,7 @@ function transformAssertions( ); edits.push( call.replace( - `// TODO: Add timeout: \`${timeoutVal}\` to test options manually`, + `// TODO(codemod@nodejs/tape-to-node-test): Add timeout: \`${timeoutVal}\` to test options manually`, ), ); } diff --git a/recipes/tape-to-node-test/tests/advanced-assertions/expected.js b/recipes/tape-to-node-test/tests/advanced-assertions/expected.js index 44681e4f..d6cbec54 100644 --- a/recipes/tape-to-node-test/tests/advanced-assertions/expected.js +++ b/recipes/tape-to-node-test/tests/advanced-assertions/expected.js @@ -2,12 +2,11 @@ import { test } from 'node:test'; import assert from 'node:assert'; test('advanced assertions', async (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); - // t.end(); + 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 index 2be9e2ea..56934e15 100644 --- a/recipes/tape-to-node-test/tests/advanced-assertions/input.js +++ b/recipes/tape-to-node-test/tests/advanced-assertions/input.js @@ -1,12 +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); - t.end(); + 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 index 2a9c33a5..c3ada63c 100644 --- a/recipes/tape-to-node-test/tests/aliased-import/expected.js +++ b/recipes/tape-to-node-test/tests/aliased-import/expected.js @@ -2,6 +2,5 @@ import { test } from 'node:test'; import assert from 'node:assert'; test('aliased test', async (t) => { - assert.strictEqual(1, 1); - // t.end(); + 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 index e00f6825..200fffc6 100644 --- a/recipes/tape-to-node-test/tests/aliased-import/input.js +++ b/recipes/tape-to-node-test/tests/aliased-import/input.js @@ -1,6 +1,5 @@ import myTest from 'tape'; myTest('aliased test', (t) => { - t.equal(1, 1); - t.end(); + t.equal(1, 1); }); diff --git a/recipes/tape-to-node-test/tests/callback-style/expected.js b/recipes/tape-to-node-test/tests/callback-style/expected.js index d68f60fa..c2160094 100644 --- a/recipes/tape-to-node-test/tests/callback-style/expected.js +++ b/recipes/tape-to-node-test/tests/callback-style/expected.js @@ -1,9 +1,8 @@ import { test } from 'node:test'; import assert from 'node:assert'; -test("callback style", (t, done) => { - setTimeout(() => { - assert.ok(true); - done(); - }, 100); +test("callback style", async (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 index bbbe722e..5fc7745e 100644 --- a/recipes/tape-to-node-test/tests/callback-style/input.js +++ b/recipes/tape-to-node-test/tests/callback-style/input.js @@ -1,8 +1,7 @@ import test from "tape"; test("callback style", (t) => { - setTimeout(() => { - t.ok(true); - t.end(); - }, 100); + 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 index b8fced1e..1b6b5d6d 100644 --- a/recipes/tape-to-node-test/tests/cjs-destructuring/expected.js +++ b/recipes/tape-to-node-test/tests/cjs-destructuring/expected.js @@ -2,6 +2,5 @@ const { test } = require('node:test'); const assert = require('node:assert'); test('cjs destructuring', async (t) => { - assert.ok(true); - // t.end(); + 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 index 36d7268c..d80dc155 100644 --- a/recipes/tape-to-node-test/tests/cjs-destructuring/input.js +++ b/recipes/tape-to-node-test/tests/cjs-destructuring/input.js @@ -1,6 +1,5 @@ const { test } = require('tape'); test('cjs destructuring', (t) => { - t.ok(true); - t.end(); + t.ok(true); }); diff --git a/recipes/tape-to-node-test/tests/dynamic-import/expected.js b/recipes/tape-to-node-test/tests/dynamic-import/expected.js index 30a80504..7132ff81 100644 --- a/recipes/tape-to-node-test/tests/dynamic-import/expected.js +++ b/recipes/tape-to-node-test/tests/dynamic-import/expected.js @@ -1,9 +1,8 @@ async function run() { - const { test } = await import('node:test'); + const { test } = await import('node:test'); const { default: assert } = await import('node:assert'); - - test("dynamic import", async (t) => { - assert.ok(true); - // t.end(); - }); + + test("dynamic import", async (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 index 055e41ae..c1667f5b 100644 --- a/recipes/tape-to-node-test/tests/dynamic-import/input.js +++ b/recipes/tape-to-node-test/tests/dynamic-import/input.js @@ -1,8 +1,7 @@ async function run() { - const test = await import("tape"); - - test("dynamic import", (t) => { - t.ok(true); - t.end(); - }); + const test = await import("tape"); + + test("dynamic import", (t) => { + t.ok(true); + }); } diff --git a/recipes/tape-to-node-test/tests/new-features/expected.js b/recipes/tape-to-node-test/tests/new-features/expected.js index f123825d..8cdbd2a2 100644 --- a/recipes/tape-to-node-test/tests/new-features/expected.js +++ b/recipes/tape-to-node-test/tests/new-features/expected.js @@ -2,17 +2,16 @@ const { test } = require('node:test'); const assert = require('node:assert'); test('new features', async (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'); - // t.end(); + 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'); +// 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 index c463782b..9513ebea 100644 --- a/recipes/tape-to-node-test/tests/new-features/input.js +++ b/recipes/tape-to-node-test/tests/new-features/input.js @@ -1,17 +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'); - t.end(); + 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'); + console.log('finished'); }); diff --git a/recipes/tape-to-node-test/tests/on-failure/expected.js b/recipes/tape-to-node-test/tests/on-failure/expected.js index dcb03262..6c59f280 100644 --- a/recipes/tape-to-node-test/tests/on-failure/expected.js +++ b/recipes/tape-to-node-test/tests/on-failure/expected.js @@ -3,7 +3,6 @@ import assert from 'node:assert'; test('failing test', async (t) => { assert.strictEqual(1, 2, 'this will fail'); - // t.end(); }); // TODO: test.onFailure(() => { diff --git a/recipes/tape-to-node-test/tests/on-failure/input.js b/recipes/tape-to-node-test/tests/on-failure/input.js index 3bef5b0c..6bf6b585 100644 --- a/recipes/tape-to-node-test/tests/on-failure/input.js +++ b/recipes/tape-to-node-test/tests/on-failure/input.js @@ -2,7 +2,6 @@ import test from 'tape'; test('failing test', (t) => { t.equal(1, 2, 'this will fail'); - t.end(); }); test.onFailure(() => { diff --git a/recipes/tape-to-node-test/tests/on-finish/expected.js b/recipes/tape-to-node-test/tests/on-finish/expected.js index f5604614..410051c3 100644 --- a/recipes/tape-to-node-test/tests/on-finish/expected.js +++ b/recipes/tape-to-node-test/tests/on-finish/expected.js @@ -3,7 +3,6 @@ import assert from 'node:assert'; test('some test', async (t) => { assert.ok(true, 'assertion passes'); - // t.end(); }); // TODO: test.onFinish(() => { diff --git a/recipes/tape-to-node-test/tests/on-finish/input.js b/recipes/tape-to-node-test/tests/on-finish/input.js index b4f69aa3..e68f176c 100644 --- a/recipes/tape-to-node-test/tests/on-finish/input.js +++ b/recipes/tape-to-node-test/tests/on-finish/input.js @@ -2,7 +2,6 @@ import test from 'tape'; test('some test', (t) => { t.ok(true, 'assertion passes'); - t.end(); }); test.onFinish(() => { 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 index 7437066b..8de67e5d 100644 --- a/recipes/tape-to-node-test/tests/plan-and-end/expected.js +++ b/recipes/tape-to-node-test/tests/plan-and-end/expected.js @@ -1,11 +1,10 @@ import { test } from 'node:test'; import assert from 'node:assert'; -test("basic equality", async (t, done) => { +test("basic equality", async (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"); - done(); }); 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 index d422ae6b..60dc73c2 100644 --- a/recipes/tape-to-node-test/tests/plan-and-end/input.js +++ b/recipes/tape-to-node-test/tests/plan-and-end/input.js @@ -6,5 +6,4 @@ test("basic equality", (t) => { t.notEqual(1, 2, "not equal numbers"); t.strictEqual(true, true, "strict equality"); t.notStrictEqual("1", 1, "not strict equality"); - t.end(); }); diff --git a/recipes/tape-to-node-test/tests/require-import/expected.js b/recipes/tape-to-node-test/tests/require-import/expected.js index 630eed4f..9cda8eb4 100644 --- a/recipes/tape-to-node-test/tests/require-import/expected.js +++ b/recipes/tape-to-node-test/tests/require-import/expected.js @@ -2,6 +2,5 @@ const { test } = require('node:test'); const assert = require('node:assert'); test("require test", async (t) => { - assert.strictEqual(1, 1); - // t.end(); + 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 index ce8e9b3e..54af3384 100644 --- a/recipes/tape-to-node-test/tests/require-import/input.js +++ b/recipes/tape-to-node-test/tests/require-import/input.js @@ -1,6 +1,5 @@ const test = require("tape"); test("require test", (t) => { - t.equal(1, 1); - t.end(); + 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 index 0dd1bc4a..27555e32 100644 --- a/recipes/tape-to-node-test/tests/test-options/expected.js +++ b/recipes/tape-to-node-test/tests/test-options/expected.js @@ -2,11 +2,9 @@ import { test } from 'node:test'; import assert from 'node:assert'; test.skip('skipped test', async (t) => { - assert.fail('should not run'); - // t.end(); + assert.fail('should not run'); }); test.only('only test', async (t) => { - assert.ok(true, 'should run'); - // t.end(); + 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 index 3a5424f0..a62fe370 100644 --- a/recipes/tape-to-node-test/tests/test-options/input.js +++ b/recipes/tape-to-node-test/tests/test-options/input.js @@ -1,11 +1,9 @@ import test from 'tape'; test.skip('skipped test', (t) => { - t.fail('should not run'); - t.end(); + t.fail('should not run'); }); test.only('only test', (t) => { - t.pass('should run'); - t.end(); + 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 index 3553e12e..26b73265 100644 --- a/recipes/tape-to-node-test/tests/timeout-non-object/expected.js +++ b/recipes/tape-to-node-test/tests/timeout-non-object/expected.js @@ -4,6 +4,6 @@ import assert from 'node:assert'; const opts = { skip: false }; test('timeout with variable opts', opts, async (t) => { - // TODO: Add timeout: `123` to test options manually; + // TODO(codemod@nodejs/tape-to-node-test): Add timeout: `123` to test options manually; assert.ok(true); }); From fc28208341aaac3bf404d28d74190a88f95068a8 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Wed, 21 Jan 2026 21:13:05 +0100 Subject: [PATCH 22/23] improve async/sync handling --- recipes/tape-to-node-test/src/workflow.ts | 117 ++++++++---------- .../tests/advanced-assertions/expected.js | 2 +- .../tests/aliased-import/expected.js | 2 +- .../tests/basic-equality/expected.js | 2 +- .../tests/callback-style/expected.js | 2 +- .../tests/cjs-destructuring/expected.js | 2 +- .../tests/deep-equality/expected.js | 2 +- .../tests/dynamic-import/expected.js | 2 +- .../tests/lifecycle/expected.js | 2 +- .../tests/nested-test-async/input.js | 14 +++ .../tests/nested-test/expected.js | 4 +- .../tests/new-features/expected.js | 2 +- .../tests/on-failure/expected.js | 2 +- .../tests/on-finish/expected.js | 2 +- .../tests/plan-and-end/expected.js | 2 +- .../tests/plan-only/expected.js | 2 +- .../tests/require-import/expected.js | 2 +- .../tests/test-options/expected.js | 4 +- .../tests/timeout-non-object/expected.js | 2 +- .../tests/timeout/expected.js | 8 +- .../tests/truthiness/expected.js | 2 +- 21 files changed, 90 insertions(+), 89 deletions(-) create mode 100644 recipes/tape-to-node-test/tests/nested-test-async/input.js diff --git a/recipes/tape-to-node-test/src/workflow.ts b/recipes/tape-to-node-test/src/workflow.ts index 58e30b82..51edab9e 100644 --- a/recipes/tape-to-node-test/src/workflow.ts +++ b/recipes/tape-to-node-test/src/workflow.ts @@ -241,61 +241,30 @@ export default function transform(root: SgRoot): string | null { } if (body) { - // Apply assertion transformations first - transformAssertions(body, tName, edits, call, shouldUseDone); + // 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. - // It must be async if it already is, or if the body contains any await expressions, - // or if there are subtests (t.test(...)) which we convert to 'await test(...)'. + // 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 hasSubtestCall = Boolean( - body.find({ - rule: { - kind: 'call_expression', - all: [ - { - has: { - field: 'function', - kind: 'member_expression', - }, - }, - { - has: { - field: 'function', - kind: 'member_expression', - has: { field: 'object', pattern: tName }, - }, - }, - { - has: { - field: 'function', - kind: 'member_expression', - has: { field: 'property', regex: '^test$' }, - }, - }, - ], - }, - }), - ); - - // If the callback has a parameter (e.g., TestContext `t`), - // we keep it async to align with expected behavior unless it already uses done style. - // For zero-arg callbacks, only add async when truly needed (awaits or subtests). - const hasParam = Boolean(paramId); - const needsAsync = hasParam - ? true - : isAsync || hasAwait || hasSubtestCall; - - if (!usesEndInCallback && !isAsync && needsAsync) { - if (params) { - edits.push({ - startPos: callback.range().start.index, - endPos: params.range().start.index, - insertedText: 'async ', - }); - } + const needsAsync = isAsync || hasAwait || assertionsRequireAsync; + + if (needsAsync && !isAsync && params) { + edits.push({ + startPos: callback.range().start.index, + endPos: params.range().start.index, + insertedText: 'async ', + }); } } } @@ -347,7 +316,8 @@ function transformAssertions( edits: Edit[], testCall: SgNode, useDone = false, -) { +): boolean { + let requiresAsync = false; const calls = node.findAll({ rule: { kind: 'call_expression', @@ -437,11 +407,8 @@ function transformAssertions( } break; case 'test': { - edits.push({ - startPos: call.range().start.index, - endPos: call.range().start.index, - insertedText: 'await ', - }); + const alreadyAwaited = call.parent()?.kind() === 'await_expression'; + let shouldAwaitSubtest = false; const cb = args ?.children() .find( @@ -456,18 +423,36 @@ function transformAssertions( if (paramId) stName = paramId.text(); const b = cb.field('body'); - if (b) transformAssertions(b, stName, edits, call); + 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 (!cb.text().startsWith('async')) { - if (p) { - edits.push({ - startPos: cb.range().start.index, - endPos: p.range().start.index, - insertedText: 'async ', - }); - } + 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': @@ -588,4 +573,6 @@ function transformAssertions( 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 index d6cbec54..1f69d398 100644 --- a/recipes/tape-to-node-test/tests/advanced-assertions/expected.js +++ b/recipes/tape-to-node-test/tests/advanced-assertions/expected.js @@ -1,7 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert'; -test('advanced assertions', async (t) => { +test('advanced assertions', (t) => { assert.throws(() => { throw new Error('fail'); }, /fail/); assert.doesNotThrow(() => { }); assert.match('string', /ring/); diff --git a/recipes/tape-to-node-test/tests/aliased-import/expected.js b/recipes/tape-to-node-test/tests/aliased-import/expected.js index c3ada63c..ed7fb75d 100644 --- a/recipes/tape-to-node-test/tests/aliased-import/expected.js +++ b/recipes/tape-to-node-test/tests/aliased-import/expected.js @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert'; -test('aliased test', async (t) => { +test('aliased test', (t) => { assert.strictEqual(1, 1); }); diff --git a/recipes/tape-to-node-test/tests/basic-equality/expected.js b/recipes/tape-to-node-test/tests/basic-equality/expected.js index 16939ee1..42b2e342 100644 --- a/recipes/tape-to-node-test/tests/basic-equality/expected.js +++ b/recipes/tape-to-node-test/tests/basic-equality/expected.js @@ -1,7 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert'; -test("basic equality", async (t) => { +test("basic equality", (t) => { assert.strictEqual(1, 1, "equal numbers"); assert.notStrictEqual(1, 2, "not equal numbers"); assert.strictEqual(true, true, "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 index c2160094..2921a722 100644 --- a/recipes/tape-to-node-test/tests/callback-style/expected.js +++ b/recipes/tape-to-node-test/tests/callback-style/expected.js @@ -1,7 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert'; -test("callback style", async (t) => { +test("callback style", (t) => { setTimeout(() => { assert.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 index 1b6b5d6d..40ada317 100644 --- a/recipes/tape-to-node-test/tests/cjs-destructuring/expected.js +++ b/recipes/tape-to-node-test/tests/cjs-destructuring/expected.js @@ -1,6 +1,6 @@ const { test } = require('node:test'); const assert = require('node:assert'); -test('cjs destructuring', async (t) => { +test('cjs destructuring', (t) => { assert.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 index 78b11f65..3c5bb03d 100644 --- a/recipes/tape-to-node-test/tests/deep-equality/expected.js +++ b/recipes/tape-to-node-test/tests/deep-equality/expected.js @@ -1,7 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert'; -test("deep equality", async (t) => { +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/dynamic-import/expected.js b/recipes/tape-to-node-test/tests/dynamic-import/expected.js index 7132ff81..a6405d25 100644 --- a/recipes/tape-to-node-test/tests/dynamic-import/expected.js +++ b/recipes/tape-to-node-test/tests/dynamic-import/expected.js @@ -2,7 +2,7 @@ async function run() { const { test } = await import('node:test'); const { default: assert } = await import('node:assert'); - test("dynamic import", async (t) => { + test("dynamic import", (t) => { assert.ok(true); }); } diff --git a/recipes/tape-to-node-test/tests/lifecycle/expected.js b/recipes/tape-to-node-test/tests/lifecycle/expected.js index f8d00a50..dd9ef9f7 100644 --- a/recipes/tape-to-node-test/tests/lifecycle/expected.js +++ b/recipes/tape-to-node-test/tests/lifecycle/expected.js @@ -3,7 +3,7 @@ import assert from 'node:assert'; let teardownState = 1; -test("teardown registers and runs after test", async (t) => { +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/nested-test-async/input.js b/recipes/tape-to-node-test/tests/nested-test-async/input.js new file mode 100644 index 00000000..0f2b9c10 --- /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 index 36364376..81cf3835 100644 --- a/recipes/tape-to-node-test/tests/nested-test/expected.js +++ b/recipes/tape-to-node-test/tests/nested-test/expected.js @@ -1,9 +1,9 @@ import { test } from 'node:test'; import assert from 'node:assert'; -test("nested tests", async (t) => { +test("nested tests", (t) => { t.plan(1); - await t.test("inner test 1", async (st) => { + t.test("inner test 1", (st) => { st.plan(1); assert.strictEqual(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 index 8cdbd2a2..b7e9d040 100644 --- a/recipes/tape-to-node-test/tests/new-features/expected.js +++ b/recipes/tape-to-node-test/tests/new-features/expected.js @@ -1,7 +1,7 @@ const { test } = require('node:test'); const assert = require('node:assert'); -test('new features', async (t) => { +test('new features', (t) => { assert.strictEqual(1, 1, 'equals alias'); assert.strictEqual(1, 1, 'is alias'); assert.notStrictEqual(1, 2, 'notEquals alias'); diff --git a/recipes/tape-to-node-test/tests/on-failure/expected.js b/recipes/tape-to-node-test/tests/on-failure/expected.js index 6c59f280..e8916e7e 100644 --- a/recipes/tape-to-node-test/tests/on-failure/expected.js +++ b/recipes/tape-to-node-test/tests/on-failure/expected.js @@ -1,7 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert'; -test('failing test', async (t) => { +test('failing test', (t) => { assert.strictEqual(1, 2, 'this will fail'); }); diff --git a/recipes/tape-to-node-test/tests/on-finish/expected.js b/recipes/tape-to-node-test/tests/on-finish/expected.js index 410051c3..6320ad43 100644 --- a/recipes/tape-to-node-test/tests/on-finish/expected.js +++ b/recipes/tape-to-node-test/tests/on-finish/expected.js @@ -1,7 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert'; -test('some test', async (t) => { +test('some test', (t) => { assert.ok(true, 'assertion passes'); }); 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 index 8de67e5d..3b775745 100644 --- a/recipes/tape-to-node-test/tests/plan-and-end/expected.js +++ b/recipes/tape-to-node-test/tests/plan-and-end/expected.js @@ -1,7 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert'; -test("basic equality", async (t) => { +test("basic equality", (t) => { t.plan(4); assert.strictEqual(1, 1, "equal numbers"); assert.notStrictEqual(1, 2, "not equal numbers"); diff --git a/recipes/tape-to-node-test/tests/plan-only/expected.js b/recipes/tape-to-node-test/tests/plan-only/expected.js index 12af95bb..6958de70 100644 --- a/recipes/tape-to-node-test/tests/plan-only/expected.js +++ b/recipes/tape-to-node-test/tests/plan-only/expected.js @@ -1,7 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert'; -test('plan only', async (t) => { +test('plan only', (t) => { t.plan(1); assert.strictEqual(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 index 9cda8eb4..861ba02b 100644 --- a/recipes/tape-to-node-test/tests/require-import/expected.js +++ b/recipes/tape-to-node-test/tests/require-import/expected.js @@ -1,6 +1,6 @@ const { test } = require('node:test'); const assert = require('node:assert'); -test("require test", async (t) => { +test("require test", (t) => { assert.strictEqual(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 index 27555e32..862a2799 100644 --- a/recipes/tape-to-node-test/tests/test-options/expected.js +++ b/recipes/tape-to-node-test/tests/test-options/expected.js @@ -1,10 +1,10 @@ import { test } from 'node:test'; import assert from 'node:assert'; -test.skip('skipped test', async (t) => { +test.skip('skipped test', (t) => { assert.fail('should not run'); }); -test.only('only test', async (t) => { +test.only('only test', (t) => { assert.ok(true, '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 index 26b73265..78ad093c 100644 --- a/recipes/tape-to-node-test/tests/timeout-non-object/expected.js +++ b/recipes/tape-to-node-test/tests/timeout-non-object/expected.js @@ -3,7 +3,7 @@ import assert from 'node:assert'; const opts = { skip: false }; -test('timeout with variable opts', opts, async (t) => { +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/expected.js b/recipes/tape-to-node-test/tests/timeout/expected.js index 62d486eb..b0efd174 100644 --- a/recipes/tape-to-node-test/tests/timeout/expected.js +++ b/recipes/tape-to-node-test/tests/timeout/expected.js @@ -1,18 +1,18 @@ import { test } from 'node:test'; import assert from 'node:assert'; -test('timeout test', { timeout: 100 }, async (t) => { +test('timeout test', { timeout: 100 }, (t) => { // t.end(); }); -test('timeout test with options', { skip: false, timeout: 200 }, async (t) => { +test('timeout test with options', { skip: false, timeout: 200 }, (t) => { // t.end(); }); -test('nested timeout', async (t) => { - await t.test('inner', { timeout: 50 }, async (st) => { +test('nested timeout', (t) => { + t.test('inner', { timeout: 50 }, (st) => { // st.end(); }); diff --git a/recipes/tape-to-node-test/tests/truthiness/expected.js b/recipes/tape-to-node-test/tests/truthiness/expected.js index e1e92a80..ff50545d 100644 --- a/recipes/tape-to-node-test/tests/truthiness/expected.js +++ b/recipes/tape-to-node-test/tests/truthiness/expected.js @@ -1,7 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert'; -test("truthiness", async (t) => { +test("truthiness", (t) => { t.plan(4); assert.ok(true, "true is ok"); assert.ok(!false, "false is not ok"); From 26302411f87c75284588283523ea9e554114e2f8 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Wed, 21 Jan 2026 21:13:48 +0100 Subject: [PATCH 23/23] update --- .../tests/nested-test-async/expected.js | 15 +++++++++++++++ .../tests/nested-test-async/input.js | 14 +++++++------- 2 files changed, 22 insertions(+), 7 deletions(-) create mode 100644 recipes/tape-to-node-test/tests/nested-test-async/expected.js 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 index 0f2b9c10..760b6c8b 100644 --- a/recipes/tape-to-node-test/tests/nested-test-async/input.js +++ b/recipes/tape-to-node-test/tests/nested-test-async/input.js @@ -1,14 +1,14 @@ import test from "tape"; async function fetchValue() { - return Promise.resolve(1); + 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"); - }); + 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"); + }); });