From a89dd0cb95eed771bd47fba2a0c7c2966a081b67 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 14 Oct 2026 20:43:27 -0400 Subject: [PATCH] test: isolate npm packaging fixtures and cleanup --- scripts/tests/build-npm.test.ts | 165 +++++++++++++++++++++++++----- scripts/tests/cli-npm-bin.test.ts | 160 ++++++++++++++++------------- scripts/tests/npm-output.ts | 50 +++++++-- 3 files changed, 268 insertions(+), 107 deletions(-) diff --git a/scripts/tests/build-npm.test.ts b/scripts/tests/build-npm.test.ts index 1aec03e3b..9becad828 100644 --- a/scripts/tests/build-npm.test.ts +++ b/scripts/tests/build-npm.test.ts @@ -1,30 +1,19 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { ensure } from "effection"; +import { ensure, scoped as withinScope, spawn, suspend, withResolvers } from "effection"; import type { Operation } from "effection"; import { exec, Stdio } from "@effectionx/process"; -import { ensureDir, exists, readTextFile, rm, writeTextFile } from "@effectionx/fs"; +import { ensureDir, exists, readTextFile, writeTextFile } from "@effectionx/fs"; import { useTempDirectory } from "@executablemd/test-support/temp"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { buildNpmPackage } from "../build-npm.ts"; import type { BuildEvent } from "../build-npm.ts"; +import { useNpmWorkspace } from "./npm-output.ts"; +import { digest, FileReads } from "../lib/prepared-state.ts"; -const ROOT = fileURLToPath(new URL("../../", import.meta.url)); - -/** - * The npm output directory for `pkgDir`. It stays on disk for the rest of the - * test, so assertions can read the artifact, and is removed once the test - * settles — whether the builder succeeded, failed, or the assertions threw. - */ -function* npmOutDir(pkgDir: string): Operation { - const outDir = path.join(ROOT, pkgDir, "npm"); - yield* ensure(() => rm(outDir, { recursive: true, force: true })); - return outDir; -} - -function* buildNpm(pkgDir: string) { +function* buildNpm(root: string, pkgDir: string, skipInstall = true) { // The builder's own output is an assertion subject, not test output. yield* Stdio.around({ *stdout() {}, @@ -33,9 +22,22 @@ function* buildNpm(pkgDir: string) { // Deno.execPath() so the child builder runs the same executable as the suite. return yield* exec(Deno.execPath(), { - arguments: ["run", "-A", "scripts/build-npm.ts", pkgDir, "0.4.2"], - cwd: ROOT, - env: { ...Deno.env.toObject(), DNT_SKIP_INSTALL: "1" }, + arguments: [ + "run", + "-A", + "--node-modules-dir=manual", + "--cached-only", + "--frozen", + "scripts/build-npm.ts", + pkgDir, + "0.4.2", + ], + cwd: root, + env: { + ...Deno.env.toObject(), + DNT_SKIP_INSTALL: skipInstall ? "1" : "", + NPM_CONFIG_LEGACY_PEER_DEPS: "true", + }, }).join(); } @@ -58,11 +60,28 @@ function* declaredEntries(outDir: string): Operation { return found; } +function* packedArtifact(outDir: string): Operation> { + const packed = yield* exec("npm", { + arguments: ["pack", "--dry-run", "--json"], + cwd: outDir, + }).expect(); + const files: string[] = JSON.parse(packed.stdout)[0].files.map( + (file: { path: string }) => file.path, + ); + const bytes: Record = {}; + const read = yield* FileReads.expect(); + for (const file of files) { + bytes[file] = digest(yield* read(path.join(outDir, file))); + } + expect(files).toContain("package.json"); + expect(files).toContain("esm/mod.js"); + return bytes; +} + describe("build-npm skip-install mode", () => { it("refuses a package with workspace dependencies and names them", function* () { - yield* npmOutDir("packages/acp"); - - const result = yield* buildNpm("packages/acp"); + const root = yield* useNpmWorkspace(); + const result = yield* buildNpm(root, "packages/acp"); expect(result.code).not.toBe(0); expect(result.stderr).toContain("DNT_SKIP_INSTALL"); @@ -72,9 +91,28 @@ describe("build-npm skip-install mode", () => { }); it("leaves no output that could be mistaken for a current build", function* () { - const outDir = yield* npmOutDir("packages/acp"); + const root = yield* useNpmWorkspace(); + const outDir = path.join(root, "packages/core/npm"); + yield* ensureDir(path.join(outDir, "esm")); + yield* ensureDir(path.join(outDir, "types")); + yield* writeTextFile(path.join(outDir, "esm/mod.js"), "export const stale = true;\n"); + yield* writeTextFile( + path.join(outDir, "types/mod.d.ts"), + "export declare const stale: true;\n", + ); + yield* writeTextFile( + path.join(outDir, "package.json"), + JSON.stringify({ + name: "@executablemd/core", + version: "0.0.0-stale", + main: "./esm/mod.js", + types: "./types/mod.d.ts", + }), + ); - yield* buildNpm("packages/acp"); + const refused = yield* buildNpm(root, "packages/core"); + expect(refused.code).not.toBe(0); + expect(refused.stderr).toContain('cannot build "@executablemd/core"'); for (const generated of ["esm", "types", "package.json"]) { expect({ generated, exists: yield* exists(path.join(outDir, generated)) }).toEqual({ @@ -85,9 +123,14 @@ describe("build-npm skip-install mode", () => { }); it("builds a package with no workspace dependencies", function* () { - const outDir = yield* npmOutDir("packages/durable-streams"); - - expect((yield* buildNpm("packages/durable-streams")).code).toBe(0); + const root = yield* useNpmWorkspace(); + const outDir = path.join(root, "packages/durable-streams/npm"); + const built = yield* buildNpm(root, "packages/durable-streams"); + if (built.code !== 0) { + throw new Error( + `build-npm.ts packages/durable-streams exited ${built.code}\n${built.stdout}\n${built.stderr}`, + ); + } for (const entry of yield* declaredEntries(outDir)) { expect({ entry, exists: yield* exists(path.join(outDir, entry)) }).toEqual({ @@ -115,6 +158,74 @@ describe("build-npm skip-install mode", () => { }); }); +describe("npm packaging fixture ownership", () => { + it("joins cancelled and failed cleanup while another invocation reads its own package", function* () { + const ready = withResolvers(); + const first = yield* spawn(function* () { + const root = yield* useNpmWorkspace(); + const built = yield* buildNpm(root, "packages/durable-streams", false); + if (built.code !== 0) { + throw new Error(`first leaf build exited ${built.code}\n${built.stdout}\n${built.stderr}`); + } + ready.resolve(root); + yield* suspend(); + }); + const firstRoot = yield* ready.operation; + let survivorRoot = ""; + + yield* withinScope(function* () { + survivorRoot = yield* useNpmWorkspace(); + expect(survivorRoot).not.toBe(firstRoot); + const outDir = path.join(survivorRoot, "packages/durable-streams/npm"); + const built = yield* buildNpm(survivorRoot, "packages/durable-streams", false); + if (built.code !== 0) { + throw new Error( + `surviving leaf build exited ${built.code}\n${built.stdout}\n${built.stderr}`, + ); + } + const before = yield* packedArtifact(outDir); + expect(yield* exists(path.join(firstRoot, "packages/durable-streams/npm/esm/mod.js"))).toBe( + true, + ); + + yield* first.halt(); + expect(yield* exists(firstRoot)).toBe(false); + expect(yield* packedArtifact(outDir)).toEqual(before); + + const failure = new Error("the private fixture owner failed"); + let failedRoot = ""; + let caught: unknown; + try { + yield* withinScope(function* () { + failedRoot = yield* useNpmWorkspace(); + const failedOutput = path.join(failedRoot, "packages/durable-streams/npm"); + yield* ensureDir(failedOutput); + yield* writeTextFile(path.join(failedOutput, "owned.txt"), "private failure output"); + throw failure; + }); + } catch (error) { + caught = error; + } + expect(caught).toBe(failure); + expect(failedRoot).not.toBe(survivorRoot); + expect(yield* exists(failedRoot)).toBe(false); + expect(yield* packedArtifact(outDir)).toEqual(before); + + const consumer = yield* exec("node", { + arguments: [ + "--input-type=module", + "--eval", + `import { InMemoryStream } from ${JSON.stringify(pathToFileURL(path.join(outDir, "esm/mod.js")).href)}; console.log(new InMemoryStream([]).constructor.name);`, + ], + cwd: survivorRoot, + }).expect(); + expect(consumer.stdout).toContain("InMemoryStream"); + }); + + expect(yield* exists(survivorRoot)).toBe(false); + }); +}); + /** * A version no `@executablemd/fixture-*` package has on npm, so nothing here * resolves unless the artifact beside it was the one consumed. diff --git a/scripts/tests/cli-npm-bin.test.ts b/scripts/tests/cli-npm-bin.test.ts index 0e9da78c8..b5992cf1f 100644 --- a/scripts/tests/cli-npm-bin.test.ts +++ b/scripts/tests/cli-npm-bin.test.ts @@ -14,28 +14,21 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; import { runShell, shellQuote } from "@executablemd/test-support/launch"; -import { ensure, scoped, until } from "effection"; +import { scoped, until } from "effection"; import { createApi } from "@effectionx/context-api"; import * as sourceGitApi from "@executablemd/git/api"; -import { exists, readTextFile, rm, writeTextFile } from "@effectionx/fs"; +import { exists, readTextFile, writeTextFile } from "@effectionx/fs"; import { createHash } from "node:crypto"; -import { mkdtemp } from "node:fs/promises"; -import { tmpdir } from "node:os"; import type { Operation } from "effection"; import { exec, Stdio } from "@effectionx/process"; import type { ProcessResult } from "@effectionx/process"; import { timebox } from "@effectionx/timebox"; -import { removeNpmOutput } from "./npm-output.ts"; +import { useNpmWorkspace } from "./npm-output.ts"; +import { useTempDirectory } from "@executablemd/test-support/temp"; import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; +import { pathToFileURL } from "node:url"; -const ROOT = fileURLToPath(new URL("../../", import.meta.url)); const PKG_DIR = "packages/cli"; -const OUT_DIR = path.join(ROOT, PKG_DIR, "npm"); -const BIN = path.join(OUT_DIR, "esm/src/node.js"); -const DOC = path.join(ROOT, "smoke-test/test-agent/README.md"); -/** The scripted request-to-approved-Plan journey every installation runs. */ -const SMOKE = path.join(ROOT, "smoke-test/plan-information/README.md"); /** npm install and a full dnt type-check dominate this; the run itself is quick. */ const TIMEOUT = 600_000; @@ -138,11 +131,11 @@ interface Manifest { dependencies?: Record; } -function* readManifest(...segments: string[]): Operation { - return JSON.parse(yield* readTextFile(path.join(ROOT, ...segments))); +function* readManifest(root: string, ...segments: string[]): Operation { + return JSON.parse(yield* readTextFile(path.join(root, ...segments))); } -function* buildCliPackage(version: string): Operation { +function* buildCliPackage(root: string, version: string): Operation { // The builder narrates every file it emits; only its exit code matters here. yield* Stdio.around({ *stdout() {}, @@ -150,8 +143,17 @@ function* buildCliPackage(version: string): Operation { }); return yield* exec(Deno.execPath(), { - arguments: ["run", "-A", "scripts/build-npm.ts", PKG_DIR, version], - cwd: ROOT, + arguments: [ + "run", + "-A", + "--node-modules-dir=manual", + "--cached-only", + "--frozen", + "scripts/build-npm.ts", + PKG_DIR, + version, + ], + cwd: root, env: { ...Deno.env.toObject(), // @effectionx/* peer-depend on effection `^3 || ^4`, which npm will not @@ -163,20 +165,20 @@ function* buildCliPackage(version: string): Operation { } /** The manifest dnt emitted for the published package. */ -function* readEmittedManifest(): Operation { - return JSON.parse(yield* readTextFile(path.join(OUT_DIR, "package.json"))); +function* readEmittedManifest(root: string): Operation { + return JSON.parse(yield* readTextFile(path.join(root, PKG_DIR, "npm/package.json"))); } /** Run the built bin under Node, the way an `npm i -g @executablemd/cli` user would. */ -function runEmittedBin(args: string[]): Operation { - return runEmittedBinIn(ROOT, args); +function runEmittedBin(root: string, args: string[]): Operation { + return runEmittedBinIn(root, root, args); } /** The same, from a working directory the caller chooses. */ -function* runEmittedBinIn(cwd: string, args: string[]): Operation { +function* runEmittedBinIn(root: string, cwd: string, args: string[]): Operation { const result = yield* timebox(TIMEOUT, function* () { return yield* exec("node", { - arguments: [BIN, ...args], + arguments: [path.join(root, PKG_DIR, "npm/esm/src/node.js"), ...args], cwd, env: Deno.env.toObject(), }).join(); @@ -189,15 +191,18 @@ function* runEmittedBinIn(cwd: string, args: string[]): Operation describe("npm CLI package", { sanitizeOps: false, sanitizeResources: false }, () => { it("relaunches its test-agent worker under Node", function* () { - yield* ensure(removeNpmOutput); - const { version } = yield* readManifest(PKG_DIR, "deno.json"); + const root = yield* useNpmWorkspace(); + const { version } = yield* readManifest(root, PKG_DIR, "deno.json"); - const built = yield* buildCliPackage(version ?? "0.0.0-dev"); + const built = yield* buildCliPackage(root, version ?? "0.0.0-dev"); if (built.code !== 0) { throw new Error(`build-npm.ts exited ${built.code}\n${built.stderr}`); } - const run = yield* runEmittedBin(["test", DOC]); + const run = yield* runEmittedBin(root, [ + "test", + path.join(root, "smoke-test/test-agent/README.md"), + ]); if (run.code !== 0) { throw new Error(`the emitted npm bin exited ${run.code}\n${run.stderr}`); } @@ -219,7 +224,11 @@ describe("npm CLI package", { sanitizeOps: false, sanitizeResources: false }, () // `` answers only a prompt carrying the selected documentation, // so a package missing the documentation assets or the protected tier gets // no Plan written at all rather than a weaker one. - const planned = yield* runEmittedBin(["test", SMOKE, "--raw"]); + const planned = yield* runEmittedBin(root, [ + "test", + path.join(root, "smoke-test/plan-information/README.md"), + "--raw", + ]); if (planned.code !== 0) { throw new Error(`the emitted npm bin exited ${planned.code}\n${planned.stderr}`); } @@ -228,7 +237,7 @@ describe("npm CLI package", { sanitizeOps: false, sanitizeResources: false }, () expect(planned.stdout).not.toContain("ERROR"); // And nothing ran it: `` renders program text, so the file that // program names is still nobody's. - expect(yield* exists(path.join(ROOT, "planned.txt"))).toBe(false); + expect(yield* exists(path.join(root, "planned.txt"))).toBe(false); // The Markdown this package executes itself ships beside the module that // reads it. dnt emits the module graph only, so an asset nothing imports is @@ -236,8 +245,8 @@ describe("npm CLI package", { sanitizeOps: false, sanitizeResources: false }, () // would then find no program to run, on Node and Bun while Deno stayed // green. for (const asset of ["plan-command.md", "Plan.md"]) { - expect(yield* readTextFile(path.join(OUT_DIR, "esm/src/documents", asset))).toBe( - yield* readTextFile(path.join(ROOT, PKG_DIR, "src/documents", asset)), + expect(yield* readTextFile(path.join(root, PKG_DIR, "npm/esm/src/documents", asset))).toBe( + yield* readTextFile(path.join(root, PKG_DIR, "src/documents", asset)), ); } @@ -245,7 +254,7 @@ describe("npm CLI package", { sanitizeOps: false, sanitizeResources: false }, () // operator selects, so its assets ship with *it* — and the emitted CLI // neither depends on it nor carries a byte of it. A build that quietly put // it back would be a distribution deciding what a run installs. - const manifest = yield* readEmittedManifest(); + const manifest = yield* readEmittedManifest(root); expect(Object.keys(manifest.dependencies ?? {})).not.toContain( "@executablemd/code-review-agent", ); @@ -267,10 +276,10 @@ describe("npm CLI package", { sanitizeOps: false, sanitizeResources: false }, () * active for every run without being named. What is proven here is the other * route — the one an operator drives. */ - it("publishes the /api subpath and loads a Plugin named on the command line", function* () { - yield* ensure(removeNpmOutput); - const { version } = yield* readManifest(PKG_DIR, "deno.json"); - const built = yield* buildCliPackage(version ?? "0.0.0-dev"); + it("publishes contextual Git APIs that compose across copies and loads a named Plugin", function* () { + const root = yield* useNpmWorkspace(); + const { version } = yield* readManifest(root, PKG_DIR, "deno.json"); + const built = yield* buildCliPackage(root, version ?? "0.0.0-dev"); if (built.code !== 0) { throw new Error(`build-npm.ts exited ${built.code}\n${built.stderr}`); } @@ -278,17 +287,16 @@ describe("npm CLI package", { sanitizeOps: false, sanitizeResources: false }, () // Both halves of the subpath, for both packages a Plugin imports from. for (const pkg of ["packages/core", "packages/runtime"]) { const emitted: Manifest & { exports?: Record } = JSON.parse( - yield* readTextFile(path.join(ROOT, pkg, "npm/package.json")), + yield* readTextFile(path.join(root, pkg, "npm/package.json")), ); expect([pkg, emitted.exports?.["./api"] !== undefined]).toEqual([pkg, true]); - expect([pkg, yield* exists(path.join(ROOT, pkg, "npm/esm/api.js"))]).toEqual([pkg, true]); + expect([pkg, yield* exists(path.join(root, pkg, "npm/esm/api.js"))]).toEqual([pkg, true]); } - const elsewhere = yield* until(mkdtemp(path.join(tmpdir(), "xmd-npm-plugin-"))); - yield* ensure(() => rm(elsewhere, { recursive: true, force: true })); + const elsewhere = yield* useTempDirectory("xmd-npm-plugin-"); yield* writeTextFile(path.join(elsewhere, "doc.md"), "document body\n"); - const fixture = path.join(ROOT, "packages/cli/tests/fixtures/plugins/external.mjs"); - const run = yield* runEmittedBinIn(elsewhere, ["run", `--plugin=${fixture}`, "doc.md"]); + const fixture = path.join(root, "packages/cli/tests/fixtures/plugins/external.mjs"); + const run = yield* runEmittedBinIn(root, elsewhere, ["run", `--plugin=${fixture}`, "doc.md"]); if (run.code !== 0) { throw new Error(`the emitted npm bin exited ${run.code}\n${run.stderr}`); } @@ -299,7 +307,7 @@ describe("npm CLI package", { sanitizeOps: false, sanitizeResources: false }, () // `@executablemd/git`'s contextual Apis, in the artifact an npm consumer // installs. The same build, because a second dnt run would cost minutes to // answer a question this one already has the bytes for. - const gitOut = path.join(ROOT, "packages/git/npm"); + const gitOut = path.join(root, "packages/git/npm"); const gitManifest: Manifest & { exports?: Record } = JSON.parse( yield* readTextFile(path.join(gitOut, "package.json")), ); @@ -397,18 +405,17 @@ describe("npm CLI package", { sanitizeOps: false, sanitizeResources: false }, () * way a caller composes it rather than through argv. */ it("runs a complete program piped into the emitted bin", function* () { - yield* ensure(removeNpmOutput); - const { version } = yield* readManifest(PKG_DIR, "deno.json"); - const built = yield* buildCliPackage(version ?? "0.0.0-dev"); + const root = yield* useNpmWorkspace(); + const { version } = yield* readManifest(root, PKG_DIR, "deno.json"); + const built = yield* buildCliPackage(root, version ?? "0.0.0-dev"); if (built.code !== 0) { throw new Error(`build-npm.ts exited ${built.code}\n${built.stderr}`); } // Run from a directory that is not the package, so nothing the program // renders could have come from a file beside the bin. - const elsewhere = yield* until(mkdtemp(path.join(tmpdir(), "xmd-npm-stdin-"))); - yield* ensure(() => rm(elsewhere, { recursive: true, force: true })); - const line = `${shellQuote("node")} ${shellQuote(BIN)} run - --raw`; + const elsewhere = yield* useTempDirectory("xmd-npm-stdin-"); + const line = `${shellQuote("node")} ${shellQuote(path.join(root, PKG_DIR, "npm/esm/src/node.js"))} run - --raw`; const run = yield* runShell(line, { cwd: elsewhere, @@ -452,21 +459,21 @@ describe("npm CLI package", { sanitizeOps: false, sanitizeResources: false }, () * works locally and names directories npm would reject. */ it("leaves every manifest in the closure publishable", function* () { - yield* ensure(removeNpmOutput); - const { version } = yield* readManifest(PKG_DIR, "deno.json"); - const built = yield* buildCliPackage(version ?? "0.0.0-dev"); + const root = yield* useNpmWorkspace(); + const { version } = yield* readManifest(root, PKG_DIR, "deno.json"); + const built = yield* buildCliPackage(root, version ?? "0.0.0-dev"); if (built.code !== 0) { throw new Error(`build-npm.ts exited ${built.code}\n${built.stderr}`); } - const source = yield* readManifest(PKG_DIR, "package.json"); + const source = yield* readManifest(root, PKG_DIR, "package.json"); const internal = Object.keys(source.dependencies ?? {}).filter((name) => name.startsWith("@executablemd/"), ); // Non-vacuous: the CLI is the deepest closure in the workspace. expect(internal.length).toBeGreaterThan(1); - const emitted = yield* readEmittedManifest(); + const emitted = yield* readEmittedManifest(root); for (const name of internal) { expect({ name, range: emitted.dependencies?.[name] }).toEqual({ name, @@ -477,7 +484,7 @@ describe("npm CLI package", { sanitizeOps: false, sanitizeResources: false }, () // Every sibling the closure built, not only the root that was requested. for (const name of internal) { const dir = path.join("packages", name.slice("@executablemd/".length)); - const sibling = yield* readManifest(dir, "npm", "package.json"); + const sibling = yield* readManifest(root, dir, "npm", "package.json"); for (const [dependency, range] of Object.entries(sibling.dependencies ?? {})) { expect({ sibling: name, dependency, local: range.startsWith("file:") }).toEqual({ sibling: name, @@ -489,19 +496,23 @@ describe("npm CLI package", { sanitizeOps: false, sanitizeResources: false }, () }); it("reports the same Component identity the source tree ships", function* () { - yield* ensure(removeNpmOutput); - const { version } = yield* readManifest(PKG_DIR, "deno.json"); - const built = yield* buildCliPackage(version ?? "0.0.0-dev"); + const root = yield* useNpmWorkspace(); + const { version } = yield* readManifest(root, PKG_DIR, "deno.json"); + const built = yield* buildCliPackage(root, version ?? "0.0.0-dev"); if (built.code !== 0) { throw new Error(`build-npm.ts exited ${built.code}\n${built.stderr}`); } - const source = yield* readTextFile(path.join(ROOT, PKG_DIR, "src/documents/Plan.md")); + const source = yield* readTextFile(path.join(root, PKG_DIR, "src/documents/Plan.md")); const digest = createHash("sha256").update(source, "utf8").digest("hex"); - const elsewhere = yield* until(mkdtemp(path.join(tmpdir(), "xmd-npm-plan-"))); - yield* ensure(() => rm(elsewhere, { recursive: true, force: true })); - const run = yield* runEmittedBinIn(elsewhere, ["syntax", "--json", "--include", elsewhere]); + const elsewhere = yield* useTempDirectory("xmd-npm-plan-"); + const run = yield* runEmittedBinIn(root, elsewhere, [ + "syntax", + "--json", + "--include", + elsewhere, + ]); if (run.code !== 0) { throw new Error(`the emitted npm bin exited ${run.code}\n${run.stderr}`); } @@ -568,7 +579,7 @@ describe("npm CLI package", { sanitizeOps: false, sanitizeResources: false }, () // lives in core's *agent* boundary rather than in its own `components.md`, // so this exercises a second copied asset path: a build that copied only // the first would still answer for core's own components and fail here. - const documented = yield* runEmittedBinIn(elsewhere, ["syntax", "Prompt"]); + const documented = yield* runEmittedBinIn(root, elsewhere, ["syntax", "Prompt"]); expect(documented.code).toBe(0); expect(documented.stdout).toContain("### ``"); expect(documented.stdout).toContain("Sends a prompt and renders the reply"); @@ -576,7 +587,16 @@ describe("npm CLI package", { sanitizeOps: false, sanitizeResources: false }, () // And it is the same answer the source tree gives, whole. const fromSource = yield* exec(Deno.execPath(), { - arguments: ["run", "-A", path.join(ROOT, "packages/cli/src/deno.ts"), "syntax", "Prompt"], + arguments: [ + "run", + "-A", + "--node-modules-dir=manual", + "--cached-only", + "--frozen", + path.join(root, "packages/cli/src/deno.ts"), + "syntax", + "Prompt", + ], cwd: elsewhere, env: Deno.env.toObject(), }).join(); @@ -587,7 +607,7 @@ describe("npm CLI package", { sanitizeOps: false, sanitizeResources: false }, () // resolves here — so a build that still accepted the switch would fail on // the agent instead of answering with the migration, which is what makes // this a check on preflight rather than on the exit status. - const removed = yield* runEmittedBinIn(elsewhere, [ + const removed = yield* runEmittedBinIn(root, elsewhere, [ "plan", "prepare the release program", "--run", @@ -603,21 +623,21 @@ describe("npm CLI package", { sanitizeOps: false, sanitizeResources: false }, () // The same for the upgrade command's program. The npm build discovers the // directory rather than listing files, so this is the check that the // discovery really covered the second document too. - expect(yield* readTextFile(path.join(OUT_DIR, "esm/src/documents/upgrade-command.md"))).toBe( - yield* readTextFile(path.join(ROOT, PKG_DIR, "src/documents/upgrade-command.md")), - ); + expect( + yield* readTextFile(path.join(root, PKG_DIR, "npm/esm/src/documents/upgrade-command.md")), + ).toBe(yield* readTextFile(path.join(root, PKG_DIR, "src/documents/upgrade-command.md"))); // `semver` is imported by the upgrade document's eval block, which nothing // in the emitted module graph references. A transitive copy that happens to // be installed alongside is not a dependency this package may resolve // through: it is declared, so npm installs it. - expect((yield* readEmittedManifest()).dependencies?.semver).toBeDefined(); + expect((yield* readEmittedManifest(root)).dependencies?.semver).toBeDefined(); // And on this host the command refuses before any of that matters. The // provenance refusal runs ahead of the block that imports semver, ahead of // any release lookup, and ahead of touching a file — which is what makes // the npm package safe to publish without the eval block ever resolving. - const refused = yield* runEmittedBin(["upgrade", "--status"]); + const refused = yield* runEmittedBin(root, ["upgrade", "--status"]); expect(refused.code).toBe(1); // The document is the output, so even a refused host renders its heading. // What matters is that the npm package answered at all: the transcript diff --git a/scripts/tests/npm-output.ts b/scripts/tests/npm-output.ts index f89295fe1..7f98d3578 100644 --- a/scripts/tests/npm-output.ts +++ b/scripts/tests/npm-output.ts @@ -1,17 +1,47 @@ -/** - * Shared cleanup for suites that run `scripts/build-npm.ts`. A local-sibling - * build writes an `npm` directory per workspace member, and one left behind is - * both a lint subject and something a later build could mistake for current. - */ -import { readdir, rm } from "@effectionx/fs"; +import { useTempDirectory } from "@executablemd/test-support/temp"; +import { ensure, until } from "effection"; import type { Operation } from "effection"; +import { cp, symlink } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; -const ROOT = fileURLToPath(new URL("../../", import.meta.url)); +const SOURCE_ROOT = fileURLToPath(new URL("../../", import.meta.url)); -export function* removeNpmOutput(): Operation { - for (const member of yield* readdir(path.join(ROOT, "packages"))) { - yield* rm(path.join(ROOT, "packages", member, "npm"), { recursive: true, force: true }); +export function* useNpmWorkspace(): Operation { + const root = yield* useTempDirectory("xmd-npm-workspace-"); + let preparing: Promise = Promise.resolve(); + // Cancellation stops observing a copy, not the copy itself. Join it before the temporary + // directory's owner removes the root, so it cannot recreate a released tree. + yield* ensure(() => + until( + preparing.then( + () => undefined, + () => undefined, + ), + ), + ); + + for (const entry of [ + "packages", + "scripts", + "smoke-test", + "site", + "deno.json", + "deno.lock", + "package.json", + "LICENSE", + ]) { + preparing = cp(path.join(SOURCE_ROOT, entry), path.join(root, entry), { + recursive: true, + filter: (source) => !["npm", "node_modules", ".xmd-eval"].includes(path.basename(source)), + }); + yield* until(preparing); } + preparing = symlink( + path.join(SOURCE_ROOT, "node_modules"), + path.join(root, "node_modules"), + "dir", + ); + yield* until(preparing); + return root; }