isolate-package 1.33.0-0 → 1.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/index.mjs +1 -1
  2. package/dist/index.mjs.map +1 -1
  3. package/dist/{isolate-DTwgcMAN.mjs → isolate-DI3eUTci.mjs} +576 -242
  4. package/dist/isolate-DI3eUTci.mjs.map +1 -0
  5. package/dist/isolate-bin.mjs +5 -6
  6. package/dist/isolate-bin.mjs.map +1 -1
  7. package/package.json +23 -19
  8. package/src/get-internal-package-names.test.ts +1 -1
  9. package/src/get-internal-package-names.ts +2 -2
  10. package/src/isolate-bin.ts +5 -5
  11. package/src/isolate.ts +20 -17
  12. package/src/lib/config.test.ts +1 -1
  13. package/src/lib/config.ts +3 -3
  14. package/src/lib/lockfile/helpers/bun-lockfile.ts +21 -11
  15. package/src/lib/lockfile/helpers/generate-bun-lockfile.test.ts +3 -3
  16. package/src/lib/lockfile/helpers/generate-bun-lockfile.ts +7 -7
  17. package/src/lib/lockfile/helpers/generate-npm-lockfile.integration.test.ts +1 -5
  18. package/src/lib/lockfile/helpers/generate-npm-lockfile.test.ts +311 -16
  19. package/src/lib/lockfile/helpers/generate-npm-lockfile.ts +193 -22
  20. package/src/lib/lockfile/helpers/generate-pnpm-lockfile.test.ts +2 -2
  21. package/src/lib/lockfile/helpers/generate-pnpm-lockfile.ts +6 -6
  22. package/src/lib/lockfile/helpers/generate-yarn-lockfile.ts +5 -5
  23. package/src/lib/lockfile/process-lockfile.test.ts +2 -2
  24. package/src/lib/manifest/helpers/adapt-internal-package-manifests.test.ts +3 -3
  25. package/src/lib/manifest/helpers/adapt-internal-package-manifests.ts +2 -2
  26. package/src/lib/manifest/helpers/adapt-manifest-internal-deps.ts +1 -1
  27. package/src/lib/manifest/helpers/adopt-pnpm-fields-from-root.test.ts +4 -4
  28. package/src/lib/manifest/helpers/adopt-pnpm-fields-from-root.ts +7 -7
  29. package/src/lib/manifest/helpers/resolve-catalog-dependencies.test.ts +410 -0
  30. package/src/lib/manifest/helpers/resolve-catalog-dependencies.ts +115 -27
  31. package/src/lib/manifest/io.ts +6 -2
  32. package/src/lib/manifest/validate-manifest.ts +2 -2
  33. package/src/lib/output/get-build-output-dir.ts +1 -1
  34. package/src/lib/output/pack-dependencies.ts +1 -1
  35. package/src/lib/output/process-build-output-files.ts +6 -17
  36. package/src/lib/package-manager/helpers/infer-from-files.ts +5 -5
  37. package/src/lib/package-manager/helpers/infer-from-manifest.ts +7 -8
  38. package/src/lib/package-manager/index.ts +1 -1
  39. package/src/lib/package-manager/names.ts +8 -10
  40. package/src/lib/patches/collect-installed-names-bun.test.ts +2 -2
  41. package/src/lib/patches/collect-installed-names-bun.ts +8 -8
  42. package/src/lib/patches/collect-installed-names-pnpm.test.ts +1 -1
  43. package/src/lib/patches/collect-installed-names-pnpm.ts +13 -12
  44. package/src/lib/patches/copy-patches.test.ts +5 -13
  45. package/src/lib/patches/copy-patches.ts +9 -9
  46. package/src/lib/patches/write-isolate-pnpm-workspace.test.ts +83 -3
  47. package/src/lib/patches/write-isolate-pnpm-workspace.ts +4 -4
  48. package/src/lib/registry/collect-reachable-package-names.test.ts +1 -1
  49. package/src/lib/registry/create-packages-registry.ts +34 -31
  50. package/src/lib/registry/helpers/find-packages-globs.ts +23 -19
  51. package/src/lib/registry/list-internal-packages.test.ts +2 -2
  52. package/src/lib/types.ts +2 -2
  53. package/src/lib/utils/filter-patched-dependencies.test.ts +1 -1
  54. package/src/lib/utils/filter-patched-dependencies.ts +2 -2
  55. package/src/lib/utils/get-dirname.ts +1 -1
  56. package/src/lib/utils/index.ts +1 -1
  57. package/src/lib/utils/json.ts +12 -14
  58. package/src/lib/utils/pack.ts +32 -22
  59. package/src/lib/utils/reset-isolate-dir.test.ts +165 -0
  60. package/src/lib/utils/reset-isolate-dir.ts +147 -0
  61. package/src/lib/utils/unpack.test.ts +76 -0
  62. package/src/lib/utils/unpack.ts +16 -10
  63. package/src/lib/utils/wait-for-complete-file.test.ts +105 -0
  64. package/src/lib/utils/wait-for-complete-file.ts +44 -0
  65. package/src/lib/utils/yaml.ts +8 -9
  66. package/src/testing/setup.ts +1 -1
  67. package/dist/isolate-DTwgcMAN.mjs.map +0 -1
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
- import type { PatchFile } from "~/lib/types";
2
+ import type { PatchFile } from "#/lib/types";
3
3
  import { writeIsolatePnpmWorkspace } from "./write-isolate-pnpm-workspace";
