11import { describe , it } from "@executablemd/test-support/bdd" ;
22import { expect } from "@executablemd/test-support/expect" ;
3- import { ensure } from "effection" ;
3+ import { ensure , scoped as withinScope , spawn , suspend , withResolvers } from "effection" ;
44import type { Operation } from "effection" ;
55import { exec , Stdio } from "@effectionx/process" ;
6- import { ensureDir , exists , readTextFile , rm , writeTextFile } from "@effectionx/fs" ;
6+ import { ensureDir , exists , readTextFile , writeTextFile } from "@effectionx/fs" ;
77import { useTempDirectory } from "@executablemd/test-support/temp" ;
88import path from "node:path" ;
99import { fileURLToPath , pathToFileURL } from "node:url" ;
1010
1111import { buildNpmPackage } from "../build-npm.ts" ;
1212import 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+
6181describe ( "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