@danypops/pi-packed 0.21.13 → 0.21.15
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.
- package/dist/client.d.ts +16 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +1 -1
- package/dist/protocol.d.ts +6 -0
- package/dist/protocol.d.ts.map +1 -1
- package/extension/src/approval/reload.ts +51 -2
- package/extension/src/index.ts +9 -1
- package/extension/src/model.ts +35 -0
- package/extension/src/package-inspector.ts +12 -0
- package/extension/src/tabs/discover.ts +27 -3
- package/extension/src/tool-output.ts +14 -1
- package/extension/src/tools.ts +55 -28
- package/extension/src/vehicle-target.ts +24 -0
- package/extension/src/vehicle-tools.ts +84 -0
- package/package.json +2 -2
- package/service/src/adoption/install-validation.ts +19 -1
- package/service/src/daemon/service.ts +17 -1
- package/service/src/packages/install.ts +94 -13
- package/service/src/packages/package.ts +35 -0
- package/service/src/public/client.ts +29 -0
- package/service/src/public/protocol.ts +6 -0
- package/service/src/registry/registry.ts +20 -1
- package/service/src/setup/setup.ts +50 -2
- package/service/test/install.test.ts +109 -2
- package/service/test/npm-metadata-e2e.test.ts +25 -3
- package/service/test/perf/multi-install.perf.test.ts +222 -0
- package/service/test/setup.test.ts +160 -0
|
@@ -15,14 +15,24 @@ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
|
|
|
15
15
|
import { tmpdir } from "node:os";
|
|
16
16
|
import { join } from "node:path";
|
|
17
17
|
import { ExecInstaller } from "../src/packages/install.ts";
|
|
18
|
+
import { createLogger } from "../src/shared/log.ts";
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* A fake `pi` binary: always prints "Updated <source>" and exits 0 (matching
|
|
21
22
|
* real `pi`'s observed behavior for a no-op). When `rewrite` is given, it
|
|
22
23
|
* additionally overwrites that exact package's on-disk version -- letting a
|
|
23
24
|
* test simulate a genuine version change on demand. The rewrite target is
|
|
24
|
-
* baked into the script file itself (not an env var)
|
|
25
|
-
*
|
|
25
|
+
* baked into the script file itself (not an env var) rather than read from
|
|
26
|
+
* process.env at spawn time, keeping these fixtures independent of whichever
|
|
27
|
+
* way ExecInstaller happens to thread its own env through -- see the
|
|
28
|
+
* `run()`/`reresolveDependencyTree() thread env explicitly` describe block
|
|
29
|
+
* below for a dedicated test of that env-threading behavior itself, added
|
|
30
|
+
* after service/test/perf/multi-install.perf.test.ts caught it live: a
|
|
31
|
+
* runtime process.env mutation (redirecting Pi's home directory) silently
|
|
32
|
+
* never reached the spawned `pi`/`npm` children because Bun.spawn's own
|
|
33
|
+
* default env inheritance doesn't pick up a mutation made after this
|
|
34
|
+
* process's own startup snapshot -- only an explicit `env: process.env`
|
|
35
|
+
* option re-reads the current object.
|
|
26
36
|
*/
|
|
27
37
|
const roots: string[] = [];
|
|
28
38
|
afterEach(() => {
|
|
@@ -206,3 +216,100 @@ describe("ExecInstaller — forces full dependency re-resolution, not just the t
|
|
|
206
216
|
await expect(installer.update("npm:plain")).rejects.toThrow(/npm install failed to re-resolve/);
|
|
207
217
|
});
|
|
208
218
|
});
|
|
219
|
+
|
|
220
|
+
describe("ExecInstaller — timing instrumentation (see service/test/perf/multi-install.perf.test.ts for a real multi-package measurement)", () => {
|
|
221
|
+
it("install() logs a validateMs/installMs/reresolveMs/totalMs breakdown, not just a pass/fail result", async () => {
|
|
222
|
+
const scriptDir = track(mkdtempSync(join(tmpdir(), "packed-exec-bin-")));
|
|
223
|
+
const bin = writeFakePi(scriptDir);
|
|
224
|
+
const npmBin = writeFakeNpm(scriptDir, join(scriptDir, "npm.log"));
|
|
225
|
+
const piHome = writePiHome();
|
|
226
|
+
const lines: string[] = [];
|
|
227
|
+
const logger = createLogger("test", (line) => lines.push(line), "debug");
|
|
228
|
+
const installer = new ExecInstaller(bin, piHome, { validate: async (source) => ({ ok: true, source, extensions: [] }) }, npmBin, logger);
|
|
229
|
+
|
|
230
|
+
await installer.install("npm:plain");
|
|
231
|
+
|
|
232
|
+
const timing = lines.map((line) => JSON.parse(line)).find((entry) => entry.msg === "install timing");
|
|
233
|
+
expect(timing).toBeDefined();
|
|
234
|
+
expect(timing.source).toBe("npm:plain");
|
|
235
|
+
for (const field of ["validateMs", "installMs", "reresolveMs", "totalMs"]) {
|
|
236
|
+
expect(typeof timing[field]).toBe("number");
|
|
237
|
+
expect(timing[field]).toBeGreaterThanOrEqual(0);
|
|
238
|
+
}
|
|
239
|
+
// totalMs is the whole call's own wall clock, not just one phase re-labeled --
|
|
240
|
+
// bounded above by itself plus a small scheduling-noise allowance, never equal to
|
|
241
|
+
// a single phase alone once every phase is genuinely counted once.
|
|
242
|
+
expect(timing.totalMs).toBeGreaterThanOrEqual(timing.validateMs + timing.installMs + timing.reresolveMs - 1);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("update() logs an updateMs/reresolveMs/totalMs breakdown", async () => {
|
|
246
|
+
const scriptDir = track(mkdtempSync(join(tmpdir(), "packed-exec-bin-")));
|
|
247
|
+
const bin = writeFakePi(scriptDir);
|
|
248
|
+
const npmBin = writeFakeNpm(scriptDir, join(scriptDir, "npm.log"));
|
|
249
|
+
const piHome = writePiHome({ plain: "0.5.0" });
|
|
250
|
+
const lines: string[] = [];
|
|
251
|
+
const logger = createLogger("test", (line) => lines.push(line), "debug");
|
|
252
|
+
const installer = new ExecInstaller(bin, piHome, undefined, npmBin, logger);
|
|
253
|
+
|
|
254
|
+
await installer.update("npm:plain");
|
|
255
|
+
|
|
256
|
+
const timing = lines.map((line) => JSON.parse(line)).find((entry) => entry.msg === "update timing");
|
|
257
|
+
expect(timing).toBeDefined();
|
|
258
|
+
expect(timing.source).toBe("npm:plain");
|
|
259
|
+
for (const field of ["updateMs", "reresolveMs", "totalMs"]) {
|
|
260
|
+
expect(typeof timing[field]).toBe("number");
|
|
261
|
+
expect(timing[field]).toBeGreaterThanOrEqual(0);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* A fake binary that dumps one specific env var's CURRENT value to `logFile` -- proves a
|
|
268
|
+
* process.env mutation made at runtime (after this test process's own startup, the exact shape
|
|
269
|
+
* of a caller redirecting Pi's home directory) actually reaches the spawned child, rather than
|
|
270
|
+
* whatever snapshot Bun.spawn's own default env inheritance captured earlier.
|
|
271
|
+
*/
|
|
272
|
+
function writeEnvDumpBinary(dir: string, name: string, varName: string, logFile: string): string {
|
|
273
|
+
const script = join(dir, name);
|
|
274
|
+
writeFileSync(script, ["#!/usr/bin/env bash", `printf '%s' "\$${varName}" > '${logFile}'`, "exit 0"].join("\n"));
|
|
275
|
+
chmodSync(script, 0o755);
|
|
276
|
+
return script;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
describe("ExecInstaller — run()/reresolveDependencyTree() thread the CURRENT process.env through explicitly", () => {
|
|
280
|
+
const MARKER = "PACKED_TEST_ENV_MARKER";
|
|
281
|
+
|
|
282
|
+
afterEach(() => {
|
|
283
|
+
delete process.env[MARKER];
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("install()'s pi spawn sees an env var set on process.env after this process already started", async () => {
|
|
287
|
+
const scriptDir = track(mkdtempSync(join(tmpdir(), "packed-exec-env-")));
|
|
288
|
+
const piLog = join(scriptDir, "pi-env.log");
|
|
289
|
+
const bin = writeEnvDumpBinary(scriptDir, "fake-pi-env", MARKER, piLog);
|
|
290
|
+
const npmBin = writeFakeNpm(scriptDir, join(scriptDir, "npm.log"));
|
|
291
|
+
const piHome = writePiHome();
|
|
292
|
+
const installer = new ExecInstaller(bin, piHome, { validate: async (source) => ({ ok: true, source, extensions: [] }) }, npmBin);
|
|
293
|
+
|
|
294
|
+
// Mutated well after this test process's own startup -- exactly what redirecting Pi's home
|
|
295
|
+
// via PI_CODING_AGENT_DIR at runtime looks like from ExecInstaller's own point of view.
|
|
296
|
+
process.env[MARKER] = "set-after-startup";
|
|
297
|
+
await installer.install("npm:plain");
|
|
298
|
+
|
|
299
|
+
expect(readFileSync(piLog, "utf8")).toBe("set-after-startup");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("reresolveDependencyTree()'s npm spawn sees the same runtime env mutation", async () => {
|
|
303
|
+
const scriptDir = track(mkdtempSync(join(tmpdir(), "packed-exec-env-")));
|
|
304
|
+
const bin = writeFakePi(scriptDir);
|
|
305
|
+
const npmLog = join(scriptDir, "npm-env.log");
|
|
306
|
+
const npmBin = writeEnvDumpBinary(scriptDir, "fake-npm-env", MARKER, npmLog);
|
|
307
|
+
const piHome = writePiHome();
|
|
308
|
+
const installer = new ExecInstaller(bin, piHome, { validate: async (source) => ({ ok: true, source, extensions: [] }) }, npmBin);
|
|
309
|
+
|
|
310
|
+
process.env[MARKER] = "reresolve-sees-this-too";
|
|
311
|
+
await installer.install("npm:plain");
|
|
312
|
+
|
|
313
|
+
expect(readFileSync(npmLog, "utf8")).toBe("reresolve-sees-this-too");
|
|
314
|
+
});
|
|
315
|
+
});
|
|
@@ -9,6 +9,9 @@ import { HttpRegistry } from "../src/registry/registry.ts";
|
|
|
9
9
|
import { createApp } from "../src/daemon/service.ts";
|
|
10
10
|
|
|
11
11
|
const TOKEN = "e".repeat(64);
|
|
12
|
+
const PUBLISHED_MODIFIED = "2024-03-01T00:00:00.000Z";
|
|
13
|
+
const WEEKLY_DOWNLOADS = 12_345;
|
|
14
|
+
const MONTHLY_DOWNLOADS = 54_321;
|
|
12
15
|
const PUBLISHED_README = `# @danypops/enigma
|
|
13
16
|
|
|
14
17
|
Encrypted credential vault and supervisor daemon. Holds delegated OAuth/API
|
|
@@ -56,17 +59,28 @@ describe("published npm metadata E2E", () => {
|
|
|
56
59
|
});
|
|
57
60
|
}
|
|
58
61
|
if (url.pathname === "/%40danypops/enigma") {
|
|
62
|
+
// One response body serves both callers that hit this exact path: HttpRegistry's own
|
|
63
|
+
// publishedReadme() (Accept: application/json, reads .readme) and modifiedAt() (Accept:
|
|
64
|
+
// application/vnd.npm.install-v1+json, reads .modified) -- real npm content-negotiates
|
|
65
|
+
// two different document shapes for the same URL; this fixture keeps it simple by
|
|
66
|
+
// returning both fields unconditionally, since each caller only ever reads its own.
|
|
59
67
|
return Response.json({
|
|
60
68
|
name: "@danypops/enigma",
|
|
61
69
|
"dist-tags": { latest: "0.22.1" },
|
|
62
70
|
readme: PUBLISHED_README,
|
|
71
|
+
modified: PUBLISHED_MODIFIED,
|
|
63
72
|
});
|
|
64
73
|
}
|
|
74
|
+
if (url.pathname === "/downloads/point/last-week/%40danypops/enigma") return Response.json({ downloads: WEEKLY_DOWNLOADS });
|
|
75
|
+
if (url.pathname === "/downloads/point/last-month/%40danypops/enigma") return Response.json({ downloads: MONTHLY_DOWNLOADS });
|
|
65
76
|
return new Response("not found", { status: 404 });
|
|
66
77
|
},
|
|
67
78
|
});
|
|
68
79
|
const app = createApp({
|
|
69
|
-
|
|
80
|
+
// downloadsBase points at the SAME fake server as the registry itself -- real npm splits
|
|
81
|
+
// these across two different hosts (registry.npmjs.org vs api.npmjs.org), but nothing here
|
|
82
|
+
// cares which host a request lands on, only that it's never the real internet.
|
|
83
|
+
reg: new HttpRegistry(`http://127.0.0.1:${npmServer.port}`, 20, 0, 1, `http://127.0.0.1:${npmServer.port}`),
|
|
70
84
|
inst: new NoopInstaller(),
|
|
71
85
|
token: TOKEN,
|
|
72
86
|
stateDir,
|
|
@@ -80,15 +94,21 @@ describe("published npm metadata E2E", () => {
|
|
|
80
94
|
rmSync(stateDir, { recursive: true, force: true });
|
|
81
95
|
});
|
|
82
96
|
|
|
83
|
-
it("preserves an npm-published README through HttpRegistry, daemon RPC, and PackedClient", async () => {
|
|
97
|
+
it("preserves an npm-published README through HttpRegistry, daemon RPC, and PackedClient, plus the daemon's own package.info release-date/downloads enrichment", async () => {
|
|
84
98
|
const client = new PackedClient(`http://127.0.0.1:${daemonServer.port}`, TOKEN);
|
|
85
99
|
const info = await client.info("@danypops/enigma");
|
|
86
100
|
|
|
87
|
-
|
|
101
|
+
// 2 from HttpRegistry.info() itself (latest + readme) + 1 from the daemon's own
|
|
102
|
+
// package.info enrichment calling modifiedAt() (same path as readme, different Accept) + 2
|
|
103
|
+
// from downloads() (last-week, last-month) -- see service.ts's "/info" handler.
|
|
104
|
+
expect(npmRequests).toHaveLength(5);
|
|
88
105
|
expect(npmRequests).toEqual(
|
|
89
106
|
expect.arrayContaining([
|
|
90
107
|
{ path: "/%40danypops/enigma/latest", accept: "application/json" },
|
|
91
108
|
{ path: "/%40danypops/enigma", accept: "application/json" },
|
|
109
|
+
{ path: "/%40danypops/enigma", accept: "application/vnd.npm.install-v1+json" },
|
|
110
|
+
{ path: "/downloads/point/last-week/%40danypops/enigma", accept: "*/*" },
|
|
111
|
+
{ path: "/downloads/point/last-month/%40danypops/enigma", accept: "*/*" },
|
|
92
112
|
]),
|
|
93
113
|
);
|
|
94
114
|
expect(info).toMatchObject({
|
|
@@ -97,6 +117,8 @@ describe("published npm metadata E2E", () => {
|
|
|
97
117
|
license: "MIT",
|
|
98
118
|
readmeAvailable: true,
|
|
99
119
|
readme: PUBLISHED_README,
|
|
120
|
+
modified: PUBLISHED_MODIFIED,
|
|
121
|
+
downloads: { weekly: WEEKLY_DOWNLOADS, monthly: MONTHLY_DOWNLOADS },
|
|
100
122
|
});
|
|
101
123
|
});
|
|
102
124
|
});
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A real, end-to-end multi-package install/update benchmark against a clean, isolated Pi + Packed
|
|
3
|
+
* environment -- the "install/update several packages together" question this exists to answer.
|
|
4
|
+
*
|
|
5
|
+
* As of this file's own history, SetupManager.apply() (service/src/setup/setup.ts) opts into
|
|
6
|
+
* BATCH MODE whenever its Installer implements validate()/installOnly()/reresolveDependencyTree()
|
|
7
|
+
* (ExecInstaller does): every package change is validate()d CONCURRENTLY first (each call stages
|
|
8
|
+
* into its own throwaway temp dir, zero shared state -- see Installer.validate's own doc comment),
|
|
9
|
+
* then installOnly() runs sequentially per package (an unlocked, shared piHome makes concurrent
|
|
10
|
+
* `pi install` calls genuinely unsafe -- confirmed empirically: two of three concurrent installs
|
|
11
|
+
* silently lost their own settings.json/package.json entries even though the CLI itself reported
|
|
12
|
+
* success), and reresolveDependencyTree() (a FULL `npm install` at piHome/npm) runs exactly ONCE
|
|
13
|
+
* for the whole batch instead of once per package. This file's own git history has the "before"
|
|
14
|
+
* numbers (N=10: ~35s, ~24% of it nine redundant reresolves) if you want the direct comparison.
|
|
15
|
+
*
|
|
16
|
+
* "Clean version of Pi with Packed": a freshly created temp directory stands in for ~/.pi --
|
|
17
|
+
* PI_CODING_AGENT_DIR (the real pi CLI's own env override, see @earendil-works/pi-coding-agent's
|
|
18
|
+
* config.js getAgentDir()) points the real `pi` binary at it, and the identical path is handed to
|
|
19
|
+
* SetupManager/ExecInstaller as piHome, so both sides agree on one on-disk state exactly the way a
|
|
20
|
+
* real `packed setup apply` run would. No pi-packed daemon process is started -- SetupManager and
|
|
21
|
+
* ExecInstaller are exactly what the daemon's own setup.apply Vehicle operation calls internally
|
|
22
|
+
* (see service/src/daemon/vehicle-registration.ts), so driving them directly here is the same real
|
|
23
|
+
* production code path minus the HTTP hop.
|
|
24
|
+
*
|
|
25
|
+
* Deliberately real npm packages, not a hand-rolled fake registry: ten tiny, long-published,
|
|
26
|
+
* zero-postinstall-script leaf packages (is-number, is-buffer, ...), pinned to exact versions with
|
|
27
|
+
* their real npm-published sha512 integrity below, so a run is deterministic in WHAT it installs
|
|
28
|
+
* even though the real npm registry's own latency is (honestly) not. None declares pi.extensions,
|
|
29
|
+
* so HeadlessInstallValidator's own extra per-extension load-check subprocesses never run for
|
|
30
|
+
* them -- this measures the floor every npm: install already pays (npm pack + npm install), not
|
|
31
|
+
* the additional cost a genuine Pi extension's own load validation adds on top.
|
|
32
|
+
*
|
|
33
|
+
* Skipped by default (like service/test/smoke.test.ts's own bwrap gate) -- this makes real,
|
|
34
|
+
* uncached network calls and takes real wall-clock seconds, unlike the rest of this suite. Run it
|
|
35
|
+
* on demand:
|
|
36
|
+
*
|
|
37
|
+
* PACKED_PERF=1 bun test service/test/perf/multi-install.perf.test.ts
|
|
38
|
+
* PACKED_PERF=1 PACKED_PERF_N=10 bun test service/test/perf/multi-install.perf.test.ts
|
|
39
|
+
*/
|
|
40
|
+
import { describe, expect, it } from "bun:test";
|
|
41
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
42
|
+
import { tmpdir } from "node:os";
|
|
43
|
+
import { join } from "node:path";
|
|
44
|
+
import { ExecInstaller } from "../../src/packages/install.ts";
|
|
45
|
+
import type { PkgInfo, Registry, SearchPage } from "../../src/packages/package.ts";
|
|
46
|
+
import { createLogger } from "../../src/shared/log.ts";
|
|
47
|
+
import { SetupManager } from "../../src/setup/setup.ts";
|
|
48
|
+
|
|
49
|
+
/** Real npm-published sha512 integrity for each pinned version below (`npm view <spec> dist.integrity`) -- SetupManifestSchema's NpmPackageSchema requires one, matching what SetupManager.resolvePackage() would itself fetch from the registry. */
|
|
50
|
+
const FIXTURE_PACKAGES: ReadonlyArray<{ name: string; version: string; integrity: string }> = [
|
|
51
|
+
{ name: "is-number", version: "7.0.0", integrity: "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" },
|
|
52
|
+
{ name: "is-buffer", version: "2.0.5", integrity: "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==" },
|
|
53
|
+
{
|
|
54
|
+
name: "is-plain-object",
|
|
55
|
+
version: "5.0.0",
|
|
56
|
+
integrity: "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
|
|
57
|
+
},
|
|
58
|
+
{ name: "is-callable", version: "1.2.7", integrity: "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==" },
|
|
59
|
+
{ name: "is-arrayish", version: "0.3.2", integrity: "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" },
|
|
60
|
+
{
|
|
61
|
+
name: "is-typedarray",
|
|
62
|
+
version: "1.0.0",
|
|
63
|
+
integrity: "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==",
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: "is-fullwidth-code-point",
|
|
67
|
+
version: "4.0.0",
|
|
68
|
+
integrity: "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==",
|
|
69
|
+
},
|
|
70
|
+
{ name: "is-extglob", version: "2.1.1", integrity: "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==" },
|
|
71
|
+
{ name: "is-glob", version: "4.0.3", integrity: "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==" },
|
|
72
|
+
{ name: "is-stream", version: "3.0.0", integrity: "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==" },
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/** Never exercised: resolvePackage() only needs Registry.info() when a package isn't ALREADY
|
|
76
|
+
* pinned in the manifest with a resolved version+integrity, which every fixture package here
|
|
77
|
+
* already is -- SetupManager.plan()/apply() never re-resolve an already-fully-specified entry. */
|
|
78
|
+
class UnusedRegistry implements Registry {
|
|
79
|
+
async search(): Promise<SearchPage> {
|
|
80
|
+
throw new Error("not exercised");
|
|
81
|
+
}
|
|
82
|
+
async searchPage(): Promise<SearchPage> {
|
|
83
|
+
throw new Error("not exercised");
|
|
84
|
+
}
|
|
85
|
+
async searchAll(): Promise<never[]> {
|
|
86
|
+
throw new Error("not exercised");
|
|
87
|
+
}
|
|
88
|
+
async info(): Promise<PkgInfo> {
|
|
89
|
+
throw new Error("not exercised");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface PackageTiming {
|
|
94
|
+
source: string;
|
|
95
|
+
validateMs?: number;
|
|
96
|
+
installMs?: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function requestedPackageCount(): number {
|
|
100
|
+
const raw = Number(process.env.PACKED_PERF_N);
|
|
101
|
+
return Number.isFinite(raw) && raw > 0 ? Math.min(Math.floor(raw), FIXTURE_PACKAGES.length) : FIXTURE_PACKAGES.length;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function formatReport(timings: PackageTiming[], reresolveMs: number, wallClockMs: number): string {
|
|
105
|
+
const sumValidate = timings.reduce((sum, t) => sum + (t.validateMs ?? 0), 0);
|
|
106
|
+
const sumInstall = timings.reduce((sum, t) => sum + (t.installMs ?? 0), 0);
|
|
107
|
+
const lines = [
|
|
108
|
+
"",
|
|
109
|
+
`packed multi-install perf (N=${timings.length}, BATCH mode: validate() concurrent, installOnly() sequential, one reresolve)`,
|
|
110
|
+
"-".repeat(80),
|
|
111
|
+
...timings.map(
|
|
112
|
+
(t, i) =>
|
|
113
|
+
` #${i + 1} ${t.source.padEnd(28)} validate=${(t.validateMs ?? 0).toFixed(0).padStart(6)}ms installOnly=${(t.installMs ?? 0).toFixed(0).padStart(6)}ms`,
|
|
114
|
+
),
|
|
115
|
+
"-".repeat(80),
|
|
116
|
+
` sum of validateMs (ran CONCURRENTLY -- NOT how long this phase actually took wall-clock) : ${sumValidate.toFixed(0)}ms`,
|
|
117
|
+
` sum of installOnly Ms (ran sequentially -- this IS roughly how long this phase took) : ${sumInstall.toFixed(0)}ms`,
|
|
118
|
+
` single batch reresolveDependencyTree() call : ${reresolveMs.toFixed(0)}ms`,
|
|
119
|
+
` observed wall clock (apply() total) : ${wallClockMs.toFixed(0)}ms`,
|
|
120
|
+
"",
|
|
121
|
+
];
|
|
122
|
+
return lines.join("\n");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const runPerf = process.env.PACKED_PERF === "1" ? describe : describe.skip;
|
|
126
|
+
|
|
127
|
+
runPerf("real multi-package install against a clean, isolated Pi + Packed", () => {
|
|
128
|
+
it(
|
|
129
|
+
"installs N real npm packages in ONE batch (validate concurrently, commit sequentially, reresolve once) and reports where the time goes",
|
|
130
|
+
async () => {
|
|
131
|
+
const n = requestedPackageCount();
|
|
132
|
+
const fixtures = FIXTURE_PACKAGES.slice(0, n);
|
|
133
|
+
|
|
134
|
+
const root = mkdtempSync(join(tmpdir(), "packed-perf-clean-pi-"));
|
|
135
|
+
const agentDir = join(root, ".pi", "agent");
|
|
136
|
+
const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
|
|
137
|
+
process.env.PI_CODING_AGENT_DIR = agentDir; // the real `pi` binary's own home-dir override
|
|
138
|
+
try {
|
|
139
|
+
const timings = new Map<string, PackageTiming>();
|
|
140
|
+
let reresolveMs = 0;
|
|
141
|
+
let reresolveCalls = 0;
|
|
142
|
+
const logger = createLogger(
|
|
143
|
+
"perf",
|
|
144
|
+
(line) => {
|
|
145
|
+
try {
|
|
146
|
+
const entry = JSON.parse(line) as Record<string, unknown>;
|
|
147
|
+
if (entry.msg === "validate timing" && typeof entry.source === "string") {
|
|
148
|
+
timings.set(entry.source, { ...timings.get(entry.source), source: entry.source, validateMs: Number(entry.validateMs) });
|
|
149
|
+
} else if (entry.msg === "installOnly timing" && typeof entry.source === "string") {
|
|
150
|
+
timings.set(entry.source, { ...timings.get(entry.source), source: entry.source, installMs: Number(entry.installMs) });
|
|
151
|
+
} else if (entry.msg === "reresolve timing") {
|
|
152
|
+
reresolveCalls += 1;
|
|
153
|
+
reresolveMs = Number(entry.reresolveMs);
|
|
154
|
+
}
|
|
155
|
+
} catch {
|
|
156
|
+
// non-JSON log line -- ignore, this sink only cares about structured timing entries
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
"debug",
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const installer = new ExecInstaller(undefined, agentDir, undefined, undefined, logger);
|
|
163
|
+
const manager = new SetupManager(new UnusedRegistry(), installer, agentDir);
|
|
164
|
+
|
|
165
|
+
const manifestPath = join(root, "pi-setup.json");
|
|
166
|
+
await Bun.write(
|
|
167
|
+
manifestPath,
|
|
168
|
+
JSON.stringify({
|
|
169
|
+
$schema: "./schema/pi-setup-v1.schema.json",
|
|
170
|
+
schemaVersion: 1,
|
|
171
|
+
packages: fixtures.map((pkg) => ({
|
|
172
|
+
kind: "npm",
|
|
173
|
+
scope: "global",
|
|
174
|
+
source: `npm:${pkg.name}@${pkg.version}`,
|
|
175
|
+
resolved: pkg.version,
|
|
176
|
+
integrity: pkg.integrity,
|
|
177
|
+
})),
|
|
178
|
+
profiles: {},
|
|
179
|
+
}),
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const wallClockStart = performance.now();
|
|
183
|
+
const result = await manager.apply(manifestPath);
|
|
184
|
+
const wallClockMs = performance.now() - wallClockStart;
|
|
185
|
+
|
|
186
|
+
// Surfaced unconditionally (not just on failure): result.operations[].output carries
|
|
187
|
+
// ExecInstaller's own real stdout/stderr/error text -- result.diagnostics alone only
|
|
188
|
+
// ever says "package operation failed", which sent an earlier real run of this exact
|
|
189
|
+
// harness straight to ad hoc /tmp debugging instead of just reading the test's own
|
|
190
|
+
// output. Never do that again -- if this harness can't explain its own failure, that's
|
|
191
|
+
// a gap in the harness to fix, not a cue to reach for bash/heredocs outside it.
|
|
192
|
+
console.log(
|
|
193
|
+
[
|
|
194
|
+
"",
|
|
195
|
+
"per-operation outcomes:",
|
|
196
|
+
...result.operations.map(
|
|
197
|
+
(op) => ` ${op.status.padEnd(9)} ${op.kind.padEnd(15)} ${op.target}${op.output ? ` -- ${op.output}` : ""}`,
|
|
198
|
+
),
|
|
199
|
+
"",
|
|
200
|
+
].join("\n"),
|
|
201
|
+
);
|
|
202
|
+
if (result.diagnostics.length > 0) console.log("diagnostics:", JSON.stringify(result.diagnostics, null, 2));
|
|
203
|
+
|
|
204
|
+
expect(result.diagnostics).toEqual([]);
|
|
205
|
+
expect(result.ok).toBe(true);
|
|
206
|
+
expect(result.operations.filter((op) => op.status === "succeeded")).toHaveLength(n);
|
|
207
|
+
expect(timings.size).toBe(n);
|
|
208
|
+
// The whole point of batch mode: exactly ONE reresolveDependencyTree() call for the
|
|
209
|
+
// entire batch, not one per package.
|
|
210
|
+
expect(reresolveCalls).toBe(1);
|
|
211
|
+
|
|
212
|
+
const orderedTimings = fixtures.map((pkg) => timings.get(`npm:${pkg.name}@${pkg.version}`)!);
|
|
213
|
+
console.log(formatReport(orderedTimings, reresolveMs, wallClockMs));
|
|
214
|
+
} finally {
|
|
215
|
+
if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
|
|
216
|
+
else process.env.PI_CODING_AGENT_DIR = previousAgentDir;
|
|
217
|
+
rmSync(root, { recursive: true, force: true });
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
10 * 60_000,
|
|
221
|
+
);
|
|
222
|
+
});
|
|
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from "bun:test";
|
|
|
2
2
|
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import type { InstallValidationResult } from "../src/adoption/install-validation.ts";
|
|
5
6
|
import type { Installer, PkgInfo, Registry, SearchPage, UpdateOutcome } from "../src/packages/package.ts";
|
|
6
7
|
import {
|
|
7
8
|
bundledEcosystemManifestPath,
|
|
@@ -47,6 +48,53 @@ class InstallerFixture implements Installer {
|
|
|
47
48
|
}
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Implements validate()/installOnly()/reresolveDependencyTree() (see Installer's own doc
|
|
53
|
+
* comment) in addition to the legacy install()/update()/remove() -- SetupManager.apply() only
|
|
54
|
+
* enters batch mode when all three are present. install() itself is kept real (not throwing) so
|
|
55
|
+
* a test can positively assert it's never called once the batch trio exists, rather than only
|
|
56
|
+
* ever seeing it absent from a call log.
|
|
57
|
+
*/
|
|
58
|
+
class BatchInstallerFixture implements Installer {
|
|
59
|
+
events: string[] = [];
|
|
60
|
+
reresolveCalls = 0;
|
|
61
|
+
validateDelayMs = 0;
|
|
62
|
+
failValidate = new Set<string>();
|
|
63
|
+
failInstallOnly = new Set<string>();
|
|
64
|
+
failReresolve = false;
|
|
65
|
+
|
|
66
|
+
async install(source: string) {
|
|
67
|
+
this.events.push(`install:${source}`);
|
|
68
|
+
return `Installed ${source}`;
|
|
69
|
+
}
|
|
70
|
+
async update(source: string): Promise<UpdateOutcome> {
|
|
71
|
+
this.events.push(`update:${source}`);
|
|
72
|
+
return { output: `Updated ${source}`, reloadRequired: true, alreadyUpToDate: false, pinned: true };
|
|
73
|
+
}
|
|
74
|
+
async remove(source: string) {
|
|
75
|
+
this.events.push(`remove:${source}`);
|
|
76
|
+
return `Removed ${source}`;
|
|
77
|
+
}
|
|
78
|
+
async validate(source: string): Promise<InstallValidationResult> {
|
|
79
|
+
if (this.validateDelayMs > 0) await Bun.sleep(this.validateDelayMs);
|
|
80
|
+
this.events.push(`validate:${source}`);
|
|
81
|
+
return this.failValidate.has(source)
|
|
82
|
+
? { ok: false, source, extensions: [], message: "validation refused" }
|
|
83
|
+
: { ok: true, source, extensions: [] };
|
|
84
|
+
}
|
|
85
|
+
async installOnly(source: string): Promise<string> {
|
|
86
|
+
this.events.push(`installOnly:${source}`);
|
|
87
|
+
if (this.failInstallOnly.has(source)) throw new Error(`installOnly failed for ${source}`);
|
|
88
|
+
return `Installed ${source}`;
|
|
89
|
+
}
|
|
90
|
+
async reresolveDependencyTree(): Promise<string> {
|
|
91
|
+
this.reresolveCalls++;
|
|
92
|
+
this.events.push("reresolve");
|
|
93
|
+
if (this.failReresolve) throw new Error("reresolve failed");
|
|
94
|
+
return "resolved";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
50
98
|
class GitFixture implements GitResolutionPort {
|
|
51
99
|
constructor(private commit = "a".repeat(40)) {}
|
|
52
100
|
async resolve(source: string) {
|
|
@@ -383,3 +431,115 @@ describe("Pi setup manifest walking skeleton", () => {
|
|
|
383
431
|
expect(existsSync(join(home, "profiles.json"))).toBe(false);
|
|
384
432
|
});
|
|
385
433
|
});
|
|
434
|
+
|
|
435
|
+
function threePackageManifest(): SetupManifest {
|
|
436
|
+
return {
|
|
437
|
+
$schema: "./schema/pi-setup-v1.schema.json",
|
|
438
|
+
schemaVersion: 1,
|
|
439
|
+
packages: [
|
|
440
|
+
{ kind: "npm", scope: "global", source: "npm:pkg-a@1.0.0", resolved: "1.0.0", integrity: "sha512-demo" },
|
|
441
|
+
{ kind: "npm", scope: "global", source: "npm:pkg-b@1.0.0", resolved: "1.0.0", integrity: "sha512-demo" },
|
|
442
|
+
{ kind: "npm", scope: "global", source: "npm:pkg-c@1.0.0", resolved: "1.0.0", integrity: "sha512-demo" },
|
|
443
|
+
],
|
|
444
|
+
profiles: {},
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
describe("SetupManager.apply() batch mode -- validate() concurrently, installOnly() sequentially, reresolveDependencyTree() once", () => {
|
|
449
|
+
it("never calls the legacy install() once validate/installOnly/reresolveDependencyTree all exist", async () => {
|
|
450
|
+
const home = piHome(false);
|
|
451
|
+
const root = project();
|
|
452
|
+
writeFileSync(join(root, "pi-setup.json"), JSON.stringify(threePackageManifest()));
|
|
453
|
+
const installer = new BatchInstallerFixture();
|
|
454
|
+
const result = await new SetupManager(new RegistryFixture(), installer, home).apply(join(root, "pi-setup.json"));
|
|
455
|
+
|
|
456
|
+
expect(result.ok).toBe(true);
|
|
457
|
+
expect(installer.events.some((event) => event.startsWith("install:"))).toBe(false);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("validates every package CONCURRENTLY, not one at a time -- wall clock proves overlap, not just call order", async () => {
|
|
461
|
+
const home = piHome(false);
|
|
462
|
+
const root = project();
|
|
463
|
+
writeFileSync(join(root, "pi-setup.json"), JSON.stringify(threePackageManifest()));
|
|
464
|
+
const installer = new BatchInstallerFixture();
|
|
465
|
+
installer.validateDelayMs = 50;
|
|
466
|
+
const manager = new SetupManager(new RegistryFixture(), installer, home);
|
|
467
|
+
|
|
468
|
+
const start = performance.now();
|
|
469
|
+
const result = await manager.apply(join(root, "pi-setup.json"));
|
|
470
|
+
const elapsedMs = performance.now() - start;
|
|
471
|
+
|
|
472
|
+
expect(result.ok).toBe(true);
|
|
473
|
+
// Three sequential 50ms validations would take >=150ms; three CONCURRENT ones take ~50ms
|
|
474
|
+
// plus scheduling noise. 100ms sits with a comfortable margin below the sequential floor
|
|
475
|
+
// without being tight enough to flake on a loaded CI box.
|
|
476
|
+
expect(elapsedMs).toBeLessThan(100);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it("validates every package before committing ANY of them, then commits sequentially, then reresolves exactly once", async () => {
|
|
480
|
+
const home = piHome(false);
|
|
481
|
+
const root = project();
|
|
482
|
+
writeFileSync(join(root, "pi-setup.json"), JSON.stringify(threePackageManifest()));
|
|
483
|
+
const installer = new BatchInstallerFixture();
|
|
484
|
+
const result = await new SetupManager(new RegistryFixture(), installer, home).apply(join(root, "pi-setup.json"));
|
|
485
|
+
|
|
486
|
+
expect(result.ok).toBe(true);
|
|
487
|
+
expect(installer.reresolveCalls).toBe(1);
|
|
488
|
+
const lastValidateIndex = installer.events.reduce((last, event, i) => (event.startsWith("validate:") ? i : last), -1);
|
|
489
|
+
const firstInstallOnlyIndex = installer.events.findIndex((event) => event.startsWith("installOnly:"));
|
|
490
|
+
expect(firstInstallOnlyIndex).toBeGreaterThan(lastValidateIndex);
|
|
491
|
+
// reresolve is genuinely last -- after every installOnly(), not interleaved between them.
|
|
492
|
+
expect(installer.events.at(-1)).toBe("reresolve");
|
|
493
|
+
expect(installer.events.filter((event) => event.startsWith("installOnly:"))).toHaveLength(3);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it("a validation failure stops the batch at that package -- earlier ones already committed, later ones never attempted, reresolve never runs", async () => {
|
|
497
|
+
const home = piHome(false);
|
|
498
|
+
const root = project();
|
|
499
|
+
writeFileSync(join(root, "pi-setup.json"), JSON.stringify(threePackageManifest()));
|
|
500
|
+
const installer = new BatchInstallerFixture();
|
|
501
|
+
installer.failValidate.add("npm:pkg-b@1.0.0");
|
|
502
|
+
const result = await new SetupManager(new RegistryFixture(), installer, home).apply(join(root, "pi-setup.json"));
|
|
503
|
+
|
|
504
|
+
expect(result.ok).toBe(false);
|
|
505
|
+
expect(result.diagnostics[0]?.code).toBe("SETUP_APPLY_FAILED");
|
|
506
|
+
expect(result.operations.map((op) => [op.target, op.status])).toEqual([
|
|
507
|
+
["npm:pkg-a@1.0.0", "succeeded"],
|
|
508
|
+
["npm:pkg-b@1.0.0", "failed"],
|
|
509
|
+
]);
|
|
510
|
+
// Validation itself still runs concurrently for every package up front (pkg-c's validate
|
|
511
|
+
// call already happened before the commit loop reached pkg-b) -- what never happens is
|
|
512
|
+
// COMMITTING pkg-c once the loop hits pkg-b's own failure.
|
|
513
|
+
expect(installer.events).toContain("validate:npm:pkg-c@1.0.0");
|
|
514
|
+
expect(installer.events).not.toContain("installOnly:npm:pkg-c@1.0.0");
|
|
515
|
+
expect(installer.reresolveCalls).toBe(0);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it("an installOnly() failure (not a validation failure) also stops the batch and skips reresolve", async () => {
|
|
519
|
+
const home = piHome(false);
|
|
520
|
+
const root = project();
|
|
521
|
+
writeFileSync(join(root, "pi-setup.json"), JSON.stringify(threePackageManifest()));
|
|
522
|
+
const installer = new BatchInstallerFixture();
|
|
523
|
+
installer.failInstallOnly.add("npm:pkg-a@1.0.0");
|
|
524
|
+
const result = await new SetupManager(new RegistryFixture(), installer, home).apply(join(root, "pi-setup.json"));
|
|
525
|
+
|
|
526
|
+
expect(result.ok).toBe(false);
|
|
527
|
+
expect(result.operations.map((op) => op.status)).toEqual(["failed"]);
|
|
528
|
+
expect(installer.reresolveCalls).toBe(0);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it("reports a reresolveDependencyTree() failure distinctly, after every package already installed/updated successfully", async () => {
|
|
532
|
+
const home = piHome(false);
|
|
533
|
+
const root = project();
|
|
534
|
+
writeFileSync(join(root, "pi-setup.json"), JSON.stringify(threePackageManifest()));
|
|
535
|
+
const installer = new BatchInstallerFixture();
|
|
536
|
+
installer.failReresolve = true;
|
|
537
|
+
const result = await new SetupManager(new RegistryFixture(), installer, home).apply(join(root, "pi-setup.json"));
|
|
538
|
+
|
|
539
|
+
expect(result.ok).toBe(false);
|
|
540
|
+
expect(result.reloadRequired).toBe(true);
|
|
541
|
+
expect(result.operations.every((op) => op.status === "succeeded")).toBe(true);
|
|
542
|
+
expect(result.diagnostics[0]).toMatchObject({ code: "SETUP_APPLY_FAILED", path: "reresolveDependencyTree" });
|
|
543
|
+
expect(result.diagnostics[0]?.message).toContain("reresolve failed");
|
|
544
|
+
});
|
|
545
|
+
});
|