diff --git a/packages/cli/test/agent-extensions.test.ts b/packages/cli/test/agent-extensions.test.ts index 49548e5d..2d3c1f0d 100644 --- a/packages/cli/test/agent-extensions.test.ts +++ b/packages/cli/test/agent-extensions.test.ts @@ -1,14 +1,15 @@ import { execFile } from "node:child_process"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { join } from "node:path"; import { promisify } from "node:util"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { buildInvocation } from "../src/util/invocation"; +import { builtCli } from "./support/built-cli"; const execFileAsync = promisify(execFile); -const binPath = resolve(import.meta.dirname, "../dist/index.js"); +const binPath = builtCli(); async function runCli( args: string[] diff --git a/packages/cli/test/anonymous-flag.test.ts b/packages/cli/test/anonymous-flag.test.ts index cb48a184..32113487 100644 --- a/packages/cli/test/anonymous-flag.test.ts +++ b/packages/cli/test/anonymous-flag.test.ts @@ -1,13 +1,14 @@ import { execFile } from "node:child_process"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { join } from "node:path"; import { promisify } from "node:util"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { migrateFixture } from "./support/current-project"; +import { builtCli } from "./support/built-cli"; const execFileAsync = promisify(execFile); -const binPath = resolve(import.meta.dirname, "../dist/index.js"); +const binPath = builtCli(); async function runCli( args: string[], diff --git a/packages/cli/test/check-gitignore.test.ts b/packages/cli/test/check-gitignore.test.ts index 79d80283..9f9d7269 100644 --- a/packages/cli/test/check-gitignore.test.ts +++ b/packages/cli/test/check-gitignore.test.ts @@ -13,9 +13,10 @@ import { } from "../src/rules/git-ignored"; import { findValeBinary } from "../src/rules/vale/binary"; import { migrateFixture } from "./support/current-project"; +import { builtCli } from "./support/built-cli"; const execFileAsync = promisify(execFile); -const binPath = resolve(import.meta.dirname, "../dist/index.js"); +const binPath = builtCli(); const fixturesDirectory = resolve( import.meta.dirname, "fixtures/mixed-engines-project" diff --git a/packages/cli/test/check-rule-filter.test.ts b/packages/cli/test/check-rule-filter.test.ts index 4a4fbb69..620d2693 100644 --- a/packages/cli/test/check-rule-filter.test.ts +++ b/packages/cli/test/check-rule-filter.test.ts @@ -11,9 +11,10 @@ import { splitRawArguments } from "../src/util/argv"; import { sgFilterArgv } from "../src/rules/scan"; import { findValeBinary } from "../src/rules/vale/binary"; import { migrateFixture } from "./support/current-project"; +import { builtCli } from "./support/built-cli"; const execFileAsync = promisify(execFile); -const binPath = resolve(import.meta.dirname, "../dist/index.js"); +const binPath = builtCli(); const fixturesDirectory = resolve( import.meta.dirname, "fixtures/mixed-engines-project" diff --git a/packages/cli/test/check.test.ts b/packages/cli/test/check.test.ts index 13efe40f..a3fca0a8 100644 --- a/packages/cli/test/check.test.ts +++ b/packages/cli/test/check.test.ts @@ -5,9 +5,10 @@ import { tmpdir } from "node:os"; import { promisify } from "node:util"; import { describe, expect, it, beforeEach, afterEach } from "vitest"; import { migrateFixture } from "./support/current-project"; +import { builtCli } from "./support/built-cli"; const execFileAsync = promisify(execFile); -const binPath = resolve(import.meta.dirname, "../dist/index.js"); +const binPath = builtCli(); const fixturesDirectory = resolve( import.meta.dirname, "fixtures/taskless-project" diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts index 4011e563..73a6c6e7 100644 --- a/packages/cli/test/cli.test.ts +++ b/packages/cli/test/cli.test.ts @@ -1,12 +1,14 @@ import { execFile } from "node:child_process"; import { readFile, mkdtemp, rm, mkdir } from "node:fs/promises"; -import { resolve, join } from "node:path"; +import { join } from "node:path"; import { tmpdir } from "node:os"; import { promisify } from "node:util"; import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { builtCli } from "./support/built-cli"; + const execFileAsync = promisify(execFile); -const binPath = resolve(import.meta.dirname, "../dist/index.js"); +const binPath = builtCli(); describe("cli", () => { it("has a shebang in the built output", async () => { diff --git a/packages/cli/test/demo-command.test.ts b/packages/cli/test/demo-command.test.ts index 6b9a182a..46daaf93 100644 --- a/packages/cli/test/demo-command.test.ts +++ b/packages/cli/test/demo-command.test.ts @@ -1,15 +1,16 @@ import { execFile } from "node:child_process"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { join } from "node:path"; import { promisify } from "node:util"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { DEMO_RULES, demoRuleFor } from "../src/rules/demo/rule"; +import { builtCli } from "./support/built-cli"; const execFileAsync = promisify(execFile); -const binPath = resolve(import.meta.dirname, "../dist/index.js"); +const binPath = builtCli(); /** * The demonstration rules, end to end over the built CLI. diff --git a/packages/cli/test/detect.test.ts b/packages/cli/test/detect.test.ts index 0ec72755..7fc757d3 100644 --- a/packages/cli/test/detect.test.ts +++ b/packages/cli/test/detect.test.ts @@ -1,12 +1,14 @@ import { execFile } from "node:child_process"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { join } from "node:path"; import { promisify } from "node:util"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { builtCli } from "./support/built-cli"; + const execFileAsync = promisify(execFile); -const binPath = resolve(import.meta.dirname, "../dist/index.js"); +const binPath = builtCli(); interface ExecError extends Error { stdout?: string; diff --git a/packages/cli/test/engine-dispatch.test.ts b/packages/cli/test/engine-dispatch.test.ts index e8e416a3..966e703f 100644 --- a/packages/cli/test/engine-dispatch.test.ts +++ b/packages/cli/test/engine-dispatch.test.ts @@ -10,7 +10,7 @@ import { } from "node:fs/promises"; import { existsSync } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { dirname, join } from "node:path"; import { promisify } from "node:util"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -27,9 +27,10 @@ import { discoverRuntimeRules } from "../src/rules/runtime/discover"; import type { ServedFileSet } from "../src/api/v2"; import { CLIError } from "../src/util/cli-error"; import { migrateFixture } from "./support/current-project"; +import { builtCli } from "./support/built-cli"; const execFileAsync = promisify(execFile); -const binPath = resolve(import.meta.dirname, "../dist/index.js"); +const binPath = builtCli(); const NO_EVAL_RULE = [ "id: no-eval", diff --git a/packages/cli/test/error-envelope.test.ts b/packages/cli/test/error-envelope.test.ts index a523533d..1fc2ceac 100644 --- a/packages/cli/test/error-envelope.test.ts +++ b/packages/cli/test/error-envelope.test.ts @@ -2,15 +2,16 @@ import { execFile } from "node:child_process"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { join } from "node:path"; import { promisify } from "node:util"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { resolveActingOrg } from "../src/auth/org"; import { cliRejectionToResult } from "./support/spawn-cli"; +import { builtCli } from "./support/built-cli"; const execFileAsync = promisify(execFile); -const binPath = resolve(import.meta.dirname, "../dist/index.js"); +const binPath = builtCli(); interface ErrorEnvelope { ok: false; diff --git a/packages/cli/test/example-project.test.ts b/packages/cli/test/example-project.test.ts index 566f8bae..3cbc3d7b 100644 --- a/packages/cli/test/example-project.test.ts +++ b/packages/cli/test/example-project.test.ts @@ -7,9 +7,10 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { findValeBinary } from "../src/rules/vale/binary"; import { migrateFixture } from "./support/current-project"; +import { builtCli } from "./support/built-cli"; const execFileAsync = promisify(execFile); -const binPath = resolve(import.meta.dirname, "../dist/index.js"); +const binPath = builtCli(); /** `/example` — the committed demo project. */ const exampleSource = resolve(import.meta.dirname, "../../../example"); diff --git a/packages/cli/test/feedback-command.test.ts b/packages/cli/test/feedback-command.test.ts index e072f700..2a8eb8ed 100644 --- a/packages/cli/test/feedback-command.test.ts +++ b/packages/cli/test/feedback-command.test.ts @@ -1,7 +1,7 @@ import { execFile } from "node:child_process"; import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { join } from "node:path"; import { promisify } from "node:util"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { readNextAsk } from "../src/survey/cadence"; import { ANSWERED_INTERVAL_MS, SURVEY_ID } from "../src/survey/constants"; import { CLIError } from "../src/util/cli-error"; +import { builtCli } from "./support/built-cli"; // Spy on telemetry by mocking the module the command imports, the same way // agent-telemetry.test.ts does. `enabled` is flipped per test to exercise the @@ -242,7 +243,7 @@ describe("feedback command", () => { describe("feedback in the built CLI", () => { const execFileAsync = promisify(execFile); - const binPath = resolve(import.meta.dirname, "../dist/index.js"); + const binPath = builtCli(); it("is absent from the agent index", async () => { const { stdout } = await execFileAsync("node", [binPath, "agent"]); diff --git a/packages/cli/test/feedback-recipes.test.ts b/packages/cli/test/feedback-recipes.test.ts index c82df196..bd38299c 100644 --- a/packages/cli/test/feedback-recipes.test.ts +++ b/packages/cli/test/feedback-recipes.test.ts @@ -1,5 +1,4 @@ import { execFile } from "node:child_process"; -import { resolve } from "node:path"; import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; @@ -7,9 +6,10 @@ import { describe, expect, it } from "vitest"; import { getRecipe } from "../src/prompts/recipes"; import { inputSchema } from "../src/schemas/feedback"; import { COMPLETED_CHOICES } from "../src/survey/constants"; +import { builtCli } from "./support/built-cli"; const execFileAsync = promisify(execFile); -const binPath = resolve(import.meta.dirname, "../dist/index.js"); +const binPath = builtCli(); const invocation = "npx @taskless/cli"; /** The sentence the invite puts to the user, as the design fixed it. */ diff --git a/packages/cli/test/help-flag.test.ts b/packages/cli/test/help-flag.test.ts index 97349c1d..855ddbbb 100644 --- a/packages/cli/test/help-flag.test.ts +++ b/packages/cli/test/help-flag.test.ts @@ -1,7 +1,7 @@ import { execFile } from "node:child_process"; import { mkdtemp, readdir, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { join } from "node:path"; import { promisify } from "node:util"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -10,9 +10,10 @@ import { joinDirectoryValues, splitRawArguments, } from "../src/util/argv"; +import { builtCli } from "./support/built-cli"; const execFileAsync = promisify(execFile); -const binPath = resolve(import.meta.dirname, "../dist/index.js"); +const binPath = builtCli(); describe("--help below the root", () => { let temporaryDirectory: string; diff --git a/packages/cli/test/info.test.ts b/packages/cli/test/info.test.ts index 60e3c111..76e15fc3 100644 --- a/packages/cli/test/info.test.ts +++ b/packages/cli/test/info.test.ts @@ -12,9 +12,10 @@ import { join, resolve } from "node:path"; import { promisify } from "node:util"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { LATEST_SCHEMA_VERSION } from "../src/filesystem/migrate"; +import { builtCli } from "./support/built-cli"; const execFileAsync = promisify(execFile); -const binPath = resolve(import.meta.dirname, "../dist/index.js"); +const binPath = builtCli(); const agentRecipeDirectory = resolve(import.meta.dirname, "../src/agent"); interface ExecError extends Error { diff --git a/packages/cli/test/init-no-interactive.test.ts b/packages/cli/test/init-no-interactive.test.ts index 2fef2d28..531b51dc 100644 --- a/packages/cli/test/init-no-interactive.test.ts +++ b/packages/cli/test/init-no-interactive.test.ts @@ -8,13 +8,14 @@ import { writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { join } from "node:path"; import { promisify } from "node:util"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { LATEST_SCHEMA_VERSION } from "../src/filesystem/migrate"; +import { builtCli } from "./support/built-cli"; const execFileAsync = promisify(execFile); -const binPath = resolve(import.meta.dirname, "../dist/index.js"); +const binPath = builtCli(); async function exists(path: string): Promise { try { diff --git a/packages/cli/test/layout.test.ts b/packages/cli/test/layout.test.ts index 3d129626..0afe7e56 100644 --- a/packages/cli/test/layout.test.ts +++ b/packages/cli/test/layout.test.ts @@ -10,8 +10,9 @@ import { RULE_TESTS_DIRECTORY, isKnownEngine, } from "../src/layout/index.js"; +import { builtDirectory } from "./support/built-cli"; -const distributionDirectory = resolve(import.meta.dirname, "../dist"); +const distributionDirectory = builtDirectory(); const distributionLayoutPath = resolve(distributionDirectory, "layout.js"); /** diff --git a/packages/cli/test/migrate-round-trip.test.ts b/packages/cli/test/migrate-round-trip.test.ts index c659fd64..8e2695cf 100644 --- a/packages/cli/test/migrate-round-trip.test.ts +++ b/packages/cli/test/migrate-round-trip.test.ts @@ -2,15 +2,16 @@ import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { join } from "node:path"; import { promisify } from "node:util"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { findValeBinary } from "../src/rules/vale/binary"; +import { builtCli } from "./support/built-cli"; import { migrateFixture } from "./support/current-project"; const execFileAsync = promisify(execFile); -const binPath = resolve(import.meta.dirname, "../dist/index.js"); +const binPath = builtCli(); const withVale = findValeBinary().path === undefined ? describe.skip : describe; diff --git a/packages/cli/test/migrated-envelope.test.ts b/packages/cli/test/migrated-envelope.test.ts index 24dea46f..44d516f7 100644 --- a/packages/cli/test/migrated-envelope.test.ts +++ b/packages/cli/test/migrated-envelope.test.ts @@ -8,13 +8,14 @@ import { writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { join } from "node:path"; import { promisify } from "node:util"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { LATEST_SCHEMA_VERSION } from "../src/filesystem/migrate"; +import { builtCli } from "./support/built-cli"; const execFileAsync = promisify(execFile); -const binPath = resolve(import.meta.dirname, "../dist/index.js"); +const binPath = builtCli(); interface MigratedField { from: number; diff --git a/packages/cli/test/migration-failure.test.ts b/packages/cli/test/migration-failure.test.ts index 38fe1bc9..96041a31 100644 --- a/packages/cli/test/migration-failure.test.ts +++ b/packages/cli/test/migration-failure.test.ts @@ -1,14 +1,15 @@ import { execFile } from "node:child_process"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { join } from "node:path"; import { promisify } from "node:util"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { cliRejectionToResult } from "./support/spawn-cli"; +import { builtCli } from "./support/built-cli"; const execFileAsync = promisify(execFile); -const binPath = resolve(import.meta.dirname, "../dist/index.js"); +const binPath = builtCli(); /** * These spawn the BUILT CLI rather than calling `runMigrations` directly, and diff --git a/packages/cli/test/mixed-engine-check.test.ts b/packages/cli/test/mixed-engine-check.test.ts index be5bbd46..26dd4932 100644 --- a/packages/cli/test/mixed-engine-check.test.ts +++ b/packages/cli/test/mixed-engine-check.test.ts @@ -8,9 +8,10 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { findValeBinary } from "../src/rules/vale/binary"; import { migrateFixture } from "./support/current-project"; +import { builtCli } from "./support/built-cli"; const execFileAsync = promisify(execFile); -const binPath = resolve(import.meta.dirname, "../dist/index.js"); +const binPath = builtCli(); const fixturesDirectory = resolve( import.meta.dirname, "fixtures/mixed-engines-project" diff --git a/packages/cli/test/no-implicit-migration.test.ts b/packages/cli/test/no-implicit-migration.test.ts index a25e96de..907b621f 100644 --- a/packages/cli/test/no-implicit-migration.test.ts +++ b/packages/cli/test/no-implicit-migration.test.ts @@ -8,15 +8,16 @@ import { writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { join } from "node:path"; import { promisify } from "node:util"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { LATEST_SCHEMA_VERSION } from "../src/filesystem/migrate"; +import { builtCli } from "./support/built-cli"; const execFileAsync = promisify(execFile); -const binPath = resolve(import.meta.dirname, "../dist/index.js"); +const binPath = builtCli(); /** * A command that reports must not rewrite the repository. diff --git a/packages/cli/test/onboard.test.ts b/packages/cli/test/onboard.test.ts index 2b072900..9b647770 100644 --- a/packages/cli/test/onboard.test.ts +++ b/packages/cli/test/onboard.test.ts @@ -1,13 +1,14 @@ import { execFile } from "node:child_process"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { join } from "node:path"; import { promisify } from "node:util"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { LATEST_SCHEMA_VERSION } from "../src/filesystem/migrate"; +import { builtCli } from "./support/built-cli"; const execFileAsync = promisify(execFile); -const binPath = resolve(import.meta.dirname, "../dist/index.js"); +const binPath = builtCli(); interface ExecError extends Error { stdout?: string; diff --git a/packages/cli/test/prompts.test.ts b/packages/cli/test/prompts.test.ts index ed8c1054..f249336a 100644 --- a/packages/cli/test/prompts.test.ts +++ b/packages/cli/test/prompts.test.ts @@ -24,11 +24,12 @@ import { getRecipe, getRenderedRecipe, } from "../src/prompts/recipes"; +import { builtDirectory } from "./support/built-cli"; const execFileAsync = promisify(execFile); const recipeDirectory = resolve(import.meta.dirname, "../src/agent"); -const distributionDirectory = resolve(import.meta.dirname, "../dist"); +const distributionDirectory = builtDirectory(); const binPath = resolve(distributionDirectory, "index.js"); const distributionPromptsPath = resolve(distributionDirectory, "prompts.js"); diff --git a/packages/cli/test/reconcile-marker.test.ts b/packages/cli/test/reconcile-marker.test.ts index d34b3229..452a0093 100644 --- a/packages/cli/test/reconcile-marker.test.ts +++ b/packages/cli/test/reconcile-marker.test.ts @@ -6,9 +6,10 @@ import { promisify } from "node:util"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { AST_GREP_VERSION, VALE_VERSION } from "../src/rules/capabilities"; +import { builtCli } from "./support/built-cli"; const execFileAsync = promisify(execFile); -const binPath = resolve(import.meta.dirname, "../dist/index.js"); +const binPath = builtCli(); async function runCli( args: string[] diff --git a/packages/cli/test/rule-from.test.ts b/packages/cli/test/rule-from.test.ts index e4f475ea..60e50505 100644 --- a/packages/cli/test/rule-from.test.ts +++ b/packages/cli/test/rule-from.test.ts @@ -1,15 +1,16 @@ import { execFile } from "node:child_process"; import { writeFile, mkdtemp, rm, mkdir } from "node:fs/promises"; -import { resolve, join } from "node:path"; +import { join } from "node:path"; import { tmpdir } from "node:os"; import { promisify } from "node:util"; import { describe, expect, it, beforeEach, afterEach } from "vitest"; import { outputSchema as createOutputSchema } from "../src/schemas/rules-create"; import { outputSchema as improveOutputSchema } from "../src/schemas/rules-improve"; +import { builtCli } from "./support/built-cli"; const execFileAsync = promisify(execFile); -const binPath = resolve(import.meta.dirname, "../dist/index.js"); +const binPath = builtCli(); describe("rules create --from", () => { let temporaryDirectory: string; diff --git a/packages/cli/test/run-directory.test.ts b/packages/cli/test/run-directory.test.ts index cbaed513..83e3eacf 100644 --- a/packages/cli/test/run-directory.test.ts +++ b/packages/cli/test/run-directory.test.ts @@ -17,10 +17,11 @@ import { promisify } from "node:util"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { openRun, sweepAbandonedRuns } from "../src/rules/run-directory"; +import { builtCli } from "./support/built-cli"; import { migrateFixture } from "./support/current-project"; const execFileAsync = promisify(execFile); -const binPath = resolve(import.meta.dirname, "../dist/index.js"); +const binPath = builtCli(); // `--import tsx` rather than the `tsx` binary, which relays a signal death as // an exit code and so hides the signal a test asserts on. const packageRoot = resolve(import.meta.dirname, ".."); diff --git a/packages/cli/test/runtime-check.test.ts b/packages/cli/test/runtime-check.test.ts index 473664ce..524b3fa4 100644 --- a/packages/cli/test/runtime-check.test.ts +++ b/packages/cli/test/runtime-check.test.ts @@ -9,14 +9,15 @@ import { mkdir, writeFile, } from "node:fs/promises"; -import { resolve, join } from "node:path"; +import { join } from "node:path"; import { tmpdir } from "node:os"; import { promisify } from "node:util"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { migrateFixture } from "./support/current-project"; +import { builtCli } from "./support/built-cli"; const execFileAsync = promisify(execFile); -const binPath = resolve(import.meta.dirname, "../dist/index.js"); +const binPath = builtCli(); interface ReportedRule { ruleId: string; diff --git a/packages/cli/test/runtime-fixture-runner.test.ts b/packages/cli/test/runtime-fixture-runner.test.ts index 662f75fa..448ec3b7 100644 --- a/packages/cli/test/runtime-fixture-runner.test.ts +++ b/packages/cli/test/runtime-fixture-runner.test.ts @@ -1,11 +1,13 @@ import { execFile } from "node:child_process"; import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { join } from "node:path"; import { promisify } from "node:util"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { builtCli } from "./support/built-cli"; + /** * `taskless test` against a runtime rule. * @@ -19,7 +21,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; */ const execFileAsync = promisify(execFile); -const binPath = resolve(import.meta.dirname, "../dist/index.js"); +const binPath = builtCli(); let cwd: string; diff --git a/packages/cli/test/schemas-export.test.ts b/packages/cli/test/schemas-export.test.ts index 9f5bd5ed..702bc30b 100644 --- a/packages/cli/test/schemas-export.test.ts +++ b/packages/cli/test/schemas-export.test.ts @@ -10,10 +10,11 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { verifyRule } from "../src/rules/verify"; import { findValeBinary } from "../src/rules/vale/binary"; import { verifyValeRule } from "../src/rules/vale/verify"; +import { builtDirectory } from "./support/built-cli"; const execFileAsync = promisify(execFile); -const distributionDirectory = resolve(import.meta.dirname, "../dist"); +const distributionDirectory = builtDirectory(); const distributionSchemasPath = resolve(distributionDirectory, "schemas.js"); const binPath = resolve(distributionDirectory, "index.js"); diff --git a/packages/cli/test/spawn-cli-support.test.ts b/packages/cli/test/spawn-cli-support.test.ts index 79126957..32e5a21c 100644 --- a/packages/cli/test/spawn-cli-support.test.ts +++ b/packages/cli/test/spawn-cli-support.test.ts @@ -1,4 +1,7 @@ import { execFile } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; @@ -58,6 +61,33 @@ describe("cliRejectionToResult", () => { ).toThrow(/taskless\/cli#262/); }); + // taskless/cli#262: a build emptying `dist/` mid-run. Node exits with a real + // code 1 here, so `code` alone reads it as the CLI failing. + it.each([ + ["the entry", "missing.mjs", undefined], + ["a chunk the entry imports", "index.mjs", 'import "./chunk.mjs";\n'], + ])( + "throws when %s is missing from the bundle, though node exits 1", + async (_label, entry, source) => { + const bundle = await mkdtemp(join(tmpdir(), "taskless-spawn-cli-")); + try { + if (source !== undefined) await writeFile(join(bundle, entry), source); + const command = [join(bundle, entry), "auth", "login"]; + try { + await execFileAsync("node", command); + expect.unreachable("node should have failed to load the bundle"); + } catch (error) { + expect((error as { code?: unknown }).code).toBe(1); + expect(() => cliRejectionToResult(error, command)).toThrow( + /The CLI never ran: its built bundle is incomplete/ + ); + } + } finally { + await rm(bundle, { recursive: true, force: true }); + } + } + ); + it("treats a process killed by a signal as never having run", () => { // `code` is null and `signal` is set. Reading `code` as an exit status here // would report `exitCode: null`, which no assertion in the suite expects. diff --git a/packages/cli/test/support/built-cli.ts b/packages/cli/test/support/built-cli.ts new file mode 100644 index 00000000..3b3e6619 --- /dev/null +++ b/packages/cli/test/support/built-cli.ts @@ -0,0 +1,19 @@ +import { join } from "node:path"; + +import { inject } from "vitest"; + +/** + * This run's snapshot of `dist/`, for tests that read or import built files. + * + * Tests never read `dist/` directly: a build that starts mid-run empties it, + * and every spawn in that window fails as if the CLI had. The snapshot is taken + * once per run by ./distribution-snapshot.ts, which explains the measurement behind it. + */ +export function builtDirectory(): string { + return inject("cliDist"); +} + +/** The built CLI entry, for `execFile("node", [builtCli(), ...])`. */ +export function builtCli(): string { + return join(builtDirectory(), "index.js"); +} diff --git a/packages/cli/test/support/distribution-snapshot.ts b/packages/cli/test/support/distribution-snapshot.ts new file mode 100644 index 00000000..50f3881a --- /dev/null +++ b/packages/cli/test/support/distribution-snapshot.ts @@ -0,0 +1,127 @@ +import { existsSync } from "node:fs"; +import { cp, mkdir, mkdtemp, readdir, rm, stat } from "node:fs/promises"; +import { join, relative, resolve } from "node:path"; +import process from "node:process"; +import { setTimeout as delay } from "node:timers/promises"; + +import type { TestProject } from "vitest/node"; + +/** + * Vitest global setup: snapshot `dist/` once per run, and run every spawned CLI + * from the snapshot instead of from `dist/` itself. + * + * WHY THIS EXISTS. taskless/cli#262: spawning tests failed once on a full-suite + * run and passed on an immediate rerun. `vitest run` does not build, so every + * spawn reads whatever `dist/` holds at that moment, and a build that starts + * mid-run (`pnpm lint` builds first) empties `dist/` before rewriting it. + * Measured: 22 of 343 spawns of `auth login --anonymous` failed while six + * builds ran alongside, each with exit code 1, empty stdout, and `Cannot find + * module …/dist/index.js`; 0 of 414 failed with no build running. Exit code 1 + * and empty stdout is exactly what both reported tests choke on. + * + * WHY HERE AND NOT IN `os.tmpdir()`. The bundle finds tsx, ast-grep and Vale + * relative to its own file, through `createRequire(import.meta.url)`, which + * walks up to `packages/cli/node_modules`. Outside the package that walk finds + * nothing (measured: both `tsx` and `@ast-grep/cli` unresolvable from a tmpdir + * copy), and tests would silently exercise whatever is on PATH instead. Under + * `packages/cli/tmp/` the walk still lands where it does from `dist/`. `tmp` is + * gitignored at any depth. + * + * One directory per run, named for the vitest process that owns it, so two + * runs in the same checkout never share or sweep each other's snapshot. + */ + +const PACKAGE_ROOT = resolve(import.meta.dirname, "../.."); +const DIST = join(PACKAGE_ROOT, "dist"); +const SNAPSHOT_ROOT = join(PACKAGE_ROOT, "tmp", "dist-test"); + +/** Attempts at a copy that no build disturbed, before giving up. */ +const COPY_ATTEMPTS = 5; +const COPY_RETRY_MS = 500; + +declare module "vitest" { + export interface ProvidedContext { + /** Absolute path to this run's snapshot of `dist/`. */ + cliDist: string; + } +} + +/** Whether a process with this id is still running. */ +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + // EPERM: it exists, it just is not ours to signal. + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +/** Remove snapshots left by runs that exited without tearing down. */ +async function sweepStaleSnapshots(): Promise { + let entries: string[]; + try { + entries = await readdir(SNAPSHOT_ROOT); + } catch { + return; + } + await Promise.all( + entries.map(async (entry) => { + const pid = Number.parseInt(entry.split("-")[0] ?? "", 10); + if (Number.isInteger(pid) && isAlive(pid)) return; + await rm(join(SNAPSHOT_ROOT, entry), { recursive: true, force: true }); + }) + ); +} + +/** + * Every file under `dist/` with its size and mtime. Equal before and after a + * copy means no build wrote to `dist/` while it was being read. + */ +async function fingerprint(directory: string): Promise { + const files = await readdir(directory, { recursive: true }); + const lines = await Promise.all( + files.map(async (file) => { + const info = await stat(join(directory, file)); + return `${file}:${info.size}:${info.mtimeMs}`; + }) + ); + return lines.toSorted().join("\n"); +} + +async function snapshotDistribution(target: string): Promise { + for (let attempt = 1; attempt <= COPY_ATTEMPTS; attempt++) { + if (existsSync(join(DIST, "index.js"))) { + try { + const before = await fingerprint(DIST); + await cp(DIST, target, { recursive: true }); + if ((await fingerprint(DIST)) === before) return; + } catch { + // A build removed a file mid-copy. Same outcome as a changed + // fingerprint: start again from an empty target. + } + await rm(target, { recursive: true, force: true }); + } + await delay(COPY_RETRY_MS); + } + throw new Error( + existsSync(join(DIST, "index.js")) + ? `${relative(process.cwd(), DIST)}/ kept changing while it was being ` + + `copied for this test run. A build is probably running; wait for it ` + + `to finish and rerun.` + : `${relative(process.cwd(), DIST)}/index.js does not exist. Most of this ` + + `suite spawns the built CLI: run \`pnpm build\` first.` + ); +} + +export default async function setup(project: TestProject) { + await sweepStaleSnapshots(); + await mkdir(SNAPSHOT_ROOT, { recursive: true }); + const snapshot = await mkdtemp(join(SNAPSHOT_ROOT, `${process.pid}-`)); + await snapshotDistribution(snapshot); + project.provide("cliDist", snapshot); + + return async () => { + await rm(snapshot, { recursive: true, force: true }); + }; +} diff --git a/packages/cli/test/support/spawn-cli.ts b/packages/cli/test/support/spawn-cli.ts index 2558f661..69c0803b 100644 --- a/packages/cli/test/support/spawn-cli.ts +++ b/packages/cli/test/support/spawn-cli.ts @@ -1,3 +1,6 @@ +import { realpathSync } from "node:fs"; +import { dirname } from "node:path"; + /** * Tell a CLI that ran and failed apart from a CLI that never ran. * @@ -19,19 +22,42 @@ * "expected 0 to be greater than 0" from inside a helper. Nothing in that * message mentions spawning. * - * That matters because of taskless/cli#262: two spawning tests failed once on a - * full-suite run and passed on an immediate rerun, with the assertion output - * lost because it said nothing useful. The plausible cause is fork pressure - * (`EAGAIN`) from many concurrent spawns, which takes exactly the path above. - * The flake has not reproduced in 8 consecutive full runs, so the useful move - * on an unreproducible failure is not to guess at a fix but to make the next - * occurrence describe itself. + * taskless/cli#262 found the second shape the same rejection hides. Two + * spawning tests failed once on a full-suite run and passed on a rerun. The + * cause was a build emptying `dist/` mid-run: node exits with a NUMERIC code 1, + * empty stdout, and `Cannot find module …/dist/index.js` on stderr. Measured: + * 22 of 343 spawns failed that way with builds running alongside, 0 of 414 + * without. That one cannot be told apart by `code`, so it is recognised by + * node naming a module inside the CLI's own directory as missing. + * + * Tests now run a per-run snapshot of `dist/` (./distribution-snapshot.ts), which a + * build cannot touch, so the second shape should not occur. If it does, it + * should say what it is rather than fail as a contract assertion. * * Raising a timeout was considered and rejected. Nothing shows the 20s * `testTimeout` was ever reached, a CLI spawn is normally sub-second, and a * longer ceiling makes a genuine hang slower to surface. */ +/** + * Whether node's stderr reports a missing module inside `directory`. + * + * The CommonJS loader names the path it was given; the ESM loader names its + * realpath (measured on macOS: `/var/…` given, `/private/var/…` reported). A + * chunk missing behind the entry is reported the ESM way, so check both. + */ +function namesMissingModuleIn(stderr: string, directory: string): boolean { + const spellings = new Set([directory]); + try { + spellings.add(realpathSync(directory)); + } catch { + // The directory itself is gone; the given spelling is all there is. + } + return [...spellings].some((spelling) => + stderr.includes(`Cannot find module '${spelling}`) + ); +} + /** What a spawned CLI did, once we know it actually ran. */ export interface SpawnedCliResult { stdout: string; @@ -77,9 +103,24 @@ export function cliRejectionToResult( `The CLI never ran: ${cause}.\n` + ` command: node ${command.join(" ")}\n` + ` This is not a CLI contract failure. It is the process not starting, ` + - `most often fork pressure from many concurrent spawns under a full-suite ` + - `run. If you are seeing this intermittently, that is taskless/cli#262: ` + - `please file a fresh issue quoting this message and the full run output.` + `which under a full-suite run is plausibly fork pressure from many ` + + `concurrent spawns. taskless/cli#262 investigated spawn flakes and ` + + `found a different cause; if you see this one intermittently, please ` + + `file a fresh issue quoting this message and the full run output.` + ); + } + + // Node exits 1 when the entry, or a chunk it imports, is missing, so this + // one carries a real exit code. Node names the module in both loaders' + // messages, and nothing the CLI prints names a file inside its own bundle. + const bundle = dirname(command[0] ?? ""); + if (bundle !== "." && namesMissingModuleIn(rejection.stderr ?? "", bundle)) { + throw new Error( + `The CLI never ran: its built bundle is incomplete.\n` + + ` command: node ${command.join(" ")}\n` + + ` ${bundle} is missing a module the CLI loads at startup. Tests read ` + + `a per-run snapshot of dist/ so a concurrent build cannot do this; ` + + `see ./support/distribution-snapshot.ts and taskless/cli#262.` ); } diff --git a/packages/cli/test/test-fixture-findings.test.ts b/packages/cli/test/test-fixture-findings.test.ts index ae61ebd3..39ec6478 100644 --- a/packages/cli/test/test-fixture-findings.test.ts +++ b/packages/cli/test/test-fixture-findings.test.ts @@ -1,13 +1,14 @@ import { execFile } from "node:child_process"; import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { join } from "node:path"; import { promisify } from "node:util"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { findValeBinary } from "../src/rules/vale/binary"; import { cliRejectionToResult } from "./support/spawn-cli"; +import { builtCli } from "./support/built-cli"; /** * The findings `test` reports, per rule and per fixture bucket. @@ -27,7 +28,7 @@ import { cliRejectionToResult } from "./support/spawn-cli"; */ const execFileAsync = promisify(execFile); -const binPath = resolve(import.meta.dirname, "../dist/index.js"); +const binPath = builtCli(); const withVale = findValeBinary().path === undefined ? describe.skip : describe; diff --git a/packages/cli/test/vale-orchestration.test.ts b/packages/cli/test/vale-orchestration.test.ts index aa416c59..6b37ddf9 100644 --- a/packages/cli/test/vale-orchestration.test.ts +++ b/packages/cli/test/vale-orchestration.test.ts @@ -7,7 +7,7 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { join } from "node:path"; import { promisify } from "node:util"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -16,6 +16,7 @@ import { hasValeRules, runEngines } from "../src/rules/dispatch"; import { assembleSgConfig, assembleValeConfig } from "../src/rules/assemble"; import { findValeBinary } from "../src/rules/vale/binary"; import { LATEST_SCHEMA_VERSION } from "../src/filesystem/migrate"; +import { builtCli } from "./support/built-cli"; const withVale = findValeBinary().path === undefined ? describe.skip : describe; @@ -106,7 +107,7 @@ function makeMixedProject(options?: { /** The committed config `makeMixedProject` writes, as `check` would resolve it. */ const execFileAsync = promisify(execFile); -const binPath = resolve(import.meta.dirname, "../dist/index.js"); +const binPath = builtCli(); /** Run the built CLI, tolerating a non-zero exit. */ async function runCli( diff --git a/packages/cli/test/verify-test-commands.test.ts b/packages/cli/test/verify-test-commands.test.ts index 3a9b1833..b1a9c353 100644 --- a/packages/cli/test/verify-test-commands.test.ts +++ b/packages/cli/test/verify-test-commands.test.ts @@ -1,16 +1,17 @@ import { execFile } from "node:child_process"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { join } from "node:path"; import { promisify } from "node:util"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { findValeBinary } from "../src/rules/vale/binary"; import { cliRejectionToResult } from "./support/spawn-cli"; +import { builtCli } from "./support/built-cli"; const execFileAsync = promisify(execFile); -const binPath = resolve(import.meta.dirname, "../dist/index.js"); +const binPath = builtCli(); const withVale = findValeBinary().path === undefined ? describe.skip : describe; diff --git a/packages/cli/test/verify.test.ts b/packages/cli/test/verify.test.ts index f58b73a3..d440b072 100644 --- a/packages/cli/test/verify.test.ts +++ b/packages/cli/test/verify.test.ts @@ -1,7 +1,7 @@ import { execFile } from "node:child_process"; import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { join } from "node:path"; import { promisify } from "node:util"; import { describe, expect, it, beforeEach, afterEach } from "vitest"; @@ -9,6 +9,7 @@ import { stringify } from "yaml"; import { verifyRule, getSchemaPayload } from "../src/rules/verify"; import { cliRejectionToResult } from "./support/spawn-cli"; +import { builtCli } from "./support/built-cli"; const execFileAsync = promisify(execFile); @@ -774,7 +775,7 @@ describe("getSchemaPayload", () => { * `packages/cli/src/rules/inspect.ts`. `verify` never produces a refusal, so * it cannot exercise this split at all. */ -const verifyMixedRunBinPath = resolve(import.meta.dirname, "../dist/index.js"); +const verifyMixedRunBinPath = builtCli(); async function runVerifyMixedRunCli(args: string[]) { try { diff --git a/packages/cli/test/version-flag.test.ts b/packages/cli/test/version-flag.test.ts index 0b72e61f..ebd51e30 100644 --- a/packages/cli/test/version-flag.test.ts +++ b/packages/cli/test/version-flag.test.ts @@ -1,14 +1,15 @@ import { execFile } from "node:child_process"; import { mkdtemp, readdir, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { join } from "node:path"; import { promisify } from "node:util"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { hasVersionFlag } from "../src/util/argv"; +import { builtCli } from "./support/built-cli"; const execFileAsync = promisify(execFile); -const binPath = resolve(import.meta.dirname, "../dist/index.js"); +const binPath = builtCli(); describe("--version", () => { let temporaryDirectory: string; diff --git a/packages/cli/vite.config.ts b/packages/cli/vite.config.ts index 20000b5a..4ca0d0e8 100644 --- a/packages/cli/vite.config.ts +++ b/packages/cli/vite.config.ts @@ -505,6 +505,9 @@ export default defineConfig({ test: { testTimeout: 20_000, hookTimeout: 20_000, + // Spawning suites run a per-run copy of `dist/`, so a build that empties + // `dist/` mid-run cannot fail them. See test/support/distribution-snapshot.ts. + globalSetup: ["./test/support/distribution-snapshot.ts"], // Most suites spawn the BUILT CLI, which carries the production PostHog // token and opts out only on these env vars. A child inherits the worker's // process.env, so without this every `init`/`check` a test spawns posts a