@specific.dev/spectest 0.131.20 → 0.132.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/dist/browser.js +4 -2
- package/dist/change-rules.d.ts +45 -0
- package/dist/change-rules.js +179 -0
- package/dist/components/k3s-coverage.js +4 -4
- package/dist/components/supabase-coverage.js +12 -0
- package/dist/components/supabase.js +25 -12
- package/dist/coverage-bun.js +2 -1
- package/dist/coverage-next.d.ts +5 -0
- package/dist/coverage-next.js +27 -3
- package/dist/coverage-rust.d.ts +2 -0
- package/dist/coverage-rust.js +20 -0
- package/dist/coverage.d.ts +27 -3
- package/dist/coverage.js +68 -15
- package/dist/daemon.js +13 -10
- package/dist/harness/component-images.d.ts +5 -0
- package/dist/harness/component-images.js +15 -2
- package/dist/harness/coverage-target.d.ts +2 -0
- package/dist/harness/coverage-target.js +3 -2
- package/dist/harness/reach.d.ts +6 -1
- package/dist/harness/reach.js +8 -34
- package/dist/harness/shadow-builder.d.ts +2 -1
- package/dist/harness/shadow-builder.js +9 -3
- package/dist/index.d.ts +8 -0
- package/dist/index.js +5 -0
- package/dist/vendor/rrweb-record.min.js +2 -17
- package/package.json +1 -1
- package/src/browser-coverage-navigation.test.ts +10 -2
- package/src/browser-image-loading.test.ts +51 -0
- package/src/browser.ts +4 -2
- package/src/change-rules.fixtures.json +35 -0
- package/src/change-rules.test.ts +125 -0
- package/src/change-rules.ts +147 -0
- package/src/components/k3s-coverage.test.ts +32 -0
- package/src/components/k3s-coverage.ts +4 -4
- package/src/components/supabase-coverage.ts +12 -0
- package/src/components/supabase-dir.test.ts +26 -0
- package/src/components/supabase-functions-reach.test.ts +41 -1
- package/src/components/supabase-migrations.test.ts +1 -1
- package/src/components/supabase.ts +26 -12
- package/src/coverage-bun.ts +2 -1
- package/src/coverage-next.ts +29 -3
- package/src/coverage-rust.test.ts +34 -0
- package/src/coverage-rust.ts +22 -0
- package/src/coverage.test.ts +2 -1
- package/src/coverage.ts +76 -7
- package/src/daemon.ts +14 -9
- package/src/harness/component-images.test.ts +42 -0
- package/src/harness/component-images.ts +20 -3
- package/src/harness/coverage-target.ts +5 -2
- package/src/harness/reach.test.ts +40 -6
- package/src/harness/reach.ts +13 -3
- package/src/harness/shadow-builder.test.ts +12 -1
- package/src/harness/shadow-builder.ts +10 -3
- package/src/index.ts +11 -0
- package/src/vendor/rrweb-record.min.js +2 -17
|
@@ -77,6 +77,7 @@ import { SUPABASE_WEBHOOKS_SQL } from "./supabase-webhooks.js";
|
|
|
77
77
|
import { postgresTls } from "./postgres-tls.js";
|
|
78
78
|
import { supabasePoolerService } from "./supabase-pooler.js";
|
|
79
79
|
import { SUPABASE_COVERAGE_COLLECTOR, supabaseFunctionsCoverage } from "./supabase-coverage.js";
|
|
80
|
+
import { COMMON_CHANGE_RULES } from "../change-rules.js";
|
|
80
81
|
import {
|
|
81
82
|
mapSupabaseConfig,
|
|
82
83
|
parseSupabaseConfigFile,
|
|
@@ -1932,20 +1933,33 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
1932
1933
|
const parts: ServicesMap = {};
|
|
1933
1934
|
|
|
1934
1935
|
// ── db ──────────────────────────────────────────────────────────
|
|
1935
|
-
//
|
|
1936
|
-
//
|
|
1937
|
-
//
|
|
1938
|
-
//
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
...(
|
|
1944
|
-
|
|
1945
|
-
];
|
|
1936
|
+
// Track actual setup inputs, including currently absent conventional
|
|
1937
|
+
// paths so newly added config/migrations still select the full suite.
|
|
1938
|
+
// Functions own their module graph separately; giving the database the
|
|
1939
|
+
// entire folder would veto every function's source-selection policy.
|
|
1940
|
+
const projectRelative = (absolute: string): string | undefined =>
|
|
1941
|
+
absolute === root ? "." : absolute.startsWith(root + "/") ? absolute.slice(root.length + 1) : undefined;
|
|
1942
|
+
const inputs = [...new Set([
|
|
1943
|
+
...(dirPath === null ? [] : ["config.toml", "config.json", "migrations", "seed.sql"].map(p => projectRelative(join(dirPath, p)))),
|
|
1944
|
+
...templateBodies.map(([, spec]) => projectRelative(absProjectPath(spec.path))),
|
|
1945
|
+
].filter((p): p is string => p !== undefined))];
|
|
1946
|
+
const literalGlob = (p: string) => p.replace(/[\\*?\[\]]/g, "\\$&");
|
|
1946
1947
|
const dbDef: ServiceDefinition = {
|
|
1947
1948
|
image: { type: "registry", reference: opts.db?.image ?? IMAGES.db },
|
|
1948
1949
|
...(inputs.length > 0 ? { inputs } : {}),
|
|
1950
|
+
changeRules: [
|
|
1951
|
+
...inputs.flatMap(p => [
|
|
1952
|
+
{ pattern: literalGlob(p), action: "full" as const },
|
|
1953
|
+
{ pattern: `${literalGlob(p)}/**`, action: "full" as const },
|
|
1954
|
+
]),
|
|
1955
|
+
...COMMON_CHANGE_RULES,
|
|
1956
|
+
// A configured template is runtime content even if its extension
|
|
1957
|
+
// is Markdown. Its exact declaration overrides the common ignore.
|
|
1958
|
+
...templateBodies.flatMap(([, spec]) => {
|
|
1959
|
+
const p = projectRelative(absProjectPath(spec.path));
|
|
1960
|
+
return p === undefined ? [] : [{ pattern: literalGlob(p), action: "full" as const }];
|
|
1961
|
+
}),
|
|
1962
|
+
],
|
|
1949
1963
|
// `args` (not `command`): keep the image's docker-entrypoint.sh so it
|
|
1950
1964
|
// runs initdb + its baked init-scripts before starting.
|
|
1951
1965
|
args: [
|
|
@@ -1971,7 +1985,7 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
1971
1985
|
// Read SQL directly, including large seeds, without copying it through
|
|
1972
1986
|
// the harness and docker exec stdin. Keep this out of PGDATA.
|
|
1973
1987
|
...(migrationsPath === null ? {} : {
|
|
1974
|
-
volumes: [{ source: dirname(migrationsPath), target: SQL_MOUNT, readOnly: true }],
|
|
1988
|
+
volumes: [{ source: dirname(migrationsPath), target: SQL_MOUNT, readOnly: true, reachExempt: true }],
|
|
1975
1989
|
}),
|
|
1976
1990
|
ports: [5432],
|
|
1977
1991
|
// `pg_isready` succeeds only once the image finishes initdb + its
|
package/src/coverage-bun.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BUN_CHANGE_RULES } from "./change-rules.js";
|
|
1
2
|
import { appendEnvFlag, node, NODE_COVERAGE_HOOK, NODE_COVERAGE_HOOK_PATH, type CoverageAdapter, type NodeCoverageOptions } from "./coverage.js";
|
|
2
3
|
import { access } from "node:fs/promises";
|
|
3
4
|
import path from "node:path";
|
|
@@ -12,7 +13,7 @@ export interface BunCoverageOptions extends NodeCoverageOptions {
|
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
export function bun(options: BunCoverageOptions = {}): CoverageAdapter {
|
|
15
|
-
const adapter = node(options);
|
|
16
|
+
const adapter = node({ ...options, changeRules: options.changeRules === undefined ? BUN_CHANGE_RULES : options.changeRules });
|
|
16
17
|
return {
|
|
17
18
|
...adapter,
|
|
18
19
|
name: "bun",
|
package/src/coverage-next.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
// Next policy lives in the SDK. The collector/indexer only see ordinary
|
|
2
2
|
// Node/browser reports and generic, report-scoped source metadata.
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { NEXT_CHANGE_RULES, type ChangeRule } from "./change-rules.js";
|
|
4
5
|
import { JAVASCRIPT_GENERATED_SOURCES } from "./coverage-javascript.js";
|
|
5
6
|
import { node, browser, type CoverageAdapter, type CoverageSourceContext, type CoverageSourceOptions } from "./coverage.js";
|
|
6
7
|
|
|
7
8
|
export interface NextCoverageOptions extends CoverageSourceOptions {
|
|
9
|
+
/** Replace this adapter's defaults, or false to disable them. */
|
|
10
|
+
changeRules?: readonly ChangeRule[] | false;
|
|
8
11
|
/** Repository-relative directory represented by the build's source-map root.
|
|
9
12
|
* Defaults to "." (repository root). Set explicitly for a nested build root. */
|
|
10
13
|
sourceRoot?: string;
|
|
@@ -108,7 +111,10 @@ if (require.main === module) {
|
|
|
108
111
|
walk(path.join(dist, "server"), true);
|
|
109
112
|
walk(path.join(dist, "static"), false);
|
|
110
113
|
if (!serverMaps || !browserMaps) throw new Error("Next coverage needs server and browser .js.map files in the runtime image. Enable productionBrowserSourceMaps and experimental.serverSourceMaps in Next config, and copy .next/server and .next/static maps into the runtime image, including Edge maps.");
|
|
111
|
-
|
|
114
|
+
const appRoot = typeof m.relativeAppDir === "string" ? m.relativeAppDir :
|
|
115
|
+
(typeof m.appDir === "string" && typeof m.config.turbopack?.root === "string" ? path.relative(m.config.turbopack.root, m.appDir) : "");
|
|
116
|
+
process.stdout.write(JSON.stringify({generated:launcher(m), serverMaps, browserMaps,
|
|
117
|
+
pageExtensions:m.config.pageExtensions || ["tsx","ts","jsx","js"], appRoot}));
|
|
112
118
|
} catch (e) { console.error("Next coverage: " + e.message); process.exitCode = 1; }
|
|
113
119
|
}
|
|
114
120
|
`;
|
|
@@ -117,6 +123,8 @@ export interface NextBuildMetadata {
|
|
|
117
123
|
generated: string | null;
|
|
118
124
|
serverMaps: number;
|
|
119
125
|
browserMaps: number;
|
|
126
|
+
pageExtensions?: string[];
|
|
127
|
+
appRoot?: string;
|
|
120
128
|
}
|
|
121
129
|
|
|
122
130
|
const GENERATED_SOURCES = [
|
|
@@ -138,7 +146,7 @@ export function next(options: NextCoverageOptions = {}): CoverageAdapter {
|
|
|
138
146
|
for (const [key, value] of Object.entries({ appDir: options.appDir, distDir: options.distDir })) {
|
|
139
147
|
if (value !== undefined && (!value || path.posix.isAbsolute(value) || value.split("/").some(p => p === "..") || /[\\\0\r\n]/.test(value))) throw new Error(`coverage.next ${key} must be a relative runtime directory`);
|
|
140
148
|
}
|
|
141
|
-
const server = node(), client = browser();
|
|
149
|
+
const server = node({ sourceRoot, changeRules: options.changeRules === undefined ? NEXT_CHANGE_RULES : options.changeRules }), client = browser();
|
|
142
150
|
const cache = new Map<string, Promise<NextBuildMetadata>>();
|
|
143
151
|
const metadata = (ctx: Pick<CoverageSourceContext, "service" | "exec">) => {
|
|
144
152
|
let value = cache.get(ctx.service);
|
|
@@ -151,7 +159,25 @@ export function next(options: NextCoverageOptions = {}): CoverageAdapter {
|
|
|
151
159
|
};
|
|
152
160
|
return {
|
|
153
161
|
defaultExcludeSources: JAVASCRIPT_GENERATED_SOURCES,
|
|
154
|
-
name: "next", reports: "delta", explains: server.explains,
|
|
162
|
+
name: "next", reports: "delta", explains: server.explains, changeRules: server.changeRules?.map(rule => ({...rule, root: rule.root ?? sourceRoot})),
|
|
163
|
+
...(options.changeRules === undefined ? { async resolveChangeRules(ctx: CoverageSourceContext) {
|
|
164
|
+
const m = await metadata(ctx);
|
|
165
|
+
const extensions = m.pageExtensions ?? ["tsx", "ts", "jsx", "js"];
|
|
166
|
+
if (!Array.isArray(extensions) || extensions.some(ext => typeof ext !== "string" || !ext || /[^a-zA-Z0-9._-]/.test(ext))) throw new Error("invalid Next pageExtensions metadata");
|
|
167
|
+
const appRoot = m.appRoot || ".";
|
|
168
|
+
if (appRoot !== "." && (appRoot.includes("\\") || appRoot.split("/").some(part => !part || part === "." || part === ".."))) throw new Error("invalid Next app root metadata");
|
|
169
|
+
const root = path.posix.join(sourceRoot, appRoot);
|
|
170
|
+
// Remove the static conventional route allowance; the build's extensions
|
|
171
|
+
// determine which files are actually routes (including custom test-like names).
|
|
172
|
+
const rules = NEXT_CHANGE_RULES.filter(rule => !/^(pages|src\/pages|app|src\/app)\//.test(rule.pattern));
|
|
173
|
+
const routes = extensions.flatMap(ext => [
|
|
174
|
+
...["pages", "src/pages"].map(dir => ({root,pattern:`${dir}/**/*.${ext}`,action:"coverage" as const})),
|
|
175
|
+
...["app", "src/app"].flatMap(dir => ["page", "route"].map(name => ({root,pattern:`${dir}/**/${name}.${ext}`,action:"coverage" as const}))),
|
|
176
|
+
]);
|
|
177
|
+
// Shared entries still require full runs, even when they are also routes.
|
|
178
|
+
const full = rules.filter(rule => rule.action === "full" && rule.pattern !== "**/migrations/**");
|
|
179
|
+
return [...rules.map(rule => ({...rule,root:rule.root ?? sourceRoot})), ...routes, ...full.map(rule => ({...rule,root:rule.root ?? sourceRoot}))];
|
|
180
|
+
}} : {}),
|
|
155
181
|
ownsReport: name => server.ownsReport!(name) || client.ownsReport!(name),
|
|
156
182
|
configure(svc, info) {
|
|
157
183
|
const configured = server.configure!(svc, info);
|
|
@@ -5,6 +5,8 @@ import { execFileSync } from "node:child_process";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import os from "node:os";
|
|
7
7
|
import { rust } from "./coverage-rust";
|
|
8
|
+
import { resolveCoverageSourceOptions, serviceChangePolicy } from "./coverage";
|
|
9
|
+
import { evaluateChangePolicy } from "./change-rules";
|
|
8
10
|
import { captureCoverageTarget } from "./harness/coverage-target";
|
|
9
11
|
import { encodeCoverageBundle } from "./harness/coverage";
|
|
10
12
|
|
|
@@ -72,3 +74,35 @@ test.skipIf(!installedTools)("Rust real LLVM reports capture live and exited pro
|
|
|
72
74
|
expect((await capture()).error).toContain("without a final report");
|
|
73
75
|
} finally { child?.kill(); await fs.rm(root, { recursive: true, force: true }); }
|
|
74
76
|
}, 30000);
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
test("Rust workspace rules discover crate-local tests and binaries without running Cargo", async () => {
|
|
80
|
+
const files = [
|
|
81
|
+
"workspace/Cargo.toml", "workspace/crates/a/Cargo.toml", "workspace/crates/b/Cargo.toml",
|
|
82
|
+
"workspace/crates/a/tests/fixture/Cargo.toml", "outside/Cargo.toml",
|
|
83
|
+
];
|
|
84
|
+
const context = {service:"rust", signal:AbortSignal.timeout(1000),
|
|
85
|
+
projectFiles:async()=>files,readProjectFile:async()=>null,
|
|
86
|
+
exec:async()=>{throw new Error("crate policy must not execute Cargo");}};
|
|
87
|
+
for (const explicitRoot of [true, false]) {
|
|
88
|
+
const adapter = rust(explicitRoot ? {sourceRoot:"workspace"} : {});
|
|
89
|
+
const svc = {coverage:{sourceRoot:"workspace",adapters:[adapter]}};
|
|
90
|
+
await resolveCoverageSourceOptions([adapter],context,{sourceRoot:"workspace"});
|
|
91
|
+
const policy = serviceChangePolicy(svc,{requireResolved:true});
|
|
92
|
+
for (const crate of ["a","b"]) {
|
|
93
|
+
const root = `workspace/crates/${crate}`;
|
|
94
|
+
for (const file of ["tests/integration.rs", "tests/data.json", "benches/perf.rs"]) expect(evaluateChangePolicy(policy,`${root}/${file}`)).toBe("ignore");
|
|
95
|
+
for (const file of ["src/lib.rs", "src/tests.rs", "examples/server.rs"]) expect(evaluateChangePolicy(policy,`${root}/${file}`)).toBe("coverage");
|
|
96
|
+
expect(evaluateChangePolicy(policy,`${root}/src/bin/server.rs`)).toBe("full");
|
|
97
|
+
expect(evaluateChangePolicy(policy,`${root}/src/bin/README.md`)).toBe("ignore");
|
|
98
|
+
}
|
|
99
|
+
expect(evaluateChangePolicy(policy,"workspace/crates/a/tests/fixture/src/bin/main.rs")).toBe("ignore");
|
|
100
|
+
expect(evaluateChangePolicy(policy,"workspace/not-a-crate/tests/helper.rs")).toBe("coverage");
|
|
101
|
+
expect(evaluateChangePolicy(policy,"workspace/not-a-crate/benches/data.json")).toBeUndefined();
|
|
102
|
+
expect(evaluateChangePolicy(policy,"outside/tests/helper.rs")).toBeUndefined();
|
|
103
|
+
}
|
|
104
|
+
for (const changeRules of [false, []] as const) {
|
|
105
|
+
const adapter = rust({changeRules});
|
|
106
|
+
expect(adapter.resolveChangeRules).toBeUndefined();
|
|
107
|
+
}
|
|
108
|
+
});
|
package/src/coverage-rust.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { RUST_CHANGE_RULES, COMMON_CHANGE_RULES, validateChangeRules, type ChangeRule } from "./change-rules.js";
|
|
1
2
|
import { promises as fs, readFileSync } from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { connect } from "node:net";
|
|
4
5
|
import type { CoverageAdapter, CoverageSourceOptions } from "./coverage.js";
|
|
5
6
|
|
|
6
7
|
export interface RustCoverageOptions extends CoverageSourceOptions {
|
|
8
|
+
changeRules?: readonly ChangeRule[] | false;
|
|
7
9
|
/** Only for idle command containers where no Rust process has run yet. */
|
|
8
10
|
allowEmpty?: boolean;
|
|
9
11
|
/** Compiler workspace directory, when different from the runtime workdir.
|
|
@@ -50,6 +52,8 @@ export async function flushCoverageSockets(dir: string, prefix: string, signal:
|
|
|
50
52
|
* The helper exports cumulative per-process LCOV before exit and on request;
|
|
51
53
|
* no compiler tools or binaries need to be copied out of terminated pods. */
|
|
52
54
|
export function rust(options: RustCoverageOptions = {}): CoverageAdapter {
|
|
55
|
+
const rules = options.changeRules === undefined ? RUST_CHANGE_RULES : options.changeRules;
|
|
56
|
+
if (rules !== false) validateChangeRules(rules);
|
|
53
57
|
const { sourceRoot, excludeSources, llvmCov, llvmProfdata, ignoreFilenameRegex, buildRoot } = options;
|
|
54
58
|
if (buildRoot !== undefined && (!buildRoot.startsWith("/") || buildRoot === "/" || /[\0\r\n]/.test(buildRoot) || buildRoot.split("/").includes(".."))) throw new Error("Rust buildRoot must be an absolute compiler workspace directory");
|
|
55
59
|
for (const value of [llvmCov, llvmProfdata, ignoreFilenameRegex]) {
|
|
@@ -57,6 +61,24 @@ export function rust(options: RustCoverageOptions = {}): CoverageAdapter {
|
|
|
57
61
|
}
|
|
58
62
|
return {
|
|
59
63
|
name: "rust", reports: "cumulative",
|
|
64
|
+
changeRules: rules === false ? [] : rules.map(rule => ({...rule})),
|
|
65
|
+
...(options.changeRules === undefined ? { async resolveChangeRules(ctx) {
|
|
66
|
+
const root = sourceRoot ?? ctx.sourceRoot ?? ".";
|
|
67
|
+
const prefix = root === "." ? "" : root + "/";
|
|
68
|
+
const files = await ctx.projectFiles();
|
|
69
|
+
const crates = [...new Set(files.filter(file =>
|
|
70
|
+
file.startsWith(prefix) && (file === "Cargo.toml" || file.endsWith("/Cargo.toml"))
|
|
71
|
+
).map(file => path.posix.dirname(file)))].sort();
|
|
72
|
+
// A workspace can contain many packages; their canonical test directories
|
|
73
|
+
// are relative to each Cargo.toml, not just the adapter's source root.
|
|
74
|
+
// Test fixture packages stay ignored even when they contain binary targets.
|
|
75
|
+
return [
|
|
76
|
+
...RUST_CHANGE_RULES.map(rule => ({...rule,root:rule.root ?? root})),
|
|
77
|
+
...crates.map(root => ({root,pattern:"src/bin/**",action:"full" as const})),
|
|
78
|
+
...crates.flatMap(root => ["tests/**", "benches/**"].map(pattern => ({root,pattern,action:"ignore" as const}))),
|
|
79
|
+
...COMMON_CHANGE_RULES.map(rule => ({...rule,root})),
|
|
80
|
+
];
|
|
81
|
+
}} satisfies Partial<CoverageAdapter> : {}),
|
|
60
82
|
sourceOptions: { ...(sourceRoot !== undefined ? { sourceRoot } : {}), ...(excludeSources ? { excludeSources } : {}) },
|
|
61
83
|
ownsReport: name => /^rust-.*\.lcov$/.test(name),
|
|
62
84
|
configure: service => ({ ...service, env: { ...service.env,
|
package/src/coverage.test.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
NODE_COVERAGE_MARKERS_SUBDIR,
|
|
13
13
|
NODE_COVERAGE_SOCKET_PREFIX,
|
|
14
14
|
NODE_EXPLAINS,
|
|
15
|
+
NODE_CHANGE_RULES,
|
|
15
16
|
NODE_INVENTORY_SCRIPT,
|
|
16
17
|
npmManifestUnit,
|
|
17
18
|
applyCoverageAdapters,
|
|
@@ -112,7 +113,7 @@ describe("applyCoverageAdapters", () => {
|
|
|
112
113
|
expect(typeof list[0]?.capture).toBe("function");
|
|
113
114
|
expect(typeof list[1]?.load).toBe("function");
|
|
114
115
|
expect(JSON.parse(JSON.stringify(out)).coverage).toEqual({
|
|
115
|
-
adapters: [{ adapter: "node", defaultExcludeSources: [...JAVASCRIPT_GENERATED_SOURCES] }, { adapter: "browser", defaultExcludeSources: [...JAVASCRIPT_GENERATED_SOURCES] }],
|
|
116
|
+
adapters: [{ adapter: "node", changeRules: NODE_CHANGE_RULES, defaultExcludeSources: [...JAVASCRIPT_GENERATED_SOURCES] }, { adapter: "browser", changeRules: NODE_CHANGE_RULES, defaultExcludeSources: [...JAVASCRIPT_GENERATED_SOURCES] }],
|
|
116
117
|
explains: [...NODE_EXPLAINS],
|
|
117
118
|
});
|
|
118
119
|
});
|
package/src/coverage.ts
CHANGED
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
|
|
30
30
|
import { createHash } from "node:crypto";
|
|
31
31
|
import { JAVASCRIPT_GENERATED_SOURCES } from "./coverage-javascript.js";
|
|
32
|
+
import { NODE_CHANGE_RULES, BROWSER_CHANGE_RULES, resolveChangePolicy, validateChangeRules, compileChangePolicy, type ChangeRule, type ChangePolicy } from "./change-rules.js";
|
|
33
|
+
export * from "./change-rules.js";
|
|
32
34
|
|
|
33
35
|
import type { ServiceConfig } from "./index.js";
|
|
34
36
|
import { harvestAllBrowserCoverage, takeBrowserCoverageReport } from "./browser-coverage.js";
|
|
@@ -80,13 +82,17 @@ export interface CoverageLoadContext {
|
|
|
80
82
|
* of the manifests that declare them (see {@link CoverageAdapter.inventory}).
|
|
81
83
|
*/
|
|
82
84
|
export interface CoverageInventoryContext {
|
|
85
|
+
/** Effective repository-relative source root for build-specific policy resolution. */
|
|
86
|
+
sourceRoot?: string;
|
|
87
|
+
/** Selection overrides of the owning service, forwarded by container wrappers. */
|
|
88
|
+
changeRules?: readonly ChangeRule[] | false;
|
|
83
89
|
/** The services-map key. */
|
|
84
90
|
service: string;
|
|
85
91
|
/** Run a shell command (`sh -c`) inside the container. Rejects on a
|
|
86
92
|
* non-zero exit with the command's output in the message. */
|
|
87
93
|
exec(command: string): Promise<{ stdout: string; stderr: string }>;
|
|
88
|
-
/**
|
|
89
|
-
*
|
|
94
|
+
/** Project-relative files; inventory calls omit paths ignored by effective
|
|
95
|
+
* change policy, as well as node_modules, .git and other upload exclusions. */
|
|
90
96
|
projectFiles(): Promise<string[]>;
|
|
91
97
|
/** One project file's text (project-relative path); `null` when absent. */
|
|
92
98
|
readProjectFile(path: string): Promise<string | null>;
|
|
@@ -127,6 +133,12 @@ export type CoverageSourceContext = CoverageInventoryContext;
|
|
|
127
133
|
export interface CoverageAdapter {
|
|
128
134
|
/** Names the adapter in errors (`coverage adapter "node" on service …`). */
|
|
129
135
|
name: string;
|
|
136
|
+
/** Ordered default rules; service rules are explicit overrides. */
|
|
137
|
+
changeRules?: readonly ChangeRule[];
|
|
138
|
+
/** Resolve build-specific rules once evidence is available; replaces static defaults. */
|
|
139
|
+
resolveChangeRules?(ctx: CoverageSourceContext): Promise<readonly ChangeRule[]>;
|
|
140
|
+
/** Wrappers retain separate policy groups for each nested adapter. */
|
|
141
|
+
changeRuleGroups?: readonly (readonly ChangeRule[])[];
|
|
130
142
|
/** Archived on this adapter's reports; other adapters retain their own policy. */
|
|
131
143
|
sourceOptions?: CoverageSourceOptions | ((ctx: CoverageSourceContext) => Promise<CoverageSourceOptions>);
|
|
132
144
|
/** Generated scaffolding excluded in addition to explicit or legacy source policy. */
|
|
@@ -251,19 +263,23 @@ export function validateCoverage(service: string, cov: unknown): void {
|
|
|
251
263
|
((a as CoverageAdapter).explains === undefined ||
|
|
252
264
|
(Array.isArray((a as CoverageAdapter).explains) &&
|
|
253
265
|
((a as CoverageAdapter).explains as unknown[]).every((e) => typeof e === "string"))) &&
|
|
254
|
-
["configure", "load", "capture", "collect", "inventory", "ownsReport"].every(
|
|
266
|
+
["configure", "load", "capture", "collect", "inventory", "ownsReport", "resolveChangeRules"].every(
|
|
255
267
|
(k) =>
|
|
256
268
|
(a as Record<string, unknown>)[k] === undefined ||
|
|
257
269
|
typeof (a as Record<string, unknown>)[k] === "function",
|
|
258
270
|
);
|
|
259
271
|
const sourceOptions = (a as CoverageAdapter | null)?.sourceOptions;
|
|
272
|
+
const changeRules = (a as CoverageAdapter | null)?.changeRules;
|
|
273
|
+
const groups = (a as CoverageAdapter | null)?.changeRuleGroups;
|
|
274
|
+
if (changeRules !== undefined) validateChangeRules(changeRules);
|
|
275
|
+
if (groups !== undefined) { if (!Array.isArray(groups)) throw new Error("invalid changeRuleGroups"); for (const group of groups) validateChangeRules(group); }
|
|
260
276
|
const defaults = (a as CoverageAdapter | null)?.defaultExcludeSources;
|
|
261
277
|
const defaultShape = defaults === undefined || typeof (a as CoverageAdapter).ownsReport === "function";
|
|
262
278
|
const sourceShape = sourceOptions === undefined || (typeof (a as CoverageAdapter).ownsReport === "function" &&
|
|
263
279
|
(typeof sourceOptions === "function" || (typeof sourceOptions === "object" && sourceOptions !== null && !Array.isArray(sourceOptions))));
|
|
264
280
|
if (!ok || !sourceShape || !defaultShape) {
|
|
265
281
|
throw new Error(
|
|
266
|
-
`service "${service}" has an invalid coverage adapter — an adapter is \`{ name, reports?, explains?, configure?, load?, capture?, inventory? }\` (see \`defineAdapter\` in @specific.dev/spectest/coverage)`,
|
|
282
|
+
`service "${service}" has an invalid coverage adapter — an adapter is \`{ name, reports?, explains?, changeRules?, configure?, load?, capture?, inventory? }\` (see \`defineAdapter\` in @specific.dev/spectest/coverage)`,
|
|
267
283
|
);
|
|
268
284
|
}
|
|
269
285
|
if (sourceOptions !== undefined && typeof sourceOptions !== "function") validateCoverageSourceOptions(service, sourceOptions);
|
|
@@ -291,6 +307,7 @@ export function coverageAdapters(cov: ServiceCoverage | undefined): readonly Cov
|
|
|
291
307
|
export function applyCoverageAdapters<S extends ServiceConfig>(key: string, svc: S): S {
|
|
292
308
|
validateCoverage(key, svc.coverage);
|
|
293
309
|
const adapters = coverageAdapters(svc.coverage);
|
|
310
|
+
serviceChangePolicy(svc); // Validate even uncovered services; never enables collection.
|
|
294
311
|
if (adapters.length === 0) return svc;
|
|
295
312
|
let out: ServiceConfig = svc;
|
|
296
313
|
for (const a of adapters) {
|
|
@@ -312,6 +329,41 @@ export function applyCoverageAdapters<S extends ServiceConfig>(key: string, svc:
|
|
|
312
329
|
return { ...out, coverage: wire } as unknown as S;
|
|
313
330
|
}
|
|
314
331
|
|
|
332
|
+
/** Resolve service policy without requiring coverage to be enabled. */
|
|
333
|
+
const RESOLVED_CHANGE_RULES = new Map<NonNullable<CoverageAdapter["resolveChangeRules"]>, readonly ChangeRule[]>();
|
|
334
|
+
export async function resolveAdapterChangeRules(adapter: CoverageAdapter, ctx: CoverageSourceContext): Promise<void> {
|
|
335
|
+
if (!adapter.resolveChangeRules) return;
|
|
336
|
+
const rules = await adapter.resolveChangeRules(ctx);
|
|
337
|
+
validateChangeRules(rules);
|
|
338
|
+
const before = RESOLVED_CHANGE_RULES.get(adapter.resolveChangeRules);
|
|
339
|
+
if (before && JSON.stringify(before) !== JSON.stringify(rules)) throw new Error(`adapter ${adapter.name} resolved inconsistent change rules across runtimes`);
|
|
340
|
+
RESOLVED_CHANGE_RULES.set(adapter.resolveChangeRules, rules.map(rule => ({...rule})));
|
|
341
|
+
}
|
|
342
|
+
export function serviceChangePolicy(svc: Pick<ServiceConfig, "coverage" | "changeRules">, options: {requireResolved?: boolean} = {}): ChangePolicy {
|
|
343
|
+
const adapters = coverageAdapters(svc.coverage).map(adapter => {
|
|
344
|
+
if (!adapter.resolveChangeRules || svc.changeRules === false) return adapter;
|
|
345
|
+
const rules = RESOLVED_CHANGE_RULES.get(adapter.resolveChangeRules);
|
|
346
|
+
if (!rules && options.requireResolved) {
|
|
347
|
+
// An unused declared image has no runtime metadata. Archive a conservative
|
|
348
|
+
// fallback, so its source cannot gain ignore/coverage exceptions.
|
|
349
|
+
const fallbackRoot = typeof adapter.sourceOptions === "object" ? adapter.sourceOptions.sourceRoot : undefined;
|
|
350
|
+
const serviceRoot = typeof svc.coverage === "object" ? svc.coverage.sourceRoot : undefined;
|
|
351
|
+
const roots = new Set([...(adapter.changeRules ?? []), ...(adapter.changeRuleGroups ?? []).flat()].map(rule => rule.root ?? fallbackRoot ?? serviceRoot ?? "."));
|
|
352
|
+
if (!roots.size) roots.add(fallbackRoot ?? serviceRoot ?? ".");
|
|
353
|
+
return {...adapter, changeRuleGroups:[], changeRules:[...roots].map(root => ({root,pattern:"**",action:"full" as const}))};
|
|
354
|
+
}
|
|
355
|
+
return rules ? {...adapter,changeRules:rules} : adapter;
|
|
356
|
+
});
|
|
357
|
+
return resolveChangePolicy(svc, adapters);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Inventory fingerprints must not turn ignored fixture manifests into changes.
|
|
361
|
+
* Installed runtime dependency inventories remain independent of project files. */
|
|
362
|
+
export function inventoryProjectFiles(svc: Pick<ServiceConfig, "coverage" | "changeRules">, files: readonly string[]): string[] {
|
|
363
|
+
const action = compileChangePolicy(serviceChangePolicy(svc, {requireResolved:true}));
|
|
364
|
+
return files.filter(file => action(file) !== "ignore");
|
|
365
|
+
}
|
|
366
|
+
|
|
315
367
|
function validateCoverageSourceOptions(service: string, options: unknown): asserts options is CoverageSourceOptions {
|
|
316
368
|
if (!options || typeof options !== "object" || Array.isArray(options) ||
|
|
317
369
|
!Object.keys(options).every(k => k === "sourceRoot" || k === "excludeSources")) {
|
|
@@ -323,6 +375,9 @@ function validateCoverageSourceOptions(service: string, options: unknown): asser
|
|
|
323
375
|
/** Resolve adapter policies once per capture. Invalid/overlapping ownership is
|
|
324
376
|
* an error, so one adapter cannot silently reinterpret another's reports. */
|
|
325
377
|
export async function resolveCoverageSourceOptions(adapters: readonly CoverageAdapter[], ctx: CoverageSourceContext, fallback?: CoverageSourceOptions) {
|
|
378
|
+
await Promise.all(adapters.map(a => resolveAdapterChangeRules(a, {
|
|
379
|
+
...ctx, sourceRoot: (typeof a.sourceOptions === "object" ? a.sourceOptions.sourceRoot : undefined) ?? fallback?.sourceRoot ?? ".",
|
|
380
|
+
})));
|
|
326
381
|
return Promise.all(adapters.filter(a => a.sourceOptions !== undefined || a.defaultExcludeSources !== undefined || a.ownsReport).map(async a => {
|
|
327
382
|
if (!a.ownsReport) throw new Error(`coverage adapter "${a.name}" needs ownsReport with sourceOptions`);
|
|
328
383
|
let options = typeof a.sourceOptions === "function" ? await a.sourceOptions(ctx) : a.sourceOptions;
|
|
@@ -353,7 +408,7 @@ export function attachCoverageSourceOptions<T extends { reports: { name: string;
|
|
|
353
408
|
function withWireName(a: CoverageAdapter): CoverageAdapter {
|
|
354
409
|
if (Object.prototype.hasOwnProperty.call(a, "toJSON")) return a;
|
|
355
410
|
return Object.assign(Object.create(Object.getPrototypeOf(a) as object | null), a, {
|
|
356
|
-
toJSON: () => ({ adapter: a.name, ...(a.defaultExcludeSources ? { defaultExcludeSources: a.defaultExcludeSources } : {}), ...(a.sourceOptions && typeof a.sourceOptions !== "function" ? { sourceOptions: a.sourceOptions } : {}) }),
|
|
411
|
+
toJSON: () => ({ adapter: a.name, ...(a.changeRules ? {changeRules: a.changeRules} : {}), ...(a.changeRuleGroups ? {changeRuleGroups: a.changeRuleGroups} : {}), ...(a.defaultExcludeSources ? { defaultExcludeSources: a.defaultExcludeSources } : {}), ...(a.sourceOptions && typeof a.sourceOptions !== "function" ? { sourceOptions: a.sourceOptions } : {}) }),
|
|
357
412
|
}) as CoverageAdapter;
|
|
358
413
|
}
|
|
359
414
|
|
|
@@ -973,6 +1028,8 @@ process.stdout.write(Array.from(out).sort().join("\\n") + (out.size ? "\\n" : ""
|
|
|
973
1028
|
`;
|
|
974
1029
|
|
|
975
1030
|
export interface NodeCoverageOptions extends CoverageSourceOptions {
|
|
1031
|
+
/** Replace this adapter's defaults, or false to disable them. */
|
|
1032
|
+
changeRules?: readonly ChangeRule[] | false;
|
|
976
1033
|
/** Generated runtime entrypoints, relative to the service working directory.
|
|
977
1034
|
* Track their exact contents as units rather than inventing repository lines.
|
|
978
1035
|
* A content change changes the inventory and invalidates dependent tests. */
|
|
@@ -980,6 +1037,11 @@ export interface NodeCoverageOptions extends CoverageSourceOptions {
|
|
|
980
1037
|
}
|
|
981
1038
|
|
|
982
1039
|
export function node(options: NodeCoverageOptions = {}): CoverageAdapter {
|
|
1040
|
+
const policy = options.changeRules === undefined ? NODE_CHANGE_RULES : options.changeRules;
|
|
1041
|
+
if (policy !== false) validateChangeRules(policy);
|
|
1042
|
+
if (options.sourceRoot !== undefined || options.excludeSources !== undefined) {
|
|
1043
|
+
validateCoverageSourceOptions("node", { sourceRoot: options.sourceRoot, excludeSources: options.excludeSources });
|
|
1044
|
+
}
|
|
983
1045
|
if (options.generated !== undefined && (!Array.isArray(options.generated) || options.generated.some(p => typeof p !== "string"))) {
|
|
984
1046
|
throw new Error("coverage.node generated must be a list of relative files");
|
|
985
1047
|
}
|
|
@@ -990,6 +1052,7 @@ export function node(options: NodeCoverageOptions = {}): CoverageAdapter {
|
|
|
990
1052
|
}
|
|
991
1053
|
return {
|
|
992
1054
|
name: "node",
|
|
1055
|
+
changeRules: policy === false ? [] : policy.map(rule => ({...rule})),
|
|
993
1056
|
defaultExcludeSources: JAVASCRIPT_GENERATED_SOURCES,
|
|
994
1057
|
...(options.sourceRoot !== undefined || options.excludeSources !== undefined ? { sourceOptions: { sourceRoot: options.sourceRoot, excludeSources: options.excludeSources } } : {}),
|
|
995
1058
|
ownsReport: name => isNodeDumpName(name) || name === NODE_COVERAGE_EMPTY_REPORT,
|
|
@@ -1204,11 +1267,17 @@ export async function collectNodeReports(dir: string): Promise<NodeReportStats>
|
|
|
1204
1267
|
* harvested first, and only this service's totals are taken and cleared. Nothing ran ⇒
|
|
1205
1268
|
* an lcov with no records, which is a report saying so.
|
|
1206
1269
|
*/
|
|
1207
|
-
export
|
|
1270
|
+
export interface BrowserCoverageOptions extends CoverageSourceOptions {
|
|
1271
|
+
changeRules?: readonly ChangeRule[] | false;
|
|
1272
|
+
}
|
|
1273
|
+
export function browser(options: BrowserCoverageOptions = {}): CoverageAdapter {
|
|
1274
|
+
const rules = options.changeRules === undefined ? BROWSER_CHANGE_RULES : options.changeRules;
|
|
1275
|
+
if (rules !== false) validateChangeRules(rules);
|
|
1208
1276
|
return {
|
|
1209
1277
|
name: "browser",
|
|
1278
|
+
changeRules: rules === false ? [] : rules.map(rule => ({...rule})),
|
|
1210
1279
|
defaultExcludeSources: JAVASCRIPT_GENERATED_SOURCES,
|
|
1211
|
-
...(options.sourceRoot !== undefined || options.excludeSources !== undefined ? { sourceOptions: options } : {}),
|
|
1280
|
+
...(options.sourceRoot !== undefined || options.excludeSources !== undefined ? { sourceOptions: {sourceRoot: options.sourceRoot, excludeSources: options.excludeSources} } : {}),
|
|
1212
1281
|
ownsReport: name => name === "browser.lcov",
|
|
1213
1282
|
reports: "delta",
|
|
1214
1283
|
load(ctx) {
|
package/src/daemon.ts
CHANGED
|
@@ -72,6 +72,8 @@ import {
|
|
|
72
72
|
NODE_COVERAGE_SCRIPTS_SUBDIR,
|
|
73
73
|
applyCoverageAdapters,
|
|
74
74
|
coverageAdapters,
|
|
75
|
+
serviceChangePolicy,
|
|
76
|
+
inventoryProjectFiles,
|
|
75
77
|
resolveCoverageSourceOptions,
|
|
76
78
|
attachCoverageSourceOptions,
|
|
77
79
|
coverageReportsMode,
|
|
@@ -123,7 +125,7 @@ import { evaluateChecked } from "./harness/eval-typecheck.js";
|
|
|
123
125
|
import type { WrapperDiagnostic } from "./harness/wrapper-rules.js";
|
|
124
126
|
import { cpus, tmpdir } from "node:os";
|
|
125
127
|
import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath, takeProjectReads } from "./project-files.js";
|
|
126
|
-
import { compactReach, projectReach, renderReachRules, renderReadsLcov, type ReachRules, type ReachSource } from "./harness/reach.js";
|
|
128
|
+
import { compactReach, environmentReachSource, projectReach, renderReachRules, renderReadsLcov, type ReachRules, type ReachSource } from "./harness/reach.js";
|
|
127
129
|
import { installFetchWrapper, isTransportError } from "./harness/fetch.js";
|
|
128
130
|
import {
|
|
129
131
|
bindInstrumentationScope,
|
|
@@ -1382,7 +1384,7 @@ async function runServiceBuild(
|
|
|
1382
1384
|
if (remote) {
|
|
1383
1385
|
buildArgs = ["buildx", "build", "--builder", remote.builder,
|
|
1384
1386
|
"--progress=plain", "--metadata-file", metadataPath,
|
|
1385
|
-
"--output", shadowOutput(remoteReference
|
|
1387
|
+
"--output", shadowOutput(remoteReference!, remote), ...argFlags,
|
|
1386
1388
|
"-f", dfPath, contextDir];
|
|
1387
1389
|
} else if (useLocal) {
|
|
1388
1390
|
// The in-VM builder is BuildKit's containerd worker on this VM's
|
|
@@ -1470,7 +1472,7 @@ async function runServiceBuild(
|
|
|
1470
1472
|
const immutable = `${remoteReference!.split(":result")[0]}@${digest}`;
|
|
1471
1473
|
const pullStarted = Date.now();
|
|
1472
1474
|
const pulled = await shxStream("docker", ["pull", immutable], undefined, undefined, () => {});
|
|
1473
|
-
await shadowTiming(name, "pull-unpack", pullStarted, { digest, ok: pulled.code === 0, lazy:
|
|
1475
|
+
await shadowTiming(name, "pull-unpack", pullStarted, { digest, ok: pulled.code === 0, lazy: remote.imageFormat === "nydus-v6", imageFormat: remote.imageFormat ?? "oci-zstd" });
|
|
1474
1476
|
if (pulled.code !== 0) return { ok: false, log: pulled.stderr };
|
|
1475
1477
|
const tagged = await docker(["tag", immutable, tag]);
|
|
1476
1478
|
if (tagged.code !== 0) return { ok: false, log: tagged.stderr };
|
|
@@ -3793,7 +3795,7 @@ async function writeCoverageInventory(svc: NamedService): Promise<string | undef
|
|
|
3793
3795
|
}
|
|
3794
3796
|
return { stdout: r.stdout, stderr: r.stderr };
|
|
3795
3797
|
},
|
|
3796
|
-
projectFiles: () => walkProjectFiles(WORKSPACE),
|
|
3798
|
+
projectFiles: async () => inventoryProjectFiles(svc, await walkProjectFiles(WORKSPACE)),
|
|
3797
3799
|
async readProjectFile(p: string) {
|
|
3798
3800
|
// Not recorded as a read: an inventory fingerprinting every
|
|
3799
3801
|
// manifest is bookkeeping, not the suite depending on the files.
|
|
@@ -3846,11 +3848,13 @@ async function computeProjectReach(all: NamedService[]): Promise<ReachRules> {
|
|
|
3846
3848
|
|
|
3847
3849
|
async function computeProjectReachInner(all: NamedService[]): Promise<ReachRules> {
|
|
3848
3850
|
const files = await walkProjectFiles(WORKSPACE, [".git"]);
|
|
3849
|
-
const sources: ReachSource[] = [];
|
|
3851
|
+
const sources: ReachSource[] = [environmentReachSource(ENVIRONMENT_SERVICE)];
|
|
3850
3852
|
const owners = new Map(componentBuildTasks(all).map(t => [t.name, t.owner]));
|
|
3851
3853
|
for (const svc of imageTasks(all)) {
|
|
3854
|
+
const changePolicy = serviceChangePolicy(svc, {requireResolved: true});
|
|
3852
3855
|
const src: ReachSource = {
|
|
3853
3856
|
service: owners.get(svc.name) ?? svc.name,
|
|
3857
|
+
changePolicy,
|
|
3854
3858
|
volumeSources: (svc.volumes ?? []).flatMap((v) => (v.source && !v.reachExempt ? [v.source] : [])),
|
|
3855
3859
|
...(svc.inputs && svc.inputs.length > 0 ? { inputs: [...svc.inputs] } : {}),
|
|
3856
3860
|
};
|
|
@@ -3870,7 +3874,7 @@ async function computeProjectReachInner(all: NamedService[]): Promise<ReachRules
|
|
|
3870
3874
|
sources.push(src);
|
|
3871
3875
|
}
|
|
3872
3876
|
const reach = projectReach(files, sources);
|
|
3873
|
-
return { version:
|
|
3877
|
+
return { version: 2, sources, prefixes: compactReach(files, reach) };
|
|
3874
3878
|
}
|
|
3875
3879
|
|
|
3876
3880
|
/**
|
|
@@ -3915,7 +3919,6 @@ async function captureServiceCoverage(baseline = false): Promise<ServiceCoverage
|
|
|
3915
3919
|
});
|
|
3916
3920
|
// The environment's own capture is unconditional (see above); the
|
|
3917
3921
|
// services' come from the ones that opted in, none included.
|
|
3918
|
-
const environment = captureEnvironmentCoverage(all, baseline);
|
|
3919
3922
|
const captures = await Promise.all(
|
|
3920
3923
|
services.map(async (svc): Promise<ServiceCoverageCapture> => {
|
|
3921
3924
|
let policies: Awaited<ReturnType<typeof resolveCoverageSourceOptions>>;
|
|
@@ -3967,6 +3970,7 @@ async function captureServiceCoverage(baseline = false): Promise<ServiceCoverage
|
|
|
3967
3970
|
try {
|
|
3968
3971
|
return await adapter.collect!({
|
|
3969
3972
|
...coverageCaptureContext(svc, signal, new Set()),
|
|
3973
|
+
changeRules: svc.changeRules,
|
|
3970
3974
|
projectFiles: () => walkProjectFiles(WORKSPACE),
|
|
3971
3975
|
async readProjectFile(p) {
|
|
3972
3976
|
try { return await fs.readFile(resolveProjectPath(p, { record: false }), "utf8"); }
|
|
@@ -3977,7 +3981,8 @@ async function captureServiceCoverage(baseline = false): Promise<ServiceCoverage
|
|
|
3977
3981
|
return [{ service: `${svc.name}/${adapter.name}`, reports: [], error: String(error) }];
|
|
3978
3982
|
}
|
|
3979
3983
|
})));
|
|
3980
|
-
|
|
3984
|
+
// Freeze build-dependent policy only after all direct and nested adapters resolved it.
|
|
3985
|
+
return [...captures, ...components.flat(), await captureEnvironmentCoverage(all, baseline)];
|
|
3981
3986
|
}
|
|
3982
3987
|
|
|
3983
3988
|
/** Per-Browser rrweb session shipped to the control plane. */
|
|
@@ -6074,7 +6079,7 @@ export function harnessMethods(state: RouteState): MethodTable {
|
|
|
6074
6079
|
return {
|
|
6075
6080
|
health: async () => ({
|
|
6076
6081
|
ok: true,
|
|
6077
|
-
capabilities: ["prepared-images-v1", "shadow-builder-v1"],
|
|
6082
|
+
capabilities: ["prepared-images-v1", "shadow-builder-v1", "shadow-nydus-v1"],
|
|
6078
6083
|
// Optional discovery: old SDKs omit this field and are never sent the
|
|
6079
6084
|
// new method. Check the loaded project, including after warm restore.
|
|
6080
6085
|
setupInteractive: typeof loaded?.project.interactive?.seed === "function",
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
2
|
import { canonicalImageReference, componentBuildTasks, isComponentImageTask, isDigestReference, kubernetesImages } from "./component-images";
|
|
3
3
|
import { validateServiceImage, type BuildImage } from "../index";
|
|
4
|
+
import { node, rust, serviceChangePolicy } from "../coverage";
|
|
5
|
+
import { evaluateChangePolicy } from "../change-rules";
|
|
6
|
+
import { projectReach } from "./reach";
|
|
4
7
|
|
|
5
8
|
test("services and Kubernetes share all Dockerfile build options without a type discriminator", () => {
|
|
6
9
|
const image: BuildImage & { reference: string } = {
|
|
@@ -51,6 +54,45 @@ test("component image tasks are recognised by name and services are not", () =>
|
|
|
51
54
|
expect(isComponentImageTask("spectest-image")).toBe(false);
|
|
52
55
|
});
|
|
53
56
|
|
|
57
|
+
test("image tasks retain each adapter policy and context without granting permissions to sibling images", () => {
|
|
58
|
+
const images = kubernetesImages([
|
|
59
|
+
{ reference: "app/api:1", context: "packages/api", content: "FROM node:24",
|
|
60
|
+
coverage: { adapters: [node({ sourceRoot: "packages/api" })] } },
|
|
61
|
+
{ reference: "app/worker:1", context: "crates/worker", content: "FROM rust:1",
|
|
62
|
+
coverage: { adapters: [rust({ sourceRoot: "crates/worker" })] },
|
|
63
|
+
changeRules: [{ root: "crates/worker", pattern: "generated/**", action: "ignore" }] },
|
|
64
|
+
{ reference: "app/uncovered:1", context: "packages/uncovered", content: "FROM scratch" },
|
|
65
|
+
]);
|
|
66
|
+
const tasks = componentBuildTasks([{ name: "cluster", image: "k3s:latest", imageBuilds: images.builds }]);
|
|
67
|
+
const [api, worker, uncovered] = tasks;
|
|
68
|
+
expect(api.coverage).toBeDefined();
|
|
69
|
+
expect(api.image).not.toHaveProperty("coverage");
|
|
70
|
+
expect(worker.image).not.toHaveProperty("changeRules");
|
|
71
|
+
expect(evaluateChangePolicy(serviceChangePolicy(api), "packages/api/main.ts")).toBe("coverage");
|
|
72
|
+
expect(evaluateChangePolicy(serviceChangePolicy(api), "crates/worker/main.rs")).toBeUndefined();
|
|
73
|
+
expect(evaluateChangePolicy(serviceChangePolicy(worker), "crates/worker/src/main.rs")).toBe("coverage");
|
|
74
|
+
expect(evaluateChangePolicy(serviceChangePolicy(worker), "crates/worker/generated/build.rs")).toBe("ignore");
|
|
75
|
+
expect(evaluateChangePolicy(serviceChangePolicy(uncovered), "packages/uncovered/main.ts")).toBeUndefined();
|
|
76
|
+
expect(evaluateChangePolicy(serviceChangePolicy(uncovered), "packages/uncovered/CLAUDE.md")).toBe("ignore");
|
|
77
|
+
const sources = tasks.map(task => ({ context: task.image.context }));
|
|
78
|
+
expect(projectReach(["packages/api/main.ts", "docs/outside.md"], sources)).toEqual(["packages/api/main.ts"]);
|
|
79
|
+
const serialized = JSON.parse(JSON.stringify(images.builds));
|
|
80
|
+
expect(serialized[0].coverage.adapters[0].changeRules).toBeDefined();
|
|
81
|
+
expect(serialized[1].changeRules[0].action).toBe("ignore");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("cluster overrides reach its image tasks unless an image specializes them", () => {
|
|
85
|
+
const images = kubernetesImages([
|
|
86
|
+
{ reference: "app/a:1", content: "FROM scratch" },
|
|
87
|
+
{ reference: "app/b:1", content: "FROM scratch", changeRules: false },
|
|
88
|
+
]);
|
|
89
|
+
const tasks = componentBuildTasks([{ name: "cluster", image: "k3s:latest", imageBuilds: images.builds,
|
|
90
|
+
changeRules: [{ pattern: "**/*.md", action: "full" as const }] }]);
|
|
91
|
+
expect(evaluateChangePolicy(serviceChangePolicy(tasks[0]), "README.md")).toBe("full");
|
|
92
|
+
expect(evaluateChangePolicy(serviceChangePolicy(tasks[1]), "README.md")).toBeUndefined();
|
|
93
|
+
expect(() => kubernetesImages([{ reference: "bad:1", content: "FROM scratch", changeRules: [{}] as never }])).toThrow();
|
|
94
|
+
});
|
|
95
|
+
|
|
54
96
|
test("only a full sha256 digest reference is immutable", () => {
|
|
55
97
|
const digest = "a".repeat(64);
|
|
56
98
|
expect(isDigestReference(`postgres@sha256:${digest}`)).toBe(true);
|