@celilo/e2e 0.19.3 → 0.20.1

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 (50) hide show
  1. package/README.md +30 -13
  2. package/bin/e2e-bake-management +196 -12
  3. package/bin/e2e-infra +0 -1
  4. package/bin/e2e-up +14 -3
  5. package/docker/Dockerfile.observer +12 -1
  6. package/docker/Dockerfile.target-machine +22 -1
  7. package/npm-registry-server/package.json +1 -1
  8. package/package.json +3 -3
  9. package/registry-server/package.json +1 -1
  10. package/scripts/pack-celilo-packages.ts +15 -0
  11. package/src/block-timing.test.ts +559 -0
  12. package/src/block-timing.ts +366 -0
  13. package/src/cli/build.test.ts +135 -4
  14. package/src/cli/build.ts +237 -94
  15. package/src/cli/command-registry.ts +21 -0
  16. package/src/cli/command-tree-parser.ts +11 -2
  17. package/src/cli/completion.ts +9 -0
  18. package/src/cli/host.ts +252 -0
  19. package/src/cli/index.ts +78 -51
  20. package/src/cli/module-discovery.ts +108 -13
  21. package/src/cli/scaffold.ts +18 -26
  22. package/src/container-manager.cleanup.test.ts +284 -0
  23. package/src/container-manager.runner.test.ts +351 -0
  24. package/src/container-manager.test.ts +84 -0
  25. package/src/container-manager.ts +721 -185
  26. package/src/docker-compose-generator.ts +135 -61
  27. package/src/doctor.test.ts +259 -4
  28. package/src/doctor.ts +276 -3
  29. package/src/exit-cleanup.test.ts +83 -1
  30. package/src/fleet-nameserver-gate.test.ts +45 -0
  31. package/src/host-vm.test.ts +156 -0
  32. package/src/host-vm.ts +230 -0
  33. package/src/index.ts +11 -0
  34. package/src/live-stack.test.ts +300 -0
  35. package/src/live-stack.ts +355 -0
  36. package/src/no-unjustified-sleep.test.ts +90 -0
  37. package/src/proxmox-provisioner.test.ts +18 -2
  38. package/src/proxmox-provisioner.ts +22 -0
  39. package/src/public-sim-routes.test.ts +9 -2
  40. package/src/repo-root.ts +33 -0
  41. package/src/run-args.test.ts +76 -0
  42. package/src/run-args.ts +89 -0
  43. package/src/runner.ts +213 -8
  44. package/src/shared-infra.ts +88 -32
  45. package/src/socks-proxy.ts +2 -0
  46. package/src/source-fingerprint.test.ts +213 -0
  47. package/src/source-fingerprint.ts +212 -0
  48. package/src/stage-simulator-inputs.ts +93 -0
  49. package/src/stages.ts +133 -0
  50. package/src/wait-for-run.ts +1 -0