4
4
 
5
5
  vi.mock("fs-extra", () => ({
@@ -8,14 +8,14 @@ vi.mock("fs-extra", () => ({
8
8
  },
9
9
  }));
10
10
 
11
- vi.mock("~/lib/utils", () => ({
11
+ vi.mock("#/lib/utils", () => ({
12
12
  readTypedYamlSync: vi.fn(),
13
13
  writeTypedYamlSync: vi.fn(),
14
14
  }));
15
15
 
16
16
  const fs = vi.mocked((await import("fs-extra")).default);
17
17
  const { readTypedYamlSync, writeTypedYamlSync } = vi.mocked(
18
- await import("~/lib/utils"),
18
+ await import("#/lib/utils"),
19
19
  );
20
20
 
21
21
  const workspaceRootDir = "/workspace";
@@ -139,6 +139,86 @@ describe("writeIsolatePnpmWorkspace", () => {
139
139
  );
140
140
  });
141
141
 
142
+ /**
143
+ * Regression test for issue #189: pnpm 11 expresses the build-script policy
144
+ * via `allowBuilds` in pnpm-workspace.yaml (and removes the older
145
+ * `pnpm.onlyBuiltDependencies` / `ignoredBuiltDependencies` fields from
146
+ * package.json). The verbatim copy must carry that field — along with other
147
+ * workspace-level settings like `minimumReleaseAge` — into the isolate
148
+ * output so downstream `pnpm install` honors the same policy.
149
+ */
150
+ it("preserves pnpm 11 workspace settings (allowBuilds, minimumReleaseAge) when no patches are involved", () => {
151
+ readTypedYamlSync.mockReturnValue({
152
+ packages: ["apps/*", "packages/*"],
153
+ allowBuilds: {
154
+ puppeteer: true,
155
+ esbuild: true,
156
+ },
157
+ minimumReleaseAge: 10_080,
158
+ });
159
+
160
+ writeIsolatePnpmWorkspace({
161
+ workspaceRootDir,
162
+ isolateDir,
163
+ copiedPatches: {},
164
+ });
165
+
166
+ /**
167
+ * With no patchedDependencies in the source yaml, the file is copied
168
+ * verbatim — preserving `allowBuilds` and any other top-level settings.
169
+ */
170
+ expect(writeTypedYamlSync).not.toHaveBeenCalled();
171
+ expect(fs.copyFileSync).toHaveBeenCalledWith(
172
+ "/workspace/pnpm-workspace.yaml",
173
+ "/workspace/isolate/pnpm-workspace.yaml",
174
+ );
175
+ });
176
+
177
+ /**
178
+ * When patches are being filtered, the rewrite path must still carry
179
+ * `allowBuilds` into the output yaml — otherwise pnpm 11's build-script
180
+ * policy is silently dropped.
181
+ */
182
+ it("preserves allowBuilds when rewriting to filter patchedDependencies", () => {
183
+ readTypedYamlSync.mockReturnValue({
184
+ packages: ["apps/*", "packages/*"],
185
+ allowBuilds: {
186
+ puppeteer: true,
187
+ },
188
+ patchedDependencies: {
189
+ "lodash@4.17.21": "patches/lodash@4.17.21.patch",
190
+ "axios@1.6.0": "patches/axios@1.6.0.patch",
191
+ },
192
+ });
193
+
194
+ const copiedPatches: Record<string, PatchFile> = {
195
+ "lodash@4.17.21": {
196
+ path: "patches/lodash@4.17.21.patch",
197
+ hash: "abc",
198
+ },
199
+ };
200
+
201
+ writeIsolatePnpmWorkspace({
202
+ workspaceRootDir,
203
+ isolateDir,
204
+ copiedPatches,
205
+ });
206
+
207
+ expect(fs.copyFileSync).not.toHaveBeenCalled();
208
+ expect(writeTypedYamlSync).toHaveBeenCalledWith(
209
+ "/workspace/isolate/pnpm-workspace.yaml",
210
+ {
211
+ packages: ["apps/*", "packages/*"],
212
+ allowBuilds: {
213
+ puppeteer: true,
214
+ },
215
+ patchedDependencies: {
216
+ "lodash@4.17.21": "patches/lodash@4.17.21.patch",
217
+ },
218
+ },
219
+ );
220
+ });
221
+
142
222
  it("copies verbatim when every patch is kept (preserving comments and order)", () => {
143
223
  readTypedYamlSync.mockReturnValue({
144
224
  packages: ["packages/*"],
@@ -1,8 +1,8 @@
1
1
  import fs from "fs-extra";
2
2
  import path from "node:path";
3
- import { useLogger } from "~/lib/logger";
4
- import type { PatchFile, PnpmSettings } from "~/lib/types";
5
- import { readTypedYamlSync, writeTypedYamlSync } from "~/lib/utils";
3
+ import { useLogger } from "#/lib/logger";
4
+ import type { PatchFile, PnpmSettings } from "#/lib/types";
5
+ import { readTypedYamlSync, writeTypedYamlSync } from "#/lib/utils";
6
6
 
7
7
  /**
8
8
  * Copy `pnpm-workspace.yaml` from the workspace root to the isolate directory,
@@ -39,7 +39,7 @@ export function writeIsolatePnpmWorkspace({
39
39
  let settings: PnpmSettings | undefined;
40
40
 
41
41
  try {
42
- settings = readTypedYamlSync<PnpmSettings>(sourcePath);
42
+ settings = readTypedYamlSync(sourcePath) as PnpmSettings | undefined;
43
43
  } catch (error) {
44
44
  log.warn(
45
45
  `Could not read pnpm-workspace.yaml, falling back to verbatim copy: ${error instanceof Error ? error.message : String(error)}`,
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import type { PackageManifest, PackagesRegistry } from "~/lib/types";
2
+ import type { PackageManifest, PackagesRegistry } from "#/lib/types";
3
3
  import { collectReachablePackageNames } from "./collect-reachable-package-names";
4
4
 
5
5
  function entry(manifest: PackageManifest) {
@@ -28,38 +28,41 @@ export async function createPackagesRegistry(
28
28
  workspaceRootDir,
29
29
  );
30
30
 
31
- const registry: PackagesRegistry = (
32
- await Promise.all(
33
- allPackages.map(async (rootRelativeDir) => {
34
- const absoluteDir = path.join(workspaceRootDir, rootRelativeDir);
35
- const manifestPath = path.join(absoluteDir, "package.json");
31
+ const entries = await Promise.all(
32
+ allPackages.map(async (rootRelativeDir) => {
33
+ const absoluteDir = path.join(workspaceRootDir, rootRelativeDir);
34
+ const manifestPath = path.join(absoluteDir, "package.json");
36
35
 
37
- if (!fs.existsSync(manifestPath)) {
38
- log.warn(
39
- `Ignoring directory ${rootRelativeDir} because it does not contain a package.json file`,
40
- );
41
- return;
42
- } else {
43
- log.debug(`Registering package ${rootRelativeDir}`);
36
+ if (!fs.existsSync(manifestPath)) {
37
+ log.warn(
38
+ `Ignoring directory ${rootRelativeDir} because it does not contain a package.json file`,
39
+ );
40
+ return null;
41
+ }
44
42
 
45
- const manifest = await readTypedJson<PackageManifest>(
46
- path.join(absoluteDir, "package.json"),
47
- );
43
+ log.debug(`Registering package ${rootRelativeDir}`);
48
44
 
49
- return {
50
- manifest,
51
- rootRelativeDir,
52
- absoluteDir,
53
- };
54
- }
55
- }),
56
- )
57
- ).reduce<PackagesRegistry>((acc, info) => {
58
- if (info) {
59
- acc[info.manifest.name] = info;
60
- }
61
- return acc;
62
- }, {});
45
+ const manifest = (await readTypedJson(
46
+ path.join(absoluteDir, "package.json"),
47
+ )) as PackageManifest;
48
+
49
+ return {
50
+ manifest,
51
+ rootRelativeDir,
52
+ absoluteDir,
53
+ };
54
+ }),
55
+ );
56
+
57
+ const registry: PackagesRegistry = entries.reduce<PackagesRegistry>(
58
+ (acc, info) => {
59
+ if (info) {
60
+ acc[info.manifest.name] = info;
61
+ }
62
+ return acc;
63
+ },
64
+ {},
65
+ );
63
66
 
64
67
  return registry;
65
68
  }
@@ -73,9 +76,9 @@ function listWorkspacePackages(
73
76
  workspaceRootDir: string,
74
77
  ) {
75
78
  if (isRushWorkspace(workspaceRootDir)) {
76
- const rushConfig = readTypedJsonSync<RushConfig>(
79
+ const rushConfig = readTypedJsonSync(
77
80
  path.join(workspaceRootDir, "rush.json"),
78
- );
81
+ ) as RushConfig;
79
82
 
80
83
  return rushConfig.projects.map(({ projectFolder }) => projectFolder);
81
84
  } else {
@@ -13,16 +13,16 @@ import {
13
13
  * monorepo. This configuration is dependent on the package manager used, and I
14
14
  * don't know if we're covering all cases yet...
15
15
  */
16
- export function findPackagesGlobs(workspaceRootDir: string) {
16
+ export function findPackagesGlobs(workspaceRootDir: string): string[] {
17
17
  const log = useLogger();
18
18
 
19
19
  const packageManager = usePackageManager();
20
20
 
21
21
  switch (packageManager.name) {
22
22
  case "pnpm": {
23
- const workspaceConfig = readTypedYamlSync<{ packages: string[] }>(
23
+ const workspaceConfig = readTypedYamlSync(
24
24
  path.join(workspaceRootDir, "pnpm-workspace.yaml"),
25
- );
25
+ ) as { packages: string[] } | undefined;
26
26
 
27
27
  if (!workspaceConfig) {
28
28
  throw new Error(
@@ -48,9 +48,9 @@ export function findPackagesGlobs(workspaceRootDir: string) {
48
48
  "package.json",
49
49
  );
50
50
 
51
- const { workspaces } = readTypedJsonSync<{ workspaces: string[] }>(
52
- workspaceRootManifestPath,
53
- );
51
+ const { workspaces } = readTypedJsonSync(workspaceRootManifestPath) as {
52
+ workspaces: string[];
53
+ };
54
54
 
55
55
  if (!workspaces) {
56
56
  throw new Error(
@@ -60,21 +60,25 @@ export function findPackagesGlobs(workspaceRootDir: string) {
60
60
 
61
61
  if (Array.isArray(workspaces)) {
62
62
  return workspaces;
63
- } else {
64
- /**
65
- * For Yarn, workspaces could be defined as an object with { packages:
66
- * [], nohoist: [] }. See
67
- * https://classic.yarnpkg.com/blog/2018/02/15/nohoist/
68
- */
69
- const workspacesObject = workspaces as { packages?: string[] };
63
+ }
70
64
 
71
- assert(
72
- workspacesObject.packages,
73
- "workspaces.packages must be an array",
74
- );
65
+ /**
66
+ * For Yarn, workspaces could be defined as an object with { packages: [],
67
+ * nohoist: [] }. See
68
+ * https://classic.yarnpkg.com/blog/2018/02/15/nohoist/
69
+ */
70
+ const workspacesObject = workspaces as { packages?: string[] };
75
71
 
76
- return workspacesObject.packages;
77
- }
72
+ assert(
73
+ Array.isArray(workspacesObject.packages),
74
+ "workspaces.packages must be an array",
75
+ );
76
+
77
+ return workspacesObject.packages;
78
78
  }
79
+ default:
80
+ throw new Error(
81
+ `Unsupported package manager: ${packageManager.name as string}`,
82
+ );
79
83
  }
80
84
  }
@@ -1,10 +1,10 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest";
2
- import type { PackageManifest, PackagesRegistry } from "~/lib/types";
2
+ import type { PackageManifest, PackagesRegistry } from "#/lib/types";
3
3
  import { listInternalPackages } from "./list-internal-packages";
4
4
 
5
5
  const mockWarn = vi.fn();
6
6
 
7
- vi.mock("~/lib/logger", () => ({
7
+ vi.mock("#/lib/logger", () => ({
8
8
  useLogger: () => ({
9
9
  debug: vi.fn(),
10
10
  info: vi.fn(),
package/src/lib/types.ts CHANGED
@@ -6,10 +6,10 @@ export type { PnpmSettings } from "@pnpm/types";
6
6
  * Represents a patch file entry in the pnpm lockfile. Contains the path to the
7
7
  * patch file and its content hash.
8
8
  */
9
- export interface PatchFile {
9
+ export type PatchFile = {
10
10
  path: string;
11
11
  hash: string;
12
- }
12
+ };
13
13
 
14
14
  export type PackageManifest = PnpmPackageManifest & {
15
15
  packageManager?: string;
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import type { PackageManifest } from "~/lib/types";
2
+ import type { PackageManifest } from "#/lib/types";
3
3
  import { filterPatchedDependencies } from "./filter-patched-dependencies";
4
4
 
5
5
  describe("filterPatchedDependencies", () => {
@@ -1,5 +1,5 @@
1
- import { useLogger } from "~/lib/logger";
2
- import type { PackageManifest } from "~/lib/types";
1
+ import { useLogger } from "#/lib/logger";
2
+ import type { PackageManifest } from "#/lib/types";
3
3
  import { getPackageName } from "./get-package-name";
4
4
 
5
5
  /**
@@ -1,4 +1,4 @@
1
- import { fileURLToPath } from "url";
1
+ import { fileURLToPath } from "node:url";
2
2
 
3
3
  /**
4
4
  * Calling context should pass in import.meta.url and the function will return
@@ -8,6 +8,6 @@ export * from "./is-present";
8
8
  export * from "./is-rush-workspace";
9
9
  export * from "./json";
10
10
  export * from "./log-paths";
11
- export * from "./pack";
11
+ export * from "./reset-isolate-dir";
12
12
  export * from "./unpack";
13
13
  export * from "./yaml";
@@ -3,32 +3,30 @@ import stripJsonComments from "strip-json-comments";
3
3
  import { getErrorMessage } from "./get-error-message";
4
4
 
5
5
  /** @todo Pass in zod schema and validate */
6
- export function readTypedJsonSync<T>(filePath: string) {
6
+ export function readTypedJsonSync(filePath: string): unknown {
7
7
  try {
8
8
  const rawContent = fs.readFileSync(filePath, "utf-8");
9
- const data = JSON.parse(
9
+ return JSON.parse(
10
10
  stripJsonComments(rawContent, { trailingCommas: true }),
11
- ) as T;
12
- return data;
13
- } catch (err) {
11
+ ) as unknown;
12
+ } catch (error) {
14
13
  throw new Error(
15
- `Failed to read JSON from ${filePath}: ${getErrorMessage(err)}`,
16
- { cause: err },
14
+ `Failed to read JSON from ${filePath}: ${getErrorMessage(error)}`,
15
+ { cause: error },
17
16
  );
18
17
  }
19
18
  }
20
19
 
21
- export async function readTypedJson<T>(filePath: string) {
20
+ export async function readTypedJson(filePath: string): Promise<unknown> {
22
21
  try {
23
22
  const rawContent = await fs.readFile(filePath, "utf-8");
24
- const data = JSON.parse(
23
+ return JSON.parse(
25
24
  stripJsonComments(rawContent, { trailingCommas: true }),
26
- ) as T;
27
- return data;
28
- } catch (err) {
25
+ ) as unknown;
26
+ } catch (error) {
29
27
  throw new Error(
30
- `Failed to read JSON from ${filePath}: ${getErrorMessage(err)}`,
31
- { cause: err },
28
+ `Failed to read JSON from ${filePath}: ${getErrorMessage(error)}`,
29
+ { cause: error },
32
30
  );
33
31
  }
34
32
  }
@@ -1,26 +1,31 @@
1
1
  import assert from "node:assert";
2
2
  import { exec } from "node:child_process";
3
- import fs from "node:fs";
4
3
  import path from "node:path";
5
4
  import { useLogger } from "../logger";
6
5
  import { shouldUsePnpmPack } from "../package-manager";
7
6
  import { getErrorMessage } from "./get-error-message";
7
+ import { waitForCompleteFile } from "./wait-for-complete-file";
8
+
9
+ /**
10
+ * How long to wait for the packed tarball to appear and stop growing on disk
11
+ * after `pnpm pack` / `npm pack` has exited.
12
+ */
13
+ const PACK_FILE_READY_TIMEOUT_MS = 5000;
14
+ const PACK_FILE_READY_POLL_MS = 50;
8
15
 
9
16
  export async function pack(srcDir: string, dstDir: string) {
10
17
  const log = useLogger();
11
18
 
12
19
  const execOptions = {
20
+ cwd: srcDir,
13
21
  maxBuffer: 10 * 1024 * 1024,
14
22
  };
15
23
 
16
- const previousCwd = process.cwd();
17
- process.chdir(srcDir);
18
-
19
24
  /**
20
25
  * PNPM pack seems to be a lot faster than NPM pack, so when PNPM is detected
21
26
  * we use that instead.
22
27
  */
23
- const stdout = shouldUsePnpmPack()
28
+ const packStdout = shouldUsePnpmPack()
24
29
  ? await new Promise<string>((resolve, reject) => {
25
30
  exec(
26
31
  `pnpm pack --pack-destination "${dstDir}"`,
@@ -28,7 +33,8 @@ export async function pack(srcDir: string, dstDir: string) {
28
33
  (err, stdout) => {
29
34
  if (err) {
30
35
  log.error(getErrorMessage(err));
31
- return reject(err);
36
+ reject(err);
37
+ return;
32
38
  }
33
39
 
34
40
  resolve(stdout);
@@ -41,7 +47,8 @@ export async function pack(srcDir: string, dstDir: string) {
41
47
  execOptions,
42
48
  (err, stdout) => {
43
49
  if (err) {
44
- return reject(err);
50
+ reject(err);
51
+ return;
45
52
  }
46
53
 
47
54
  resolve(stdout);
@@ -49,9 +56,12 @@ export async function pack(srcDir: string, dstDir: string) {
49
56
  );
50
57
  });
51
58
 
52
- const lastLine = stdout.trim().split("\n").at(-1);
59
+ const lastLine = packStdout.trim().split("\n").at(-1);
53
60
 
54
- assert(lastLine, `Failed to parse last line from stdout: ${stdout.trim()}`);
61
+ assert(
62
+ lastLine,
63
+ `Failed to parse last line from stdout: ${packStdout.trim()}`,
64
+ );
55
65
 
56
66
  const fileName = path.basename(lastLine);
57
67
 
@@ -59,20 +69,20 @@ export async function pack(srcDir: string, dstDir: string) {
59
69
 
60
70
  const filePath = path.join(dstDir, fileName);
61
71
 
62
- if (!fs.existsSync(filePath)) {
63
- log.error(
64
- `The response from pack could not be resolved to an existing file: ${filePath}`,
65
- );
66
- } else {
67
- log.debug(`Packed (temp)/${fileName}`);
68
- }
69
-
70
- process.chdir(previousCwd);
71
-
72
72
  /**
73
- * Return the path anyway even if it doesn't validate. A later stage will wait
74
- * for the file to occur still. Not sure if this makes sense. Maybe we should
75
- * stop at the validation error...
73
+ * `pnpm pack` (and occasionally `npm pack`) can return before the tarball is
74
+ * fully visible/flushed to disk. A naive `existsSync` check is not enough:
75
+ * the directory entry can appear before the file's data has been written,
76
+ * which causes downstream consumers (gunzip + tar) to fail with
77
+ * "unexpected end of file". Wait until the file exists and its size has
78
+ * stopped changing across two consecutive polls before returning.
76
79
  */
80
+ await waitForCompleteFile(filePath, {
81
+ timeoutMs: PACK_FILE_READY_TIMEOUT_MS,
82
+ pollMs: PACK_FILE_READY_POLL_MS,
83
+ });
84
+
85
+ log.debug(`Packed (temp)/${fileName}`);
86
+
77
87
  return filePath;
78
88
  }
@@ -0,0 +1,165 @@
1
+ import fs from "fs-extra";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { resetIsolateDir } from "./reset-isolate-dir";
6
+
7
+ /**
8
+ * Wait until `predicate` returns true or the timeout elapses. Used for the
9
+ * fire-and-forget background delete, which we can't await directly.
10
+ */
11
+ async function waitFor(
12
+ predicate: () => boolean | Promise<boolean>,
13
+ {
14
+ timeoutMs = 2000,
15
+ intervalMs = 20,
16
+ }: { timeoutMs?: number; intervalMs?: number } = {},
17
+ ): Promise<void> {
18
+ const deadline = Date.now() + timeoutMs;
19
+ while (Date.now() < deadline) {
20
+ if (await predicate()) return;
21
+ await new Promise((resolve) => {
22
+ setTimeout(resolve, intervalMs);
23
+ });
24
+ }
25
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for predicate`);
26
+ }
27
+
28
+ describe("resetIsolateDir", () => {
29
+ let tempDir: string;
30
+
31
+ beforeEach(async () => {
32
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "reset-isolate-dir-"));
33
+ });
34
+
35
+ afterEach(async () => {
36
+ await fs.remove(tempDir).catch(() => {
37
+ /** Best-effort. */
38
+ });
39
+ });
40
+
41
+ it("creates an empty isolate dir when none exists", async () => {
42
+ const isolateDir = path.join(tempDir, "package", "isolate");
43
+
44
+ await resetIsolateDir(isolateDir);
45
+
46
+ expect(fs.existsSync(isolateDir)).toBe(true);
47
+ expect(await fs.readdir(isolateDir)).toEqual([]);
48
+ });
49
+
50
+ it("empties an existing isolate dir and creates the trash sibling next to it by default", async () => {
51
+ const isolateDir = path.join(tempDir, "package", "isolate");
52
+ await fs.ensureDir(isolateDir);
53
+ await fs.writeFile(path.join(isolateDir, "stale.txt"), "stale");
54
+
55
+ await resetIsolateDir(isolateDir);
56
+
57
+ expect(fs.existsSync(isolateDir)).toBe(true);
58
+ expect(await fs.readdir(isolateDir)).toEqual([]);
59
+
60
+ /** Background delete eventually removes the trash sibling. */
61
+ await waitFor(async () => {
62
+ const entries = await fs.readdir(path.dirname(isolateDir));
63
+ return entries.every((entry) => !entry.includes(".trash-"));
64
+ });
65
+ });
66
+
67
+ it("places trash in the provided trashParentDir instead of next to isolateDir", async () => {
68
+ const packageParent = path.join(tempDir, "packages");
69
+ const isolateDir = path.join(packageParent, "api", "isolate");
70
+ await fs.ensureDir(isolateDir);
71
+ await fs.writeFile(path.join(isolateDir, "stale.txt"), "stale");
72
+
73
+ /**
74
+ * Wrap `fs.rename` so we can read back the destination path the function
75
+ * picked — the trash dir name is randomised, so capturing the spy's
76
+ * arguments is the only way to assert where the rename targeted.
77
+ */
78
+ const renameSpy = vi.spyOn(fs, "rename");
79
+
80
+ await resetIsolateDir(isolateDir, { trashParentDir: packageParent });
81
+
82
+ expect(renameSpy).toHaveBeenCalledTimes(1);
83
+ const [, renamedTo] = renameSpy.mock.calls[0]!;
84
+ expect(path.dirname(renamedTo as string)).toBe(packageParent);
85
+ expect(path.basename(renamedTo as string)).toMatch(
86
+ /^\.api-isolate\.trash-/,
87
+ );
88
+
89
+ /** The original target package dir contains nothing but a fresh empty isolate dir. */
90
+ expect(await fs.readdir(path.dirname(isolateDir))).toEqual(["isolate"]);
91
+ expect(await fs.readdir(isolateDir)).toEqual([]);
92
+
93
+ renameSpy.mockRestore();
94
+
95
+ await waitFor(async () => {
96
+ const entries = await fs.readdir(packageParent);
97
+ return entries.every((entry) => !entry.includes(".trash-"));
98
+ });
99
+ });
100
+
101
+ it("sweeps leftover trash from previous runs", async () => {
102
+ const packageParent = path.join(tempDir, "packages");
103
+ const isolateDir = path.join(packageParent, "api", "isolate");
104
+ await fs.ensureDir(isolateDir);
105
+
106
+ /** Simulate debris left behind by a previously killed run. */
107
+ const stale1 = path.join(packageParent, ".api-isolate.trash-9999-aabbccdd");
108
+ const stale2 = path.join(packageParent, ".api-isolate.trash-9998-eeff0011");
109
+ await fs.ensureDir(stale1);
110
+ await fs.ensureDir(stale2);
111
+ await fs.writeFile(path.join(stale1, "junk"), "junk");
112
+
113
+ /** Unrelated sibling that must be left alone. */
114
+ const sibling = path.join(packageParent, "web");
115
+ await fs.ensureDir(sibling);
116
+
117
+ await resetIsolateDir(isolateDir, { trashParentDir: packageParent });
118
+
119
+ /** Eventually both the stale entries and any new trash are gone. */
120
+ await waitFor(async () => {
121
+ const entries = await fs.readdir(packageParent);
122
+ return entries.every((entry) => !entry.includes(".trash-"));
123
+ });
124
+
125
+ expect(fs.existsSync(sibling)).toBe(true);
126
+ });
127
+
128
+ it("only sweeps trash matching this isolateDir's stem", async () => {
129
+ const packageParent = path.join(tempDir, "packages");
130
+ const isolateDir = path.join(packageParent, "api", "isolate");
131
+ await fs.ensureDir(isolateDir);
132
+
133
+ /** Trash from a different package's isolate run. */
134
+ const otherTrash = path.join(
135
+ packageParent,
136
+ ".web-isolate.trash-1234-deadbeef",
137
+ );
138
+ await fs.ensureDir(otherTrash);
139
+
140
+ await resetIsolateDir(isolateDir, { trashParentDir: packageParent });
141
+
142
+ /** The sweep filter is keyed on the stem, so other packages' trash stays. */
143
+ expect(fs.existsSync(otherTrash)).toBe(true);
144
+ });
145
+
146
+ it("falls back to recursive delete when rename fails", async () => {
147
+ const isolateDir = path.join(tempDir, "package", "isolate");
148
+ await fs.ensureDir(isolateDir);
149
+ await fs.writeFile(path.join(isolateDir, "stale.txt"), "stale");
150
+
151
+ const renameSpy = vi
152
+ .spyOn(fs, "rename")
153
+ .mockRejectedValueOnce(
154
+ Object.assign(new Error("EXDEV"), { code: "EXDEV" }),
155
+ );
156
+
157
+ await resetIsolateDir(isolateDir);
158
+
159
+ expect(renameSpy).toHaveBeenCalledTimes(1);
160
+ expect(fs.existsSync(isolateDir)).toBe(true);
161
+ expect(await fs.readdir(isolateDir)).toEqual([]);
162
+
163
+ renameSpy.mockRestore();
164
+ });
165
+ });