Skip to content

Commit f2007e6

Browse files
authored
Merge pull request #918 from taras/agent/issue-914-packaging-ownership
test: isolate npm packaging output per invocation
2 parents ebe09c2 + a89dd0c commit f2007e6

3 files changed

Lines changed: 268 additions & 107 deletions

File tree

‎scripts/tests/build-npm.test.ts‎

Lines changed: 138 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,19 @@
11
import { describe, it } from "@executablemd/test-support/bdd";
22
import { expect } from "@executablemd/test-support/expect";
3-
import { ensure } from "effection";
3+
import { ensure, scoped as withinScope, spawn, suspend, withResolvers } from "effection";
44
import type { Operation } from "effection";
55
import { exec, Stdio } from "@effectionx/process";
6-
import { ensureDir, exists, readTextFile, rm, writeTextFile } from "@effectionx/fs";
6+
import { ensureDir, exists, readTextFile, writeTextFile } from "@effectionx/fs";
77
import { useTempDirectory } from "@executablemd/test-support/temp";
88
import path from "node:path";
99
import { fileURLToPath, pathToFileURL } from "node:url";
1010

1111
import { buildNpmPackage } from "../build-npm.ts";
1212
import type { BuildEvent } from "../build-npm.ts";
13+
import { useNpmWorkspace } from "./npm-output.ts";
14+
import { digest, FileReads } from "../lib/prepared-state.ts";
1315