@@ -0,0 +1,213 @@
1
+ /**
2
+ * The fingerprint has one job: change when the baked CLI would change, and not
3
+ * otherwise. Both halves are failure modes. A fingerprint that misses an edit
4
+ * lets a stale image pass as fresh, which is the bug this exists to catch; one
5
+ * that changes on every unrelated edit produces a warning nobody reads, which
6
+ * is the same outcome by a different route.
7
+ *
8
+ * So each test here edits a real throwaway checkout and asserts which way the
9
+ * answer moved.
10
+ */
11
+
12
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
13
+ import { execFileSync } from 'node:child_process';
14
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import {
18
+ applyWorkingTree,
19
+ bakedSourcePaths,
20
+ computeSourceFingerprint,
21
+ fingerprintFrom,
22
+ readWorkingTreeEntries,
23
+ } from './source-fingerprint';
24
+
25
+ let repo: string;
26
+
27
+ function git(...args: string[]): void {
28
+ execFileSync('git', ['-C', repo, ...args], { stdio: 'ignore' });
29
+ }
30
+
31
+ function write(relative: string, content: string): void {
32
+ const full = join(repo, relative);
33
+ mkdirSync(join(full, '..'), { recursive: true });
34
+ writeFileSync(full, content);
35
+ }
36
+
37
+ beforeEach(() => {
38
+ repo = mkdtempSync(join(tmpdir(), 'fingerprint-repo-'));
39
+ write('apps/celilo/package.json', '{"name":"@celilo/cli","version":"1.0.0"}\n');
40
+ write('apps/celilo/src/index.ts', 'export const version = 1;\n');
41
+ write('packages/capabilities/src/index.ts', 'export const cap = 1;\n');
42
+ write('packages/capabilities/package.json', '{"name":"@celilo/capabilities"}\n');
43
+ write('packages/e2e/src/runner.ts', 'export const harness = 1;\n');
44
+ write('packages/e2e/package.json', '{"name":"@celilo/e2e"}\n');
45
+ git('init', '-q');
46
+ git('config', 'user.email', 'test@celilo.invalid');
47
+ git('config', 'user.name', 'test');
48
+ git('add', '-A');
49
+ git('commit', '-qm', 'initial');
50
+ });
51
+
52
+ afterEach(() => rmSync(repo, { recursive: true, force: true }));
53
+
54
+ describe('bakedSourcePaths', () => {
55
+ test('covers the app and every package, derived from the tree', () => {
56
+ const paths = bakedSourcePaths(repo);
57
+ expect(paths).toContain('apps/celilo/src');
58
+ expect(paths).toContain('apps/celilo/drizzle');
59
+ expect(paths).toContain('packages/capabilities/src');
60
+ expect(paths).toContain('packages/capabilities/package.json');
61
+ });
62
+
63
+ test('excludes packages/e2e, which runs from the worktree and is never stale', () => {
64
+ // Including it would fire the warning continuously for anyone working on
65
+ // the rig, which is the fastest way to teach people to ignore it.
66
+ expect(bakedSourcePaths(repo).some((p) => p.startsWith('packages/e2e'))).toBe(false);
67
+ });
68
+
69
+ test('a new package is covered the day it is added, without editing a list', () => {
70
+ write('packages/brand-new/src/index.ts', 'export const x = 1;\n');
71
+ expect(bakedSourcePaths(repo)).toContain('packages/brand-new/src');
72
+ });
73
+ });
74
+
75
+ describe('fingerprintFrom', () => {
76
+ test('is stable for the same inputs', () => {
77
+ const files = [{ path: 'a.ts', hash: 'abc' }];
78
+ expect(fingerprintFrom(files)).toBe(fingerprintFrom(files));
79
+ });
80
+
81
+ test('does not depend on the order git happened to list files in', () => {
82
+ const a = { path: 'a.ts', hash: 'aa' };
83
+ const b = { path: 'b.ts', hash: 'bb' };
84
+ expect(fingerprintFrom([a, b])).toBe(fingerprintFrom([b, a]));
85
+ });
86
+
87
+ test('a changed file content is a different fingerprint', () => {
88
+ expect(fingerprintFrom([{ path: 'a.ts', hash: 'aa' }])).not.toBe(
89
+ fingerprintFrom([{ path: 'a.ts', hash: 'zz' }]),
90
+ );
91
+ });
92
+
93
+ test('a removed file is a different fingerprint', () => {
94
+ expect(fingerprintFrom([{ path: 'a.ts', hash: 'aa' }])).not.toBe(fingerprintFrom([]));
95
+ });
96
+ });
97
+
98
+ describe('applyWorkingTree', () => {
99
+ const tracked = [
100
+ { path: 'a.ts', hash: 'indexed-a' },
101
+ { path: 'b.ts', hash: 'indexed-b' },
102
+ ];
103
+
104
+ test('an edit on disk overrides what the index holds', () => {
105
+ const out = applyWorkingTree(tracked, [
106
+ { status: ' M', path: 'a.ts', contentHash: 'ondisk-a' },
107
+ ]);
108
+ expect(out.find((f) => f.path === 'a.ts')?.hash).toBe('ondisk-a');
109
+ expect(out.find((f) => f.path === 'b.ts')?.hash).toBe('indexed-b');
110
+ });
111
+
112
+ test('an untracked file is added', () => {
113
+ const out = applyWorkingTree(tracked, [
114
+ { status: '??', path: 'c.ts', contentHash: 'ondisk-c' },
115
+ ]);
116
+ expect(out).toHaveLength(3);
117
+ });
118
+
119
+ test('a deleted file is removed, not recorded as empty', () => {
120
+ const out = applyWorkingTree(tracked, [{ status: ' D', path: 'a.ts', contentHash: '' }]);
121
+ expect(out.map((f) => f.path)).toEqual(['b.ts']);
122
+ });
123
+ });
124
+
125
+ describe('computeSourceFingerprint', () => {
126
+ test('a clean checkout fingerprints, and repeats', () => {
127
+ const first = computeSourceFingerprint(repo);
128
+ expect(first).not.toBeNull();
129
+ expect(computeSourceFingerprint(repo)).toBe(first as string);
130
+ });
131
+
132
+ test('an UNCOMMITTED edit to baked source moves it', () => {
133
+ // The case that matters most in practice: an agent edits the CLI, does not
134
+ // commit, runs a suite, and the image is a bake behind.
135
+ const before = computeSourceFingerprint(repo);
136
+ write('apps/celilo/src/index.ts', 'export const version = 2;\n');
137
+ expect(computeSourceFingerprint(repo)).not.toBe(before as string);
138
+ });
139
+
140
+ test('a DIFFERENT BRANCH with identical baked source fingerprints the same', () => {
141
+ // The reason this hashes content rather than HEAD. This machine runs seven
142
+ // agent worktrees on seven branches against one Docker daemon, and so one
143
+ // baked image. Most of those branches touch no CLI source at all; keying on
144
+ // the commit would make every one of them disagree with the image over an
145
+ // unrelated change, and a warning that is usually wrong is one nobody reads.
146
+ const onMain = computeSourceFingerprint(repo);
147
+ git('checkout', '-qb', 'some-other-branch');
148
+ write('README.md', '# a change that touches no baked source\n');
149
+ git('add', '-A');
150
+ git('commit', '-qm', 'unrelated work');
151
+ expect(computeSourceFingerprint(repo)).toBe(onMain as string);
152
+ });
153
+
154
+ test('a committed edit moves it too', () => {
155
+ const before = computeSourceFingerprint(repo);
156
+ write('apps/celilo/src/index.ts', 'export const version = 3;\n');
157
+ git('add', '-A');
158
+ git('commit', '-qm', 'change');
159
+ expect(computeSourceFingerprint(repo)).not.toBe(before as string);
160
+ });
161
+
162
+ test('a NEW untracked file under baked source moves it', () => {
163
+ const before = computeSourceFingerprint(repo);
164
+ write('apps/celilo/src/extra.ts', 'export const extra = 1;\n');
165
+ expect(computeSourceFingerprint(repo)).not.toBe(before as string);
166
+ });
167
+
168
+ test('an edit to packages/e2e does NOT move it', () => {
169
+ const before = computeSourceFingerprint(repo);
170
+ write('packages/e2e/src/runner.ts', 'export const harness = 99;\n');
171
+ expect(computeSourceFingerprint(repo)).toBe(before as string);
172
+ });
173
+
174
+ test('an edit outside the baked paths does NOT move it', () => {
175
+ const before = computeSourceFingerprint(repo);
176
+ write('README.md', '# unrelated\n');
177
+ expect(computeSourceFingerprint(repo)).toBe(before as string);
178
+ });
179
+
180
+ test('a directory that is not a celilo checkout has no fingerprint', () => {
181
+ expect(computeSourceFingerprint(mkdtempSync(join(tmpdir(), 'empty-')))).toBeNull();
182
+ });
183
+
184
+ test('no path at all has no fingerprint — an npm consumer has no source', () => {
185
+ expect(computeSourceFingerprint(undefined)).toBeNull();
186
+ });
187
+ });
188
+
189
+ describe('readWorkingTreeEntries', () => {
190
+ test('reports a modified file with its current content hash', () => {
191
+ write('apps/celilo/src/index.ts', 'export const version = 7;\n');
192
+ const entries = readWorkingTreeEntries(repo, ['apps/celilo/src']);
193
+ expect(entries).toHaveLength(1);
194
+ expect(entries[0].path).toBe('apps/celilo/src/index.ts');
195
+ expect(entries[0].contentHash).not.toBe('');
196
+ });
197
+
198
+ test('lists an untracked file individually, not collapsed to its directory', () => {
199
+ // Without -uall git reports `apps/celilo/src/` for a whole new subtree, and
200
+ // two different new files under it would fingerprint identically.
201
+ mkdirSync(join(repo, 'apps/celilo/src/nested'), { recursive: true });
202
+ write('apps/celilo/src/nested/one.ts', 'export const one = 1;\n');
203
+ const entries = readWorkingTreeEntries(repo, ['apps/celilo/src']);
204
+ expect(entries.map((e) => e.path)).toEqual(['apps/celilo/src/nested/one.ts']);
205
+ });
206
+
207
+ test('a deleted file is still a deviation, with no content to hash', () => {
208
+ rmSync(join(repo, 'apps/celilo/src/index.ts'));
209
+ const entries = readWorkingTreeEntries(repo, ['apps/celilo/src']);
210
+ expect(entries).toHaveLength(1);
211
+ expect(entries[0].contentHash).toBe('');
212
+ });
213
+ });
@@ -0,0 +1,212 @@
1
+ /**
2
+ * A fingerprint of the celilo source that `cele2e build-infra` bakes INTO
3
+ * `celilo-e2e/management:latest`, so a run can tell whether the image it is
4
+ * about to use was built from the tree it is testing.
5
+ *
6
+ * This exists because of a specific, measured hazard. The management image
7
+ * carries a real installed `celilo` CLI, produced by running install.sh against
8
+ * the simulated npm registry, which serves tarballs packed from THIS workspace.
9
+ * That CLI is the artifact under test. Nothing about it announces its age: a
10
+ * run against a month-old image looks exactly like a run against a fresh one,
11
+ * passes or fails for reasons that have nothing to do with the working tree,
12
+ * and sends the reader to the wrong file.
13
+ *
14
+ * The defence is to measure rather than to remember. build-infra stamps the
15
+ * fingerprint of what it packed onto the image as a label; `cele2e doctor`
16
+ * recomputes it from the working tree and says so when the two differ. That is
17
+ * a warning, never a refusal — an operator deliberately testing an older image
18
+ * is doing something legitimate, and should be told rather than stopped.
19
+ *
20
+ * Deliberately NOT covered: `packages/e2e` itself. The harness runs from the
21
+ * host worktree on every invocation (`bun` executes the checkout), so its
22
+ * source is never stale by construction, and including it would make the
23
+ * warning fire continuously for anyone working on the rig — the fastest way to
24
+ * teach people to ignore it.
25
+ *
26
+ * For the same reason the fingerprint is CONTENT, not commit. This machine runs
27
+ * seven agent worktrees on seven branches against one Docker daemon and
28
+ * therefore one baked image, and most of those branches do not touch the CLI at
29
+ * all. Hashing `HEAD` would make every one of them disagree with the image on
30
+ * the strength of an unrelated commit, and a warning that is usually wrong is
31
+ * one nobody reads. Hashing the bytes means a branch that changed no baked
32
+ * source agrees with the image, which is exactly what is true of it.
33
+ */
34
+
35
+ import { execFileSync } from 'node:child_process';
36
+ import { createHash } from 'node:crypto';
37
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
38
+ import { join } from 'node:path';
39
+
40
+ /**
41
+ * Docker label carrying the fingerprint. Reverse-DNS so it cannot collide with
42
+ * a label from a base image.
43
+ */
44
+ export const SOURCE_LABEL = 'computer.celilo.e2e.source';
45
+
46
+ /**
47
+ * Value stamped by `build-infra --published`, whose CLI comes from real npm
48
+ * rather than from this tree. There is no source to be stale against, so the
49
+ * freshness check reports the mode and stops.
50
+ */
51
+ export const PUBLISHED_FINGERPRINT_PREFIX = 'published:';
52
+
53
+ /**
54
+ * Value stamped by a CONSUMER-mode bake: `cele2e build-infra` run from an
55
+ * installed `@celilo/e2e` with no monorepo checkout. The CLI comes from the sim
56
+ * registry's freshly staged tarballs, so it is neither real npm nor this tree,
57
+ * and no tree fingerprint exists to record. The version is the honest
58
+ * attribution. Deliberately NOT the published prefix: that one tells the
59
+ * freshness check there is nothing to be stale against, which would suppress a
60
+ * real warning if such an image were later inspected from a checkout.
61
+ */
62
+ export const CONSUMER_FINGERPRINT_PREFIX = 'consumer:';
63
+
64
+ /**
65
+ * Repo-relative paths whose content reaches the baked CLI.
66
+ *
67
+ * Derived from the tree rather than listed, so a new `packages/<x>` is covered
68
+ * the day it is added — the failure mode of a hand-written list is that it
69
+ * covers what someone remembered, not what the repo contains (celilo#582).
70
+ */
71
+ export function bakedSourcePaths(repoRoot: string): string[] {
72
+ const paths = ['apps/celilo/src', 'apps/celilo/drizzle', 'apps/celilo/package.json'];
73
+ const packagesDir = join(repoRoot, 'packages');
74
+ if (existsSync(packagesDir)) {
75
+ for (const name of readdirSync(packagesDir).sort()) {
76
+ // See the header: the harness runs from the worktree, never from the image.
77
+ if (name === 'e2e') continue;
78
+ if (!statSync(join(packagesDir, name)).isDirectory()) continue;
79
+ for (const sub of ['src', 'package.json']) {
80
+ if (existsSync(join(packagesDir, name, sub))) paths.push(`packages/${name}/${sub}`);
81
+ }
82
+ }
83
+ }
84
+ // install.sh is the script the bake actually executes; a change to it changes
85
+ // what lands in the image even when no package source moved.
86
+ const installSh = 'modules/celilo-website/site/public/install.sh';
87
+ if (existsSync(join(repoRoot, installSh))) paths.push(installSh);
88
+ return paths;
89
+ }
90
+
91
+ /** One deviation of the working tree from the index, as `git status --porcelain` reports it. */
92
+ export interface WorkingTreeEntry {
93
+ /** Two-character porcelain status, e.g. ' M', '??', 'D '. */
94
+ status: string;
95
+ path: string;
96
+ /** sha256 of the file's current content, or '' when it no longer exists. */
97
+ contentHash: string;
98
+ }
99
+
100
+ /** A path and the hash of its current content. */
101
+ export interface FileHash {
102
+ path: string;
103
+ hash: string;
104
+ }
105
+
106
+ /**
107
+ * The fingerprint: every baked file paired with the hash of its bytes.
108
+ *
109
+ * Pure, so the composition is testable without a repo. Two checkouts holding
110
+ * the same baked source fingerprint identically no matter what branch, commit
111
+ * or edit history produced it; changing one byte of one baked file changes it.
112
+ * Entries are sorted here rather than trusted from the caller, so git's output
113
+ * order cannot change the answer.
114
+ */
115
+ export function fingerprintFrom(files: FileHash[]): string {
116
+ const hash = createHash('sha256');
117
+ for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
118
+ hash.update(`${file.path} ${file.hash}\n`);
119
+ }
120
+ return hash.digest('hex').slice(0, 16);
121
+ }
122
+
123
+ function git(repoRoot: string, args: string[]): string {
124
+ return execFileSync('git', ['-C', repoRoot, ...args], {
125
+ encoding: 'utf-8',
126
+ stdio: ['ignore', 'pipe', 'ignore'],
127
+ timeout: 15_000,
128
+ });
129
+ }
130
+
131
+ /**
132
+ * Read the working tree's deviations from HEAD across the baked paths.
133
+ *
134
+ * `--porcelain=v1 -z` is used because a path with a space in it is otherwise
135
+ * ambiguous, and `-uall` so an untracked FILE inside a tracked directory is
136
+ * listed individually rather than collapsed to its directory.
137
+ */
138
+ export function readWorkingTreeEntries(repoRoot: string, paths: string[]): WorkingTreeEntry[] {
139
+ const raw = git(repoRoot, ['status', '--porcelain=v1', '-z', '-uall', '--', ...paths]);
140
+ const entries: WorkingTreeEntry[] = [];
141
+ for (const record of raw.split('\0')) {
142
+ if (record.length < 4) continue;
143
+ const status = record.slice(0, 2);
144
+ const path = record.slice(3);
145
+ const abs = join(repoRoot, path);
146
+ let contentHash = '';
147
+ try {
148
+ if (existsSync(abs) && statSync(abs).isFile()) {
149
+ contentHash = createHash('sha256').update(readFileSync(abs)).digest('hex').slice(0, 16);
150
+ }
151
+ } catch {
152
+ // Unreadable is a deviation in itself; the status char still records it.
153
+ }
154
+ entries.push({ status, path, contentHash });
155
+ }
156
+ return entries;
157
+ }
158
+
159
+ /**
160
+ * Every tracked file under `paths`, paired with git's own blob hash for it.
161
+ *
162
+ * `git ls-files -s` reports the index, which is content-addressed and free —
163
+ * git has already hashed these. It is the working tree for every file that has
164
+ * not been edited since it was staged, and `readWorkingTreeEntries` corrects
165
+ * the rest.
166
+ */
167
+ export function readTrackedBlobs(repoRoot: string, paths: string[]): FileHash[] {
168
+ const raw = git(repoRoot, ['ls-files', '-s', '-z', '--', ...paths]);
169
+ const files: FileHash[] = [];
170
+ for (const record of raw.split('\0')) {
171
+ if (!record) continue;
172
+ // `<mode> <blob> <stage>\t<path>`
173
+ const [meta, path] = record.split('\t');
174
+ const blob = meta?.split(/\s+/)[1];
175
+ if (blob && path) files.push({ path, hash: blob });
176
+ }
177
+ return files;
178
+ }
179
+
180
+ /**
181
+ * Fold the working tree's deviations over the indexed blobs: an edited or
182
+ * untracked file contributes the hash of what is on disk now, a deleted one
183
+ * contributes nothing.
184
+ *
185
+ * Pure, so the precedence is testable without a repo.
186
+ */
187
+ export function applyWorkingTree(tracked: FileHash[], entries: WorkingTreeEntry[]): FileHash[] {
188
+ const byPath = new Map(tracked.map((f) => [f.path, f.hash]));
189
+ for (const entry of entries) {
190
+ if (entry.contentHash === '') byPath.delete(entry.path);
191
+ else byPath.set(entry.path, entry.contentHash);
192
+ }
193
+ return [...byPath].map(([path, hash]) => ({ path, hash }));
194
+ }
195
+
196
+ /**
197
+ * The current tree's fingerprint, or null when there is no repo to read — an
198
+ * npm-installed consumer has no celilo checkout, and has nothing to be stale
199
+ * against.
200
+ */
201
+ export function computeSourceFingerprint(repoRoot: string | undefined): string | null {
202
+ if (!repoRoot || !existsSync(join(repoRoot, 'apps', 'celilo', 'package.json'))) return null;
203
+ try {
204
+ const paths = bakedSourcePaths(repoRoot);
205
+ return fingerprintFrom(
206
+ applyWorkingTree(readTrackedBlobs(repoRoot, paths), readWorkingTreeEntries(repoRoot, paths)),
207
+ );
208
+ } catch {
209
+ // Not a git checkout (a tarball extraction, say). Nothing to compare.
210
+ return null;
211
+ }
212
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Stage the two simulator inputs the DEFAULT bake's install path depends on:
3
+ *
4
+ * .celilo-website-cache/ ← modules/celilo-website/site/dist/ — what the
5
+ * celilo-website-sim COPYs, i.e. where install.sh
6
+ * is served from.
7
+ * .npm-registry-cache/ ← bun pm pack of each @celilo/* workspace pkg —
8
+ * what the npm-registry-sim COPYs, i.e. what
9
+ * install.sh's `bun add -g` installs.
10
+ *
11
+ * `cele2e build` (cli/build.ts stageSimulatorInputs) stages these PLUS the
12
+ * apt-repo and libsignal inputs, which nothing in the bake's install path
13
+ * reads. The bake calls the two functions here directly so a standalone
14
+ * `e2e-bake-management` re-stages exactly what it is about to install, and
15
+ * not the debs too — without this, the bake reinstalls whatever tarballs the
16
+ * last full build left in the cache, silently shipping a CLI without the
17
+ * tree's changes (celilo#1299).
18
+ *
19
+ * Both are .gitignored build outputs, and both images COPY them at docker
20
+ * build time — re-staging alone does nothing until the sim images are
21
+ * rebuilt, which is the bake caller's job.
22
+ */
23
+
24
+ import { spawnSync } from 'node:child_process';
25
+ import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
26
+ import { join } from 'node:path';
27
+
28
+ const dim = '\x1b[2m';
29
+ const green = '\x1b[32m';
30
+ const red = '\x1b[31m';
31
+ const reset = '\x1b[0m';
32
+
33
+ /**
34
+ * Build the celilo-website static site and stage its dist/ into
35
+ * `<pkgDir>/.celilo-website-cache/`. Throws (does not exit) so library
36
+ * callers — the bake — fail their own run with context rather than killing
37
+ * the process mid-flight.
38
+ */
39
+ export function stageWebsiteDist(repoRoot: string, pkgDir: string): void {
40
+ const websiteSrc = join(repoRoot, 'modules', 'celilo-website', 'site');
41
+ const websiteCache = join(pkgDir, '.celilo-website-cache');
42
+ if (!existsSync(websiteSrc)) return;
43
+
44
+ process.stdout.write(` ${'celilo-website (build)'.padEnd(28)} `);
45
+ const t0 = Date.now();
46
+ let result = spawnSync('bun', ['install'], { cwd: websiteSrc, stdio: 'pipe' });
47
+ if (result.status !== 0) {
48
+ console.log(`${red}✗${reset}`);
49
+ throw new Error(`bun install in ${websiteSrc} failed:\n${result.stderr?.toString()}`);
50
+ }
51
+ result = spawnSync('bun', ['run', 'build'], { cwd: websiteSrc, stdio: 'pipe' });
52
+ if (result.status !== 0) {
53
+ console.log(`${red}✗${reset}`);
54
+ throw new Error(`bun run build in ${websiteSrc} failed:\n${result.stderr?.toString()}`);
55
+ }
56
+ console.log(`${green}✔${reset} ${dim}${Math.round((Date.now() - t0) / 1000)}s${reset}`);
57
+
58
+ process.stdout.write(` ${'celilo-website (stage)'.padEnd(28)} `);
59
+ rmSync(websiteCache, { recursive: true, force: true });
60
+ mkdirSync(websiteCache, { recursive: true });
61
+ cpSync(join(websiteSrc, 'dist'), websiteCache, { recursive: true });
62
+ console.log(`${green}✔${reset}`);
63
+ }
64
+
65
+ /**
66
+ * Pack the @celilo/* workspace packages into `<pkgDir>/.npm-registry-cache/`
67
+ * by running scripts/pack-celilo-packages.ts, whose manifest records the tree
68
+ * fingerprint the tarballs were packed from (see that script). Re-runs are
69
+ * cheap and always clean the destination first, so the cache can never mix
70
+ * tarballs from two trees.
71
+ */
72
+ export function packNpmRegistryTarballs(repoRoot: string, pkgDir: string): void {
73
+ const packScript = join(pkgDir, 'scripts', 'pack-celilo-packages.ts');
74
+ if (!existsSync(packScript)) return;
75
+
76
+ process.stdout.write(` ${'npm-registry (pack)'.padEnd(28)} `);
77
+ const t0 = Date.now();
78
+ const npmCache = join(pkgDir, '.npm-registry-cache');
79
+ const result = spawnSync('bun', ['run', packScript, `--dest=${npmCache}`], {
80
+ cwd: repoRoot,
81
+ stdio: 'pipe',
82
+ });
83
+ if (result.status !== 0) {
84
+ console.log(`${red}✗${reset}`);
85
+ throw new Error(`pack-celilo-packages failed:\n${result.stderr?.toString()}`);
86
+ }
87
+ const tarballCount = existsSync(npmCache)
88
+ ? readdirSync(npmCache).filter((f) => f.endsWith('.tgz')).length
89
+ : 0;
90
+ console.log(
91
+ `${green}✔${reset} ${dim}${Math.round((Date.now() - t0) / 1000)}s, ${tarballCount} tarball(s)${reset}`,
92
+ );
93
+ }
package/src/stages.ts ADDED
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Staged e2e suites, as one shared mechanism.
3
+ *
4
+ * A staged suite is a pipeline: stage 1 builds the fixture, later stages assert
5
+ * against it, and every stage after the first is guarded so one failure skips
6
+ * the rest instead of reporting a cascade of fake failures.
7
+ *
8
+ * Every suite used to re-declare that guard by hand: a `stageError` variable
9
+ * set in each stage's catch, and a `requireStage` check at the top of every
10
+ * later stage. The copies had a hole (celilo#1272). A stage that TIMED OUT
11
+ * never reached its own catch, because bun aborts the test at its cap.
12
+ * stageError stayed null, requireStage did not fire, and every later stage ran
13
+ * against a half-built fixture. The downstream error was louder than the cause
14
+ * and named the wrong stage.
15
+ *
16
+ * This module closes the hole. `stage()` wraps bun's test() and tracks each
17
+ * stage through three endings:
18
+ *
19
+ * resolved -> completed; the next stage runs
20
+ * rejected -> failed; the FIRST failure's message is recorded, and every
21
+ * later stage reports `Skipped: ...`
22
+ * aborted -> the stage body never settled. This is what a bun timeout looks
23
+ * like from inside: no catch, no finally, no code after the
24
+ * await. The stage stays pending, and the next requireStage sees
25
+ * a pending stage and skips.
26
+ *
27
+ * The `Skipped:` prefix is load-bearing: packages/e2e/src/extract-failure.ts
28
+ * keys on it to split real failures from cascade-skips in the run summary.
29
+ * Several hand-rolled copies used messages without the colon, so their
30
+ * cascade-skips were tallied as real failures. One message fixes that too.
31
+ *
32
+ * State lives in the closure `createStages()` returns, not in module scope. One
33
+ * bun process runs every test file in a suite, so a module-level tracker would
34
+ * leak one suite's failure into the next file's first stage.
35
+ */
36
+
37
+ import { test } from 'bun:test';
38
+
39
+ /**
40
+ * The stage lifecycle state machine, without bun. You almost certainly want
41
+ * `createStages()`, which drives this for you. Exposed separately so its
42
+ * contract (especially the aborted ending) has unit tests that do not need to
43
+ * spawn a real timed-out test.
44
+ */
45
+ export interface StageGuard {
46
+ /** Marks a stage begun. Throws if a prior stage is still pending. */
47
+ begin(name: string): void;
48
+ /** Marks the pending stage completed successfully. */
49
+ ok(): void;
50
+ /** Marks the pending stage failed. The first failure's message wins. */
51
+ fail(error: unknown): void;
52
+ /**
53
+ * Throws `Skipped: <label> — <reason>` unless every stage so far completed.
54
+ * The reason names the first failure, or a stage that never completed.
55
+ */
56
+ requireStage(label: string): void;
57
+ }
58
+
59
+ export function createStageGuard(): StageGuard {
60
+ let stageError: string | null = null;
61
+ let pending: string | null = null;
62
+
63
+ return {
64
+ begin(name) {
65
+ if (pending !== null) {
66
+ throw new Error(
67
+ `stage "${name}" begun while "${pending}" is still pending — a stage never completed`,
68
+ );
69
+ }
70
+ pending = name;
71
+ },
72
+
73
+ ok() {
74
+ pending = null;
75
+ },
76
+
77
+ fail(error) {
78
+ pending = null;
79
+ stageError ??= error instanceof Error ? error.message : String(error);
80
+ },
81
+
82
+ requireStage(label) {
83
+ if (stageError !== null) {
84
+ throw new Error(`Skipped: ${label} — ${stageError}`);
85
+ }
86
+ if (pending !== null) {
87
+ throw new Error(
88
+ `Skipped: ${label} — stage "${pending}" never completed (aborted mid-flight, almost certainly a test timeout)`,
89
+ );
90
+ }
91
+ },
92
+ };
93
+ }
94
+
95
+ /** The bun adapter: registers stages as tests with cascade semantics. */
96
+ export interface Stages {
97
+ /**
98
+ * Registers a stage as a bun test. On failure the remaining stages skip; on
99
+ * a bun timeout (the body never settles) they skip too. Pass the same
100
+ * per-stage timeout you passed to test().
101
+ */
102
+ stage(name: string, fn: () => void | Promise<void>, timeoutMs?: number): void;
103
+ /** For a plain test() that should skip like a stage when the pipeline broke. */
104
+ requireStage(label: string): void;
105
+ }
106
+
107
+ export function createStages(): Stages {
108
+ const guard = createStageGuard();
109
+
110
+ return {
111
+ stage(name, fn, timeoutMs) {
112
+ test(
113
+ name,
114
+ async () => {
115
+ guard.requireStage(name);
116
+ guard.begin(name);
117
+ try {
118
+ await fn();
119
+ guard.ok();
120
+ } catch (err) {
121
+ guard.fail(err);
122
+ throw err;
123
+ }
124
+ },
125
+ timeoutMs,
126
+ );
127
+ },
128
+
129
+ requireStage(label) {
130
+ guard.requireStage(label);
131
+ },
132
+ };
133
+ }
@@ -129,5 +129,6 @@ export async function waitForRunCompletion(opts: WaitOptions): Promise<WaitResul
129
129
  }
130
130
 
131
131
  function sleep(ms: number): Promise<void> {
132
+ // e2e-sleep-ok: poll cadence for waitForRunCompletion, which polls an observable condition.
132
133
  return new Promise((r) => setTimeout(r, ms));
133
134
  }