@celilo/e2e 0.19.3 → 0.20.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.
- package/README.md +30 -13
- package/bin/e2e-bake-management +171 -12
- package/bin/e2e-infra +0 -1
- package/bin/e2e-up +14 -3
- package/docker/Dockerfile.observer +12 -1
- package/docker/Dockerfile.target-machine +22 -1
- package/npm-registry-server/package.json +1 -1
- package/package.json +3 -3
- package/registry-server/package.json +1 -1
- package/scripts/pack-celilo-packages.ts +15 -0
- package/src/block-timing.test.ts +559 -0
- package/src/block-timing.ts +366 -0
- package/src/cli/build.test.ts +54 -4
- package/src/cli/build.ts +204 -88
- package/src/cli/command-registry.ts +21 -0
- package/src/cli/command-tree-parser.ts +11 -2
- package/src/cli/completion.ts +9 -0
- package/src/cli/host.ts +252 -0
- package/src/cli/index.ts +78 -51
- package/src/cli/module-discovery.ts +108 -13
- package/src/cli/scaffold.ts +18 -26
- package/src/container-manager.cleanup.test.ts +284 -0
- package/src/container-manager.runner.test.ts +351 -0
- package/src/container-manager.test.ts +84 -0
- package/src/container-manager.ts +721 -185
- package/src/docker-compose-generator.ts +135 -61
- package/src/doctor.test.ts +259 -4
- package/src/doctor.ts +276 -3
- package/src/exit-cleanup.test.ts +83 -1
- package/src/fleet-nameserver-gate.test.ts +45 -0
- package/src/host-vm.test.ts +156 -0
- package/src/host-vm.ts +230 -0
- package/src/index.ts +11 -0
- package/src/live-stack.test.ts +184 -0
- package/src/live-stack.ts +145 -0
- package/src/no-unjustified-sleep.test.ts +90 -0
- package/src/proxmox-provisioner.test.ts +18 -2
- package/src/proxmox-provisioner.ts +22 -0
- package/src/public-sim-routes.test.ts +9 -2
- package/src/repo-root.ts +33 -0
- package/src/run-args.test.ts +76 -0
- package/src/run-args.ts +89 -0
- package/src/runner.ts +213 -8
- package/src/shared-infra.ts +83 -32
- package/src/socks-proxy.ts +2 -0
- package/src/source-fingerprint.test.ts +213 -0
- package/src/source-fingerprint.ts +201 -0
- package/src/stage-simulator-inputs.ts +93 -0
- package/src/stages.ts +133 -0
- 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,201 @@
|
|
|
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
|
+
* Repo-relative paths whose content reaches the baked CLI.
|
|
55
|
+
*
|
|
56
|
+
* Derived from the tree rather than listed, so a new `packages/<x>` is covered
|
|
57
|
+
* the day it is added — the failure mode of a hand-written list is that it
|
|
58
|
+
* covers what someone remembered, not what the repo contains (celilo#582).
|
|
59
|
+
*/
|
|
60
|
+
export function bakedSourcePaths(repoRoot: string): string[] {
|
|
61
|
+
const paths = ['apps/celilo/src', 'apps/celilo/drizzle', 'apps/celilo/package.json'];
|
|
62
|
+
const packagesDir = join(repoRoot, 'packages');
|
|
63
|
+
if (existsSync(packagesDir)) {
|
|
64
|
+
for (const name of readdirSync(packagesDir).sort()) {
|
|
65
|
+
// See the header: the harness runs from the worktree, never from the image.
|
|
66
|
+
if (name === 'e2e') continue;
|
|
67
|
+
if (!statSync(join(packagesDir, name)).isDirectory()) continue;
|
|
68
|
+
for (const sub of ['src', 'package.json']) {
|
|
69
|
+
if (existsSync(join(packagesDir, name, sub))) paths.push(`packages/${name}/${sub}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// install.sh is the script the bake actually executes; a change to it changes
|
|
74
|
+
// what lands in the image even when no package source moved.
|
|
75
|
+
const installSh = 'modules/celilo-website/site/public/install.sh';
|
|
76
|
+
if (existsSync(join(repoRoot, installSh))) paths.push(installSh);
|
|
77
|
+
return paths;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** One deviation of the working tree from the index, as `git status --porcelain` reports it. */
|
|
81
|
+
export interface WorkingTreeEntry {
|
|
82
|
+
/** Two-character porcelain status, e.g. ' M', '??', 'D '. */
|
|
83
|
+
status: string;
|
|
84
|
+
path: string;
|
|
85
|
+
/** sha256 of the file's current content, or '' when it no longer exists. */
|
|
86
|
+
contentHash: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A path and the hash of its current content. */
|
|
90
|
+
export interface FileHash {
|
|
91
|
+
path: string;
|
|
92
|
+
hash: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The fingerprint: every baked file paired with the hash of its bytes.
|
|
97
|
+
*
|
|
98
|
+
* Pure, so the composition is testable without a repo. Two checkouts holding
|
|
99
|
+
* the same baked source fingerprint identically no matter what branch, commit
|
|
100
|
+
* or edit history produced it; changing one byte of one baked file changes it.
|
|
101
|
+
* Entries are sorted here rather than trusted from the caller, so git's output
|
|
102
|
+
* order cannot change the answer.
|
|
103
|
+
*/
|
|
104
|
+
export function fingerprintFrom(files: FileHash[]): string {
|
|
105
|
+
const hash = createHash('sha256');
|
|
106
|
+
for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
|
|
107
|
+
hash.update(`${file.path} ${file.hash}\n`);
|
|
108
|
+
}
|
|
109
|
+
return hash.digest('hex').slice(0, 16);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function git(repoRoot: string, args: string[]): string {
|
|
113
|
+
return execFileSync('git', ['-C', repoRoot, ...args], {
|
|
114
|
+
encoding: 'utf-8',
|
|
115
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
116
|
+
timeout: 15_000,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Read the working tree's deviations from HEAD across the baked paths.
|
|
122
|
+
*
|
|
123
|
+
* `--porcelain=v1 -z` is used because a path with a space in it is otherwise
|
|
124
|
+
* ambiguous, and `-uall` so an untracked FILE inside a tracked directory is
|
|
125
|
+
* listed individually rather than collapsed to its directory.
|
|
126
|
+
*/
|
|
127
|
+
export function readWorkingTreeEntries(repoRoot: string, paths: string[]): WorkingTreeEntry[] {
|
|
128
|
+
const raw = git(repoRoot, ['status', '--porcelain=v1', '-z', '-uall', '--', ...paths]);
|
|
129
|
+
const entries: WorkingTreeEntry[] = [];
|
|
130
|
+
for (const record of raw.split('\0')) {
|
|
131
|
+
if (record.length < 4) continue;
|
|
132
|
+
const status = record.slice(0, 2);
|
|
133
|
+
const path = record.slice(3);
|
|
134
|
+
const abs = join(repoRoot, path);
|
|
135
|
+
let contentHash = '';
|
|
136
|
+
try {
|
|
137
|
+
if (existsSync(abs) && statSync(abs).isFile()) {
|
|
138
|
+
contentHash = createHash('sha256').update(readFileSync(abs)).digest('hex').slice(0, 16);
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
// Unreadable is a deviation in itself; the status char still records it.
|
|
142
|
+
}
|
|
143
|
+
entries.push({ status, path, contentHash });
|
|
144
|
+
}
|
|
145
|
+
return entries;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Every tracked file under `paths`, paired with git's own blob hash for it.
|
|
150
|
+
*
|
|
151
|
+
* `git ls-files -s` reports the index, which is content-addressed and free —
|
|
152
|
+
* git has already hashed these. It is the working tree for every file that has
|
|
153
|
+
* not been edited since it was staged, and `readWorkingTreeEntries` corrects
|
|
154
|
+
* the rest.
|
|
155
|
+
*/
|
|
156
|
+
export function readTrackedBlobs(repoRoot: string, paths: string[]): FileHash[] {
|
|
157
|
+
const raw = git(repoRoot, ['ls-files', '-s', '-z', '--', ...paths]);
|
|
158
|
+
const files: FileHash[] = [];
|
|
159
|
+
for (const record of raw.split('\0')) {
|
|
160
|
+
if (!record) continue;
|
|
161
|
+
// `<mode> <blob> <stage>\t<path>`
|
|
162
|
+
const [meta, path] = record.split('\t');
|
|
163
|
+
const blob = meta?.split(/\s+/)[1];
|
|
164
|
+
if (blob && path) files.push({ path, hash: blob });
|
|
165
|
+
}
|
|
166
|
+
return files;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Fold the working tree's deviations over the indexed blobs: an edited or
|
|
171
|
+
* untracked file contributes the hash of what is on disk now, a deleted one
|
|
172
|
+
* contributes nothing.
|
|
173
|
+
*
|
|
174
|
+
* Pure, so the precedence is testable without a repo.
|
|
175
|
+
*/
|
|
176
|
+
export function applyWorkingTree(tracked: FileHash[], entries: WorkingTreeEntry[]): FileHash[] {
|
|
177
|
+
const byPath = new Map(tracked.map((f) => [f.path, f.hash]));
|
|
178
|
+
for (const entry of entries) {
|
|
179
|
+
if (entry.contentHash === '') byPath.delete(entry.path);
|
|
180
|
+
else byPath.set(entry.path, entry.contentHash);
|
|
181
|
+
}
|
|
182
|
+
return [...byPath].map(([path, hash]) => ({ path, hash }));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The current tree's fingerprint, or null when there is no repo to read — an
|
|
187
|
+
* npm-installed consumer has no celilo checkout, and has nothing to be stale
|
|
188
|
+
* against.
|
|
189
|
+
*/
|
|
190
|
+
export function computeSourceFingerprint(repoRoot: string | undefined): string | null {
|
|
191
|
+
if (!repoRoot || !existsSync(join(repoRoot, 'apps', 'celilo', 'package.json'))) return null;
|
|
192
|
+
try {
|
|
193
|
+
const paths = bakedSourcePaths(repoRoot);
|
|
194
|
+
return fingerprintFrom(
|
|
195
|
+
applyWorkingTree(readTrackedBlobs(repoRoot, paths), readWorkingTreeEntries(repoRoot, paths)),
|
|
196
|
+
);
|
|
197
|
+
} catch {
|
|
198
|
+
// Not a git checkout (a tarball extraction, say). Nothing to compare.
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -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
|
+
}
|
package/src/wait-for-run.ts
CHANGED
|
@@ -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
|
}
|