14-
const ROOT = fileURLToPath(new URL("../../", import.meta.url));
15-
16-
/**
17-
* The npm output directory for `pkgDir`. It stays on disk for the rest of the
18-
* test, so assertions can read the artifact, and is removed once the test
19-
* settles — whether the builder succeeded, failed, or the assertions threw.
20-
*/
21-
function* npmOutDir(pkgDir: string): Operation<string> {
22-
const outDir = path.join(ROOT, pkgDir, "npm");
23-
yield* ensure(() => rm(outDir, { recursive: true, force: true }));
24-
return outDir;
25-
}
26-
27-
function* buildNpm(pkgDir: string) {
16+
function* buildNpm(root: string, pkgDir: string, skipInstall = true) {
2817
// The builder's own output is an assertion subject, not test output.
2918
yield* Stdio.around({
3019
*stdout() {},
@@ -33,9 +22,22 @@ function* buildNpm(pkgDir: string) {
3322

3423
// Deno.execPath() so the child builder runs the same executable as the suite.
3524
return yield* exec(Deno.execPath(), {
36-
arguments: ["run", "-A", "scripts/build-npm.ts", pkgDir, "0.4.2"],
37-
cwd: ROOT,
38-
env: { ...Deno.env.toObject(), DNT_SKIP_INSTALL: "1" },
25+
arguments: [
26+
"run",
27+
"-A",
28+
"--node-modules-dir=manual",
29+
"--cached-only",
30+
"--frozen",
31+
"scripts/build-npm.ts",
32+
pkgDir,
33+
"0.4.2",
34+
],
35+
cwd: root,
36+
env: {
37+
...Deno.env.toObject(),
38+
DNT_SKIP_INSTALL: skipInstall ? "1" : "",
39+
NPM_CONFIG_LEGACY_PEER_DEPS: "true",
40+
},
3941
}).join();
4042
}
4143

@@ -58,11 +60,28 @@ function* declaredEntries(outDir: string): Operation<string[]> {
5860
return found;
5961
}
6062

63+
function* packedArtifact(outDir: string): Operation<Record<string, string>> {
64+
const packed = yield* exec("npm", {
65+
arguments: ["pack", "--dry-run", "--json"],
66+
cwd: outDir,
67+
}).expect();
68+
const files: string[] = JSON.parse(packed.stdout)[0].files.map(
69+
(file: { path: string }) => file.path,
70+
);
71+
const bytes: Record<string, string> = {};
72+
const read = yield* FileReads.expect();
73+
for (const file of files) {
74+
bytes[file] = digest(yield* read(path.join(outDir, file)));
75+
}
76+
expect(files).toContain("package.json");
77+
expect(files).toContain("esm/mod.js");
78+
return bytes;
79+
}
80+
6181
describe("build-npm skip-install mode", () => {
6282
it("refuses a package with workspace dependencies and names them", function* () {
63-
yield* npmOutDir("packages/acp");
64-
65-
const result = yield* buildNpm("packages/acp");
83+
const root = yield* useNpmWorkspace();
84+
const result = yield* buildNpm(root, "packages/acp");
6685

6786
expect(result.code).not.toBe(0);
6887
expect(result.stderr).toContain("DNT_SKIP_INSTALL");
@@ -72,9 +91,28 @@ describe("build-npm skip-install mode", () => {
7291
});
7392

7493
it("leaves no output that could be mistaken for a current build", function* () {
75-
const outDir = yield* npmOutDir("packages/acp");
94+
const root = yield* useNpmWorkspace();
95+
const outDir = path.join(root, "packages/core/npm");
96+
yield* ensureDir(path.join(outDir, "esm"));
97+
yield* ensureDir(path.join(outDir, "types"));
98+
yield* writeTextFile(path.join(outDir, "esm/mod.js"), "export const stale = true;\n");
99+
yield* writeTextFile(
100+
path.join(outDir, "types/mod.d.ts"),
101+
"export declare const stale: true;\n",
102+
);
103+
yield* writeTextFile(
104+
path.join(outDir, "package.json"),
105+
JSON.stringify({
106+
name: "@executablemd/core",
107+
version: "0.0.0-stale",
108+
main: "./esm/mod.js",
109+
types: "./types/mod.d.ts",
110+
}),
111+
);
76112

77-
yield* buildNpm("packages/acp");
113+
const refused = yield* buildNpm(root, "packages/core");
114+
expect(refused.code).not.toBe(0);
115+
expect(refused.stderr).toContain('cannot build "@executablemd/core"');
78116

79117
for (const generated of ["esm", "types", "package.json"]) {
80118
expect({ generated, exists: yield* exists(path.join(outDir, generated)) }).toEqual({
@@ -85,9 +123,14 @@ describe("build-npm skip-install mode", () => {
85123
});
86124

87125
it("builds a package with no workspace dependencies", function* () {
88-
const outDir = yield* npmOutDir("packages/durable-streams");
89-
90-
expect((yield* buildNpm("packages/durable-streams")).code).toBe(0);
126+
const root = yield* useNpmWorkspace();
127+
const outDir = path.join(root, "packages/durable-streams/npm");
128+
const built = yield* buildNpm(root, "packages/durable-streams");
129+
if (built.code !== 0) {
130+
throw new Error(
131+
`build-npm.ts packages/durable-streams exited ${built.code}\n${built.stdout}\n${built.stderr}`,
132+
);
133+
}
91134

92135
for (const entry of yield* declaredEntries(outDir)) {
93136
expect({ entry, exists: yield* exists(path.join(outDir, entry)) }).toEqual({
@@ -115,6 +158,74 @@ describe("build-npm skip-install mode", () => {
115158
});
116159
});
117160

161+
describe("npm packaging fixture ownership", () => {
162+
it("joins cancelled and failed cleanup while another invocation reads its own package", function* () {
163+
const ready = withResolvers<string>();
164+
const first = yield* spawn(function* () {
165+
const root = yield* useNpmWorkspace();
166+
const built = yield* buildNpm(root, "packages/durable-streams", false);
167+
if (built.code !== 0) {
168+
throw new Error(`first leaf build exited ${built.code}\n${built.stdout}\n${built.stderr}`);
169+
}
170+
ready.resolve(root);
171+
yield* suspend();
172+
});
173+
const firstRoot = yield* ready.operation;
174+
let survivorRoot = "";
175+
176+
yield* withinScope(function* () {
177+
survivorRoot = yield* useNpmWorkspace();
178+
expect(survivorRoot).not.toBe(firstRoot);
179+
const outDir = path.join(survivorRoot, "packages/durable-streams/npm");
180+
const built = yield* buildNpm(survivorRoot, "packages/durable-streams", false);
181+
if (built.code !== 0) {
182+
throw new Error(
183+
`surviving leaf build exited ${built.code}\n${built.stdout}\n${built.stderr}`,
184+
);
185+
}
186+
const before = yield* packedArtifact(outDir);
187+
expect(yield* exists(path.join(firstRoot, "packages/durable-streams/npm/esm/mod.js"))).toBe(
188+
true,
189+
);
190+
191+
yield* first.halt();
192+
expect(yield* exists(firstRoot)).toBe(false);
193+
expect(yield* packedArtifact(outDir)).toEqual(before);
194+
195+
const failure = new Error("the private fixture owner failed");
196+
let failedRoot = "";
197+
let caught: unknown;
198+
try {
199+
yield* withinScope(function* () {
200+
failedRoot = yield* useNpmWorkspace();
201+
const failedOutput = path.join(failedRoot, "packages/durable-streams/npm");
202+
yield* ensureDir(failedOutput);
203+
yield* writeTextFile(path.join(failedOutput, "owned.txt"), "private failure output");
204+
throw failure;
205+
});
206+
} catch (error) {
207+
caught = error;
208+
}
209+
expect(caught).toBe(failure);
210+
expect(failedRoot).not.toBe(survivorRoot);
211+
expect(yield* exists(failedRoot)).toBe(false);
212+
expect(yield* packedArtifact(outDir)).toEqual(before);
213+
214+
const consumer = yield* exec("node", {
215+
arguments: [
216+
"--input-type=module",
217+
"--eval",
218+
`import { InMemoryStream } from ${JSON.stringify(pathToFileURL(path.join(outDir, "esm/mod.js")).href)}; console.log(new InMemoryStream([]).constructor.name);`,
219+
],
220+
cwd: survivorRoot,
221+
}).expect();
222+
expect(consumer.stdout).toContain("InMemoryStream");
223+
});
224+
225+
expect(yield* exists(survivorRoot)).toBe(false);
226+
});
227+
});
228+
118229
/**
119230
* A version no `@executablemd/fixture-*` package has on npm, so nothing here
120231
* resolves unless the artifact beside it was the one consumed.

0 commit comments

Comments
 (0)