@hasna/recordings 0.2.13 → 0.3.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.
- package/Dockerfile.package +4 -5
- package/README.md +113 -8
- package/bun.lock +2 -2
- package/dist/__tests__/helpers/installer-guard-execution.d.ts +22 -0
- package/dist/__tests__/helpers/installer-guard-execution.d.ts.map +1 -0
- package/dist/__tests__/helpers/installer-preflight.d.ts +22 -0
- package/dist/__tests__/helpers/installer-preflight.d.ts.map +1 -0
- package/dist/__tests__/helpers/native-fs-guard.d.ts.map +1 -1
- package/dist/__tests__/helpers/source-assertions.d.ts +169 -0
- package/dist/__tests__/helpers/source-assertions.d.ts.map +1 -0
- package/dist/__tests__/preload.d.ts +1 -4
- package/dist/__tests__/preload.d.ts.map +1 -1
- package/dist/cli/desktop-snapshot.d.ts +20 -0
- package/dist/cli/desktop-snapshot.d.ts.map +1 -0
- package/dist/cli/index.js +2078 -277
- package/dist/cli/macos-permissions.d.ts +72 -0
- package/dist/cli/macos-permissions.d.ts.map +1 -1
- package/dist/cli/macos-shortcut.d.ts +139 -0
- package/dist/cli/macos-shortcut.d.ts.map +1 -0
- package/dist/cli/trigger-probe.d.ts +172 -0
- package/dist/cli/trigger-probe.d.ts.map +1 -0
- package/dist/db/database.d.ts +9 -0
- package/dist/db/database.d.ts.map +1 -1
- package/dist/http/client.d.ts +10 -7
- package/dist/http/client.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +634 -49
- package/dist/lib/capture-probe.d.ts +216 -0
- package/dist/lib/capture-probe.d.ts.map +1 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/macos-bundle.d.ts +50 -0
- package/dist/lib/macos-bundle.d.ts.map +1 -0
- package/dist/lib/persistence-probe.d.ts +208 -0
- package/dist/lib/persistence-probe.d.ts.map +1 -0
- package/dist/lib/recorder.d.ts.map +1 -1
- package/dist/lib/retired-deployment-modes.d.ts +24 -0
- package/dist/lib/retired-deployment-modes.d.ts.map +1 -0
- package/dist/lib/transcriber.d.ts +60 -0
- package/dist/lib/transcriber.d.ts.map +1 -1
- package/dist/mcp/index.js +140 -56
- package/dist/server/cloud-config.d.ts +25 -3
- package/dist/server/cloud-config.d.ts.map +1 -1
- package/dist/server/cloud-readiness.d.ts.map +1 -1
- package/dist/server/cloud.d.ts +8 -7
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.js +187 -79
- package/dist/server/migrate-command.d.ts.map +1 -1
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/storage.d.ts +2 -2
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +108 -49
- package/dist/store.d.ts +1 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/package.json +25 -6
- package/scripts/build_native_fs_guard.sh +15 -2
- package/scripts/ci-linux-suite.ts +403 -0
- package/scripts/ci-native-build.ts +402 -0
- package/scripts/enforce_identity_migration.sh +110 -0
- package/scripts/generate-sdk.ts +1 -1
- package/scripts/install_macos_app.sh +138 -24
- package/scripts/macos_artifact.ts +205 -33
- package/scripts/native/realtime-settle-probe/README.md +39 -0
- package/scripts/native/realtime-settle-probe/main.swift +135 -0
- package/scripts/policy/local-only-approved-targets.txt +21 -0
- package/scripts/read_local_only_targets.sh +203 -0
- package/scripts/scan-artifact.ts +40 -0
- package/scripts/set-version.ts +145 -0
- package/scripts/vacuity-manifest-gen.ts +96 -0
- package/scripts/vacuity-manifests/enhancement-screen-mirror.tsv +44 -0
- package/scripts/vacuity-manifests/install-chain.tsv +13 -0
- package/scripts/vacuity-manifests/must-survive-controls.tsv +10 -0
- package/scripts/vacuity-manifests/reorder.tsv +4 -0
- package/scripts/vacuity-manifests/source-side.tsv +15 -0
- package/scripts/vacuity-manifests/variable-operands.tsv +16 -0
- package/scripts/vacuity-manifests/version-sites.tsv +61 -0
- package/scripts/vacuity-mutation-battery.ts +266 -0
- package/src/native/Recordings/App/MenuBarStatusView.swift +14 -7
- package/src/native/Recordings/App/RuntimeSmoke.swift +5 -1
- package/src/native/Recordings/App/SidebarView.swift +1 -1
- package/src/native/Recordings/Package.swift +24 -5
- package/src/native/Recordings/RecordingsLib/EnhancementScreen.swift +91 -0
- package/src/native/Recordings/RecordingsLib/FnKeyMonitor.swift +27 -4
- package/src/native/Recordings/RecordingsLib/Info.plist +2 -2
- package/src/native/Recordings/RecordingsLib/MenuBarPresentation.swift +56 -3
- package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +13 -0
- package/src/native/Recordings/RecordingsLib/PasteDeliveryVerification.swift +460 -0
- package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +41 -7
- package/src/native/Recordings/RecordingsLib/RecordingAttemptAlert.swift +36 -0
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +1071 -75
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +47 -3
- package/src/native/Recordings/RecordingsLib/VoiceShortcuts.swift +11 -6
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +3 -3
- package/src/native/Recordings/RecordingsTests/EnhancementScreenTests.swift +191 -0
- package/src/native/Recordings/RecordingsTests/MenuBarPresentationTests.swift +144 -4
- package/src/native/Recordings/RecordingsTests/PasteDeliveryVerificationTests.swift +463 -0
- package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +33 -20
- package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +1 -1
- package/src/native/Recordings/RecordingsTests/RealtimeSettleBenchmark.swift +200 -0
- package/src/native/Recordings/RecordingsTests/RealtimeSettleBudgetTests.swift +80 -0
- package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +22 -4
- package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +12 -6
- package/src/native/Recordings/RecordingsTests/RecordingEngineHomeIsolationTests.swift +67 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +12 -3
- package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +207 -11
- package/src/native/Recordings/RecordingsTests/SpeechIntentTests.swift +17 -6
- package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +23 -0
- package/src/native/Recordings/Updater/Broker/ApplicationProcessQuiescence.swift +7 -1
- package/src/native/Recordings/Updater/Broker/AtomicActivation.swift +10 -10
- package/src/native/Recordings/Updater/Broker/BrokerMain.swift +5 -1
- package/src/native/Recordings/Updater/Broker/HostOSProductVersion.swift +1 -0
- package/src/native/Recordings/Updater/Broker/PeerIdentity.swift +59 -3
- package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +21 -2
- package/src/native/Recordings/Updater/ProtocolTests/ExpectationIntegrityTests.swift +60 -0
- package/src/native/Recordings/Updater/ProtocolTests/UpdateXPCInterfaceTests.swift +113 -0
- package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +106 -5
- package/src/native/Recordings/build.sh +75 -9
- package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Partition the `bun test` suite into the Linux-GATED set and the Linux-QUARANTINED set, and prove
|
|
4
|
+
* afterwards that the run actually honoured the partition.
|
|
5
|
+
*
|
|
6
|
+
* Why this is a script with its own contract test rather than a glob in the workflow YAML:
|
|
7
|
+
*
|
|
8
|
+
* A gate that names its files explicitly stops covering anything added after it was written, and
|
|
9
|
+
* a gate that silently narrows its own file list is the same vacuity class this repo spent a day
|
|
10
|
+
* closing in its assertions — a green check over almost nothing looks exactly like a green check
|
|
11
|
+
* over everything. So discovery is DYNAMIC (a new `*.test.ts` is gated the moment it lands) and
|
|
12
|
+
* the narrowing is verified after the fact against the runner's own output.
|
|
13
|
+
*
|
|
14
|
+
* Three independent facts are cross-checked, because any one of them alone can fail silently:
|
|
15
|
+
*
|
|
16
|
+
* 1. `git ls-files` and a filesystem walk must agree on the set of test files. A walker with a
|
|
17
|
+
* broken suffix check, or a suite that was never `git add`ed, shows up as a disagreement
|
|
18
|
+
* instead of as a quietly smaller gate.
|
|
19
|
+
* 2. Every quarantine entry must name a DISCOVERED test file. A renamed or deleted suite cannot
|
|
20
|
+
* keep its exemption; the entry goes stale and `--check` fails.
|
|
21
|
+
* 3. After the gated run, the number of files the runner reports must equal the gated count, and
|
|
22
|
+
* no quarantined file may appear as an executed file. `bun test` treats its positional
|
|
23
|
+
* arguments as SUBSTRING filters rather than paths, so "I passed it a list" is not evidence
|
|
24
|
+
* that it ran that list — only its own output is.
|
|
25
|
+
*
|
|
26
|
+
* Counts of passing/failing TESTS are deliberately never asserted. The suite is nondeterministic at
|
|
27
|
+
* the margin (subprocess and FIFO timeouts), so any count is a flake generator; see
|
|
28
|
+
* src/__tests__/helpers/source-assertions.ts. Only set membership and directions are gated.
|
|
29
|
+
*
|
|
30
|
+
* Usage:
|
|
31
|
+
* bun scripts/ci-linux-suite.ts --check validate the quarantine file, print counts
|
|
32
|
+
* bun scripts/ci-linux-suite.ts --gated newline-separated gated files
|
|
33
|
+
* bun scripts/ci-linux-suite.ts --quarantined newline-separated quarantined files
|
|
34
|
+
* bun scripts/ci-linux-suite.ts --verify-run <log> assert a gated run honoured the partition
|
|
35
|
+
*/
|
|
36
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
37
|
+
import { join, relative, sep } from "node:path";
|
|
38
|
+
import { spawnSync } from "node:child_process";
|
|
39
|
+
|
|
40
|
+
/** Repo-relative path of the quarantine list, relative to the repository root. */
|
|
41
|
+
export const QUARANTINE_FILE = ".github/linux-quarantine.txt";
|
|
42
|
+
|
|
43
|
+
const TEST_SUFFIX = ".test.ts";
|
|
44
|
+
|
|
45
|
+
/** Directories never worth walking. `.build` holds SwiftPM output, which can be enormous. */
|
|
46
|
+
const SKIPPED_DIRS = new Set([".git", "node_modules", ".build", "dist"]);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parse the quarantine list.
|
|
50
|
+
*
|
|
51
|
+
* Blank lines and `#` comments are dropped. Leading and trailing whitespace is stripped so a stray
|
|
52
|
+
* indent cannot produce an entry that matches no discovered file and reads as a typo in the list
|
|
53
|
+
* rather than as the misformatting it is.
|
|
54
|
+
*/
|
|
55
|
+
export function parseQuarantine(text: string): string[] {
|
|
56
|
+
return text
|
|
57
|
+
.split("\n")
|
|
58
|
+
.map((line) => line.trim())
|
|
59
|
+
.filter((line) => line.length > 0 && !line.startsWith("#"));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Entries not directly preceded by a `# reason:` line.
|
|
64
|
+
*
|
|
65
|
+
* An exemption without a stated reason cannot be told apart from a suite somebody muted to get a
|
|
66
|
+
* green check, which is the whole failure mode this file guards. Implemented in the script rather
|
|
67
|
+
* than in the test so that it is enforced whenever an entry is ADDED — the list is currently empty,
|
|
68
|
+
* and a check living only in a test runs against the list as it stands today.
|
|
69
|
+
*
|
|
70
|
+
* The marker is required rather than accepting any comment, and that is not pedantry: the first
|
|
71
|
+
* version accepted "the line above starts with #", which the explanatory header at the top of the
|
|
72
|
+
* file satisfies. Appending an entry to the end of that header therefore passed with no reason of
|
|
73
|
+
* its own — found by running the negative control rather than by reading the code.
|
|
74
|
+
*/
|
|
75
|
+
const REASON_MARKER = /^#\s*reason:\s*\S/;
|
|
76
|
+
|
|
77
|
+
export function entriesMissingReason(text: string): string[] {
|
|
78
|
+
const lines = text.split("\n").map((line) => line.trim());
|
|
79
|
+
const missing: string[] = [];
|
|
80
|
+
lines.forEach((line, index) => {
|
|
81
|
+
if (line.length === 0 || line.startsWith("#")) return;
|
|
82
|
+
if (!REASON_MARKER.test(lines[index - 1] ?? "")) missing.push(line);
|
|
83
|
+
});
|
|
84
|
+
return missing;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Enumerate tracked test files with git.
|
|
89
|
+
*
|
|
90
|
+
* git is the authoritative enumeration in CI, where the checkout contains tracked files and
|
|
91
|
+
* nothing else. It is also the one enumeration that cannot be fooled by a leftover build artifact
|
|
92
|
+
* in the working tree.
|
|
93
|
+
*/
|
|
94
|
+
export function testFilesFromGit(repoRoot: string): string[] {
|
|
95
|
+
const result = spawnSync("git", ["-C", repoRoot, "ls-files", "-z", `*${TEST_SUFFIX}`], {
|
|
96
|
+
encoding: "utf8",
|
|
97
|
+
});
|
|
98
|
+
if (result.status !== 0) {
|
|
99
|
+
throw new Error(`git ls-files failed (${result.status}): ${result.stderr?.trim() ?? ""}`);
|
|
100
|
+
}
|
|
101
|
+
// -z because a path may legally contain a newline, and a newline-split enumeration would report
|
|
102
|
+
// one such path as two missing files — a disagreement that reads as a broken walker.
|
|
103
|
+
return result.stdout.split("\0").filter((path) => path.length > 0).sort();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Enumerate test files by walking the filesystem.
|
|
108
|
+
*
|
|
109
|
+
* This exists purely as an independent second opinion on {@link testFilesFromGit}. It is not the
|
|
110
|
+
* gate's source of truth; it is the thing that makes a wrong source of truth visible.
|
|
111
|
+
*/
|
|
112
|
+
export function testFilesFromWalk(repoRoot: string): string[] {
|
|
113
|
+
const found: string[] = [];
|
|
114
|
+
const walk = (dir: string): void => {
|
|
115
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
116
|
+
if (entry.isSymbolicLink()) continue;
|
|
117
|
+
const absolute = join(dir, entry.name);
|
|
118
|
+
if (entry.isDirectory()) {
|
|
119
|
+
if (SKIPPED_DIRS.has(entry.name)) continue;
|
|
120
|
+
walk(absolute);
|
|
121
|
+
} else if (entry.isFile() && entry.name.endsWith(TEST_SUFFIX)) {
|
|
122
|
+
found.push(relative(repoRoot, absolute).split(sep).join("/"));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
walk(repoRoot);
|
|
127
|
+
return found.sort();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export type DiscoveryDisagreement = { trackedNotOnDisk: string[]; onDiskUntracked: string[] };
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Compare the two enumerations.
|
|
134
|
+
*
|
|
135
|
+
* The two directions are NOT symmetric, and treating them as one check is what made the first
|
|
136
|
+
* version of this script unusable:
|
|
137
|
+
*
|
|
138
|
+
* trackedNotOnDisk — always fatal. The index names a suite the checkout does not have, so the
|
|
139
|
+
* gated list contains a file the runner cannot run.
|
|
140
|
+
* onDiskUntracked — fatal in CI ONLY. In CI the checkout is exactly the tracked tree, so an
|
|
141
|
+
* untracked suite means the enumeration itself is wrong. On a developer's machine it usually
|
|
142
|
+
* means a test being written right now, and failing the whole suite for that teaches people to
|
|
143
|
+
* delete the check rather than to commit the file.
|
|
144
|
+
*/
|
|
145
|
+
export function compareDiscovery(fromGit: string[], fromWalk: string[]): DiscoveryDisagreement {
|
|
146
|
+
const gitSet = new Set(fromGit);
|
|
147
|
+
const walkSet = new Set(fromWalk);
|
|
148
|
+
return {
|
|
149
|
+
trackedNotOnDisk: fromGit.filter((path) => !walkSet.has(path)),
|
|
150
|
+
onDiskUntracked: fromWalk.filter((path) => !gitSet.has(path)),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export type Partition = { gated: string[]; quarantined: string[] };
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Split `discovered` using `quarantined`.
|
|
158
|
+
*
|
|
159
|
+
* Throws when an entry names something that was not discovered, which is the anti-rot property:
|
|
160
|
+
* the exemption list can only ever refer to suites that currently exist.
|
|
161
|
+
*/
|
|
162
|
+
export function partition(discovered: string[], quarantined: string[]): Partition {
|
|
163
|
+
const discoveredSet = new Set(discovered);
|
|
164
|
+
const seen = new Set<string>();
|
|
165
|
+
for (const entry of quarantined) {
|
|
166
|
+
if (!discoveredSet.has(entry)) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`${QUARANTINE_FILE} names "${entry}", which is not a discovered test file. ` +
|
|
169
|
+
`If the suite was renamed or deleted, delete this line.`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
if (seen.has(entry)) {
|
|
173
|
+
throw new Error(`${QUARANTINE_FILE} lists "${entry}" more than once.`);
|
|
174
|
+
}
|
|
175
|
+
seen.add(entry);
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
gated: discovered.filter((path) => !seen.has(path)),
|
|
179
|
+
quarantined: [...seen].sort(),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The set of test files a run actually executed, read from bun's JUnit report.
|
|
185
|
+
*
|
|
186
|
+
* WHY THE JUNIT REPORT AND NOT THE CONSOLE OUTPUT. Not because the console is empty — an earlier
|
|
187
|
+
* version of this comment claimed a fully passing run prints only a summary, which is true when you
|
|
188
|
+
* run bun in a terminal and FALSE on an Actions runner, where it emits a `##[group]<file>:` header
|
|
189
|
+
* and a `(pass)` line per test. Asserting against a stream whose shape depends on the environment is
|
|
190
|
+
* the problem; the JUnit report has one documented shape with a `file=` attribute per suite whether
|
|
191
|
+
* or not anything failed.
|
|
192
|
+
*
|
|
193
|
+
* What makes the check load-bearing at all is measured bun behaviour that is genuinely surprising:
|
|
194
|
+
* positional arguments are SUBSTRING FILTERS, not paths, so `bun test gen1` matched four files, and
|
|
195
|
+
* `bun test src/nope.test.ts` on a path that does not exist EXITS 0 — it reports nothing to run and
|
|
196
|
+
* succeeds. So "the command was given 56 paths and returned 0" is not evidence that 56 suites ran,
|
|
197
|
+
* or that any did.
|
|
198
|
+
*
|
|
199
|
+
* Attributes are matched with a regex rather than a parser because the path set is already known to
|
|
200
|
+
* be free of whitespace and XML metacharacters (`--check` enforces the whitespace half, and the
|
|
201
|
+
* paths are repo-relative `*.test.ts`), so there is nothing for a parser to disambiguate.
|
|
202
|
+
*/
|
|
203
|
+
export function executedFilesFromJUnit(xml: string): string[] {
|
|
204
|
+
const executed = new Set<string>();
|
|
205
|
+
for (const match of xml.matchAll(/\bfile="([^"]+)"/g)) {
|
|
206
|
+
const path = match[1];
|
|
207
|
+
if (path?.endsWith(TEST_SUFFIX)) executed.add(path);
|
|
208
|
+
}
|
|
209
|
+
return [...executed].sort();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export type SuiteSkips = { file: string; total: number; skipped: number };
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Per-file test and skip counts from the JUnit report.
|
|
216
|
+
*
|
|
217
|
+
* Membership in the executed set is NOT the same as being asserted. bun emits a `file=` attribute for
|
|
218
|
+
* a suite whose every test is `describe.skip`ped, so such a file counts as executed and the partition
|
|
219
|
+
* check still reports "56 suites executed, exactly the 56 gated" while that suite asserts nothing.
|
|
220
|
+
* Set equality cannot see that; only the skip counts can.
|
|
221
|
+
*
|
|
222
|
+
* A skipped case is `<testcase ...><skipped /></testcase>` while a real one self-closes, so each
|
|
223
|
+
* `<testcase` chunk is inspected up to its terminator rather than the attributes being trusted —
|
|
224
|
+
* the `skipped=` attribute appears on the enclosing `<testsuite>` at several nesting levels and
|
|
225
|
+
* summing those double-counts.
|
|
226
|
+
*/
|
|
227
|
+
export function suiteSkips(xml: string): SuiteSkips[] {
|
|
228
|
+
const counts = new Map<string, { total: number; skipped: number }>();
|
|
229
|
+
for (const chunk of xml.split("<testcase").slice(1)) {
|
|
230
|
+
const file = chunk.match(/\bfile="([^"]+)"/)?.[1];
|
|
231
|
+
if (!file?.endsWith(TEST_SUFFIX)) continue;
|
|
232
|
+
const body = chunk.split("</testcase>")[0] ?? chunk;
|
|
233
|
+
const entry = counts.get(file) ?? { total: 0, skipped: 0 };
|
|
234
|
+
entry.total += 1;
|
|
235
|
+
// The self-closing form ends the element before any child could appear, so a `<skipped` found in
|
|
236
|
+
// a self-closed chunk belongs to the NEXT case and must not be attributed here.
|
|
237
|
+
if (!/^[^>]*\/>/.test(chunk) && /<skipped\b/.test(body)) entry.skipped += 1;
|
|
238
|
+
counts.set(file, entry);
|
|
239
|
+
}
|
|
240
|
+
return [...counts.entries()]
|
|
241
|
+
.map(([file, { total, skipped }]) => ({ file, total, skipped }))
|
|
242
|
+
.sort((a, b) => a.file.localeCompare(b.file));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Whether the JUnit report is a complete one.
|
|
247
|
+
*
|
|
248
|
+
* A run killed by a job timeout can leave a truncated file behind. Without this, a truncated report
|
|
249
|
+
* naming three suites looks identical to a run that only had three suites to name.
|
|
250
|
+
*/
|
|
251
|
+
export function isCompleteJUnit(xml: string): boolean {
|
|
252
|
+
return /<testsuites\b/.test(xml) && /<\/testsuites>/.test(xml);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function fail(message: string): never {
|
|
256
|
+
console.error(message);
|
|
257
|
+
process.exit(1);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function loadPartition(repoRoot: string): Partition & { discovered: string[] } {
|
|
261
|
+
const fromGit = testFilesFromGit(repoRoot);
|
|
262
|
+
const { trackedNotOnDisk, onDiskUntracked } = compareDiscovery(fromGit, testFilesFromWalk(repoRoot));
|
|
263
|
+
if (trackedNotOnDisk.length > 0) {
|
|
264
|
+
fail(
|
|
265
|
+
"The index names test files the checkout does not have, so the gated list contains files the " +
|
|
266
|
+
`runner cannot run:\n${trackedNotOnDisk.map((path) => ` ${path}`).join("\n")}`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
if (onDiskUntracked.length > 0) {
|
|
270
|
+
const detail = onDiskUntracked.map((path) => ` ${path}`).join("\n");
|
|
271
|
+
// In CI the checkout IS the tracked tree, so this is a broken enumeration rather than
|
|
272
|
+
// work in progress, and a smaller gate is exactly the failure that must not pass quietly.
|
|
273
|
+
if (process.env.CI) {
|
|
274
|
+
fail(`Untracked test files are invisible to the gate; commit or delete them:\n${detail}`);
|
|
275
|
+
}
|
|
276
|
+
console.error(`warning: untracked test files are not gated (local only):\n${detail}`);
|
|
277
|
+
}
|
|
278
|
+
if (fromGit.length === 0) {
|
|
279
|
+
fail("Discovered zero test files. A gate over zero files is green for the wrong reason.");
|
|
280
|
+
}
|
|
281
|
+
const quarantineText = readFileSync(join(repoRoot, QUARANTINE_FILE), "utf8");
|
|
282
|
+
const unexplained = entriesMissingReason(quarantineText);
|
|
283
|
+
if (unexplained.length > 0) {
|
|
284
|
+
fail(
|
|
285
|
+
`Every entry in ${QUARANTINE_FILE} needs a reason comment on the line directly above it. ` +
|
|
286
|
+
`Without one an exemption is indistinguishable from a muted suite:\n` +
|
|
287
|
+
unexplained.map((entry) => ` ${entry}`).join("\n"),
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
let split: Partition;
|
|
291
|
+
try {
|
|
292
|
+
split = partition(fromGit, parseQuarantine(quarantineText));
|
|
293
|
+
} catch (error) {
|
|
294
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
295
|
+
}
|
|
296
|
+
if (split.gated.length === 0) {
|
|
297
|
+
fail(
|
|
298
|
+
`Every discovered test file is quarantined by ${QUARANTINE_FILE}. The gate would run nothing.`,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
// The workflow expands `--gated` unquoted so the runner receives one argument per file, which is
|
|
302
|
+
// only safe while no path contains whitespace. Asserting it here keeps that assumption in the
|
|
303
|
+
// same place as the list, instead of leaving a shell-quoting bug to be discovered by a suite that
|
|
304
|
+
// silently stopped being gated.
|
|
305
|
+
const whitespace = split.gated.filter((path) => /\s/.test(path));
|
|
306
|
+
if (whitespace.length > 0) {
|
|
307
|
+
fail(
|
|
308
|
+
"Test paths containing whitespace cannot be passed through unquoted word splitting:\n" +
|
|
309
|
+
whitespace.map((path) => ` ${path}`).join("\n"),
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
return { ...split, discovered: fromGit };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function main(argv: string[]): void {
|
|
316
|
+
const repoRoot = process.cwd();
|
|
317
|
+
if (!statSync(join(repoRoot, QUARANTINE_FILE), { throwIfNoEntry: false })) {
|
|
318
|
+
fail(`Run this from the repository root; ${QUARANTINE_FILE} is not there.`);
|
|
319
|
+
}
|
|
320
|
+
const mode = argv[0];
|
|
321
|
+
const { gated, quarantined, discovered } = loadPartition(repoRoot);
|
|
322
|
+
|
|
323
|
+
if (mode === "--gated") {
|
|
324
|
+
console.log(gated.join("\n"));
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (mode === "--quarantined") {
|
|
328
|
+
console.log(quarantined.join("\n"));
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (mode === "--check") {
|
|
332
|
+
console.log(
|
|
333
|
+
`discovered ${discovered.length} test files: ${gated.length} gated, ` +
|
|
334
|
+
`${quarantined.length} quarantined by ${QUARANTINE_FILE}`,
|
|
335
|
+
);
|
|
336
|
+
for (const entry of quarantined) console.log(` quarantined: ${entry}`);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (mode === "--verify-run") {
|
|
340
|
+
const reportPath = argv[1];
|
|
341
|
+
if (!reportPath) fail("usage: --verify-run <junit.xml>");
|
|
342
|
+
// Reported as a verdict rather than as an exception. This step runs with `if: always()`, so it
|
|
343
|
+
// is the step that speaks when the suite crashed before writing anything, and a stack trace
|
|
344
|
+
// there buries the actual failure under a Node error.
|
|
345
|
+
if (!statSync(reportPath, { throwIfNoEntry: false })) {
|
|
346
|
+
fail(`${reportPath} does not exist; the gated run produced no report at all.`);
|
|
347
|
+
}
|
|
348
|
+
const xml = readFileSync(reportPath, "utf8");
|
|
349
|
+
if (!isCompleteJUnit(xml)) {
|
|
350
|
+
fail(`${reportPath} is not a complete JUnit report; the gated run did not finish.`);
|
|
351
|
+
}
|
|
352
|
+
const executed = executedFilesFromJUnit(xml);
|
|
353
|
+
const leaked = executed.filter((path) => quarantined.includes(path));
|
|
354
|
+
if (leaked.length > 0) {
|
|
355
|
+
fail(
|
|
356
|
+
"The gated run executed quarantined suites, so its result is not the gated result. " +
|
|
357
|
+
"`bun test` matches its arguments as SUBSTRING filters, not as paths:\n" +
|
|
358
|
+
leaked.map((path) => ` ${path}`).join("\n"),
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
const missing = gated.filter((path) => !executed.includes(path));
|
|
362
|
+
if (missing.length > 0) {
|
|
363
|
+
fail(
|
|
364
|
+
`${missing.length} gated suite(s) contributed nothing to the run, so the gate did not ` +
|
|
365
|
+
"cover them. Either the filter did not match the path, or the file declares no tests:\n" +
|
|
366
|
+
missing.map((path) => ` ${path}`).join("\n"),
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
const unexpected = executed.filter((path) => !gated.includes(path));
|
|
370
|
+
if (unexpected.length > 0) {
|
|
371
|
+
fail(
|
|
372
|
+
"The gated run executed suites outside the gated set:\n" +
|
|
373
|
+
unexpected.map((path) => ` ${path}`).join("\n"),
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
console.log(
|
|
377
|
+
`gated run honoured the partition: ${executed.length} suites executed, ` +
|
|
378
|
+
`exactly the ${gated.length} gated, none of the ${quarantined.length} quarantined`,
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
// Membership is not assertion. Reported rather than gated: a suite MAY legitimately skip
|
|
382
|
+
// everything on a given platform, so failing here would be a false red — but leaving it silent is
|
|
383
|
+
// how `describe.skip` hollows out a gate that still reports full coverage.
|
|
384
|
+
const skips = suiteSkips(xml);
|
|
385
|
+
const totalSkipped = skips.reduce((sum, s) => sum + s.skipped, 0);
|
|
386
|
+
const hollow = skips.filter((s) => s.total > 0 && s.skipped === s.total);
|
|
387
|
+
console.log(
|
|
388
|
+
`${skips.reduce((sum, s) => sum + s.total, 0)} tests reported, ${totalSkipped} skipped`,
|
|
389
|
+
);
|
|
390
|
+
for (const suite of hollow) {
|
|
391
|
+
console.log(
|
|
392
|
+
`::warning title=Suite asserts nothing::${suite.file} ran ${suite.total} test(s) and skipped ` +
|
|
393
|
+
"ALL of them. It counts as covered by the gate while asserting nothing.",
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
fail("usage: --check | --gated | --quarantined | --verify-run <junit.xml>");
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (import.meta.main) {
|
|
402
|
+
main(process.argv.slice(2));
|
|
403
|
+
}
|