@specific.dev/spectest 0.131.16 → 0.131.17

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 CHANGED
@@ -83,6 +83,14 @@ Pub/Sub integrations are project-owned as of SDK 0.98.0. The former
83
83
  Use a local service definition or `serviceGroup()` wrapper; the repository’s
84
84
  `examples/google-pubsub` demonstrates this using public SDK APIs.
85
85
 
86
+ ## Browser coverage across navigation (0.131.17)
87
+
88
+ Browser coverage keeps script identities separate when Chromium changes renderers
89
+ during cross-site navigation, including login redirects. Anonymous browser code
90
+ cannot inherit an earlier script’s URL or source map. No application configuration
91
+ change is needed. A script identity change during capture is reported as incomplete
92
+ coverage instead of publishing uncertain attribution.
93
+
86
94
  ## Next.js coverage (0.131.16)
87
95
 
88
96
  Use `coverage.next({ appDir: "ops", sourceRoot: "." })` in your Spectest
@@ -47,8 +47,9 @@ export async function attachBrowserCoverage(cdp) {
47
47
  COLLECTORS.set(cdp, collector);
48
48
  LIVE_COLLECTORS.add(collector);
49
49
  cdp.on("Debugger.scriptParsed", (ev) => {
50
- if (!ev.url)
51
- return;
50
+ // IDs belong to a renderer, not the lifetime of this CDP session. A
51
+ // cross-site navigation can reuse an old named ID for anonymous code.
52
+ // Replace anonymous entries too, invalidating the previous URL/source/map.
52
53
  collector.scripts.set(ev.scriptId, {
53
54
  url: ev.url,
54
55
  sourceMapURL: ev.sourceMapURL || undefined,
@@ -108,6 +109,7 @@ export async function harvestBrowserCoverage(cdp, signal) {
108
109
  }
109
110
  }
110
111
  async function harvestInner(c, config, signal) {
112
+ const scriptsAtTake = new Map(c.scripts);
111
113
  let result;
112
114
  try {
113
115
  ({ result } = (await c.cdp.send("Profiler.takePreciseCoverage")));
@@ -119,12 +121,13 @@ async function harvestInner(c, config, signal) {
119
121
  throw err;
120
122
  }
121
123
  for (const script of result) {
122
- const info = c.scripts.get(script.scriptId) ?? (script.url ? { url: script.url } : undefined);
123
- if (!info)
124
+ // The profiler's URL identifies this execution. Never supply a URL from
125
+ // an earlier renderer to an anonymous result.
126
+ if (!script.url)
124
127
  continue;
125
128
  let url;
126
129
  try {
127
- url = new URL(info.url);
130
+ url = new URL(script.url);
128
131
  }
129
132
  catch {
130
133
  continue;
@@ -134,19 +137,34 @@ async function harvestInner(c, config, signal) {
134
137
  const service = config.serviceForHost(url.hostname);
135
138
  if (!service)
136
139
  continue;
140
+ let info = c.scripts.get(script.scriptId);
141
+ const before = scriptsAtTake.get(script.scriptId);
142
+ const changed = () => {
143
+ c.incomplete = new Error("Browser script identity changed during coverage capture; coverage is incomplete");
144
+ return c.incomplete;
145
+ };
146
+ if ((before && before !== info) || (info && info.url !== script.url))
147
+ throw changed();
148
+ if (!info) {
149
+ info = { url: script.url };
150
+ c.scripts.set(script.scriptId, info);
151
+ }
137
152
  if (info.source === undefined) {
138
153
  try {
139
154
  const r = (await c.cdp.send("Debugger.getScriptSource", {
140
155
  scriptId: script.scriptId,
141
156
  }));
142
157
  signal.throwIfAborted();
158
+ if (c.scripts.get(script.scriptId) !== info)
159
+ throw changed();
143
160
  info.source = r.scriptSource;
144
161
  }
145
162
  catch {
146
163
  signal.throwIfAborted();
164
+ if (c.incomplete)
165
+ throw c.incomplete;
147
166
  continue; // script gone with its document
148
167
  }
149
- c.scripts.set(script.scriptId, info);
150
168
  }
151
169
  const generated = rangesToLineCounts(info.source, script.functions);
152
170
  if (generated.size === 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.131.16",
3
+ "version": "0.131.17",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -27,7 +27,10 @@ test("an ambiguous coverage timeout is retained and a late CDP result cannot pub
27
27
  await Bun.sleep(0);
28
28
  expect(sourceReads).toBe(0);
29
29
  expect(browserCoverageReports().has("coverage-deadline-test")).toBe(false);
30
- await expect(harvestAllBrowserCoverage()).rejects.toThrow("Browser coverage capture was interrupted; coverage is incomplete");
30
+ // Other tests may also retain incomplete collectors. Global harvest must
31
+ // reject; this session must retain its own interruption reason.
32
+ await expect(harvestAllBrowserCoverage()).rejects.toThrow("coverage is incomplete");
33
+ await expect(harvestBrowserCoverage(cdp)).rejects.toThrow("Browser coverage capture was interrupted; coverage is incomplete");
31
34
  expect(takes).toBe(1);
32
35
  } finally { configureBrowserCoverage(null); }
33
36
  });
@@ -0,0 +1,87 @@
1
+ import { expect, test } from "bun:test";
2
+ import { EventEmitter } from "node:events";
3
+ import type { CDPSession } from "playwright-core";
4
+ import {
5
+ attachBrowserCoverage, configureBrowserCoverage, harvestBrowserCoverage,
6
+ takeBrowserCoverageReport,
7
+ } from "./browser-coverage.js";
8
+
9
+ function session() {
10
+ const events = new EventEmitter();
11
+ let source = "globalThis.ran = true;";
12
+ let url = "http://app.test/app.js";
13
+ const cdp = Object.assign(events, {
14
+ async send(method: string): Promise<any> {
15
+ if (method === "Profiler.takePreciseCoverage") return { result: [{ scriptId: "4", url,
16
+ functions: [{ ranges: [{ startOffset: 0, endOffset: source.length, count: 1 }] }] }] };
17
+ if (method === "Debugger.getScriptSource") return { scriptSource: source };
18
+ return {};
19
+ },
20
+ }) as unknown as CDPSession;
21
+ return { cdp, parsed(nextUrl = url, nextSource = source, sourceMapURL?: string) {
22
+ url = nextUrl; source = nextSource;
23
+ events.emit("Debugger.scriptParsed", { scriptId: "4", url, sourceMapURL });
24
+ } };
25
+ }
26
+
27
+ const inlineMap = (source: string) => "data:application/json;base64," + Buffer.from(JSON.stringify({
28
+ version: 3, sources: [source], names: [], mappings: "AAAA",
29
+ })).toString("base64");
30
+
31
+ test("a reused named ID replaces cached source maps even when its URL is unchanged", async () => {
32
+ const s = session();
33
+ configureBrowserCoverage({ serviceForHost: () => "identity-maps" });
34
+ try {
35
+ await attachBrowserCoverage(s.cdp);
36
+ s.parsed(undefined, undefined, inlineMap("src/old.ts"));
37
+ await harvestBrowserCoverage(s.cdp);
38
+ expect(takeBrowserCoverageReport("identity-maps")).toContain("SF:src/old.ts");
39
+ s.parsed(undefined, undefined, inlineMap("src/new.ts"));
40
+ await harvestBrowserCoverage(s.cdp);
41
+ const report = takeBrowserCoverageReport("identity-maps");
42
+ expect(report).toContain("SF:src/new.ts");
43
+ expect(report).not.toContain("src/old.ts");
44
+ } finally { configureBrowserCoverage(null); takeBrowserCoverageReport("identity-maps"); }
45
+ });
46
+
47
+ test("the profiler's anonymous URL cannot inherit a stale named URL", async () => {
48
+ const s = session();
49
+ configureBrowserCoverage({ serviceForHost: () => "identity-anonymous" });
50
+ try {
51
+ await attachBrowserCoverage(s.cdp);
52
+ s.parsed();
53
+ const send = s.cdp.send.bind(s.cdp);
54
+ s.cdp.send = (async (method: string) => {
55
+ if (method === "Profiler.takePreciseCoverage") {
56
+ const result = await send(method as any) as any;
57
+ result.result[0].url = "";
58
+ return result;
59
+ }
60
+ return send(method as any);
61
+ }) as CDPSession["send"];
62
+ await harvestBrowserCoverage(s.cdp);
63
+ expect(takeBrowserCoverageReport("identity-anonymous")).toBe("TN:\n");
64
+ } finally { configureBrowserCoverage(null); }
65
+ });
66
+
67
+ for (const phase of ["Profiler.takePreciseCoverage", "Debugger.getScriptSource"]) {
68
+ test(`script replacement during ${phase} retains an incomplete-capture error`, async () => {
69
+ const s = session();
70
+ const service = `identity-race-${phase}`;
71
+ configureBrowserCoverage({ serviceForHost: () => service });
72
+ try {
73
+ await attachBrowserCoverage(s.cdp);
74
+ s.parsed();
75
+ const send = s.cdp.send.bind(s.cdp);
76
+ s.cdp.send = (async (method: string) => {
77
+ const result = await send(method as any);
78
+ // The same URL and ID now refer to a different renderer's script.
79
+ if (method === phase) s.parsed(undefined, "globalThis.replacement = true;");
80
+ return result;
81
+ }) as CDPSession["send"];
82
+ await expect(harvestBrowserCoverage(s.cdp)).rejects.toThrow("identity changed during coverage capture");
83
+ expect(takeBrowserCoverageReport(service)).toBe("TN:\n");
84
+ await expect(harvestBrowserCoverage(s.cdp)).rejects.toThrow("coverage is incomplete");
85
+ } finally { configureBrowserCoverage(null); takeBrowserCoverageReport(service); }
86
+ });
87
+ }
@@ -0,0 +1,54 @@
1
+ import { expect, test } from "bun:test";
2
+ import { existsSync } from "node:fs";
3
+ import { chromium } from "playwright-core";
4
+ import {
5
+ attachBrowserCoverage, configureBrowserCoverage, harvestBrowserCoverage,
6
+ takeBrowserCoverageReport,
7
+ } from "./browser-coverage.js";
8
+
9
+ const executablePath = Bun.which("chromium") ?? Bun.which("chromium-browser") ??
10
+ Bun.which("google-chrome") ?? chromium.executablePath();
11
+
12
+ test.skipIf(!existsSync(executablePath))("cross-site script ID reuse cannot attribute anonymous code to an earlier empty script", async () => {
13
+ const emptyScript = "/ingest/static/1.438.19/exception-autocapture.js";
14
+ const map = Buffer.from(JSON.stringify({ version: 3, sources: ["src/customer.ts"], names: [], mappings: "AAAA" })).toString("base64");
15
+ const server = Bun.serve({ hostname: "0.0.0.0", port: 0, fetch(request) {
16
+ const url = new URL(request.url);
17
+ if (url.pathname === emptyScript) return new Response("", { headers: { "content-type": "application/javascript" } });
18
+ if (url.pathname === "/app.js") return new Response(`globalThis.appRan = true;\n//# sourceMappingURL=data:application/json;base64,${map}`, { headers: { "content-type": "application/javascript" } });
19
+ return new Response(url.hostname === "127.0.0.1"
20
+ ? `<script src="${emptyScript}"></script><script src="/app.js"></script><p>Application</p>`
21
+ : "<p>External login</p>", { headers: { "content-type": "text/html", "cache-control": "no-store" } });
22
+ } });
23
+ const browser = await chromium.launch({ executablePath, headless: true, args: ["--no-sandbox", "--site-per-process"] });
24
+ try {
25
+ const page = await browser.newPage();
26
+ const cdp = await page.context().newCDPSession(page);
27
+ const urls = new Map<string, string>();
28
+ let anonymousReuse = false;
29
+ cdp.on("Debugger.scriptParsed", event => {
30
+ if (urls.get(event.scriptId)?.endsWith(emptyScript) && !event.url) anonymousReuse = true;
31
+ urls.set(event.scriptId, event.url);
32
+ });
33
+ configureBrowserCoverage({ serviceForHost: host => host === "127.0.0.1" ? "navigation-test" : undefined });
34
+ await attachBrowserCoverage(cdp);
35
+ // Use different sites to force a renderer swap, then return to the app.
36
+ // Keep collected application counts across navigation until service capture.
37
+ for (const host of ["127.0.0.1", "127.0.0.2", "127.0.0.1", "127.0.0.2"]) {
38
+ await page.goto(`http://${host}:${server.port}/`);
39
+ await page.evaluate("(() => { const value = 1; return value + 1; })()");
40
+ await harvestBrowserCoverage(cdp);
41
+ }
42
+ expect(anonymousReuse).toBe(true); // Prove the regression's trigger happened.
43
+ const report = takeBrowserCoverageReport("navigation-test");
44
+ expect(report).toContain("SF:src/customer.ts\nDA:1,2\n");
45
+ expect(report).not.toContain(emptyScript);
46
+ expect(report.match(/^SF:/gm)).toHaveLength(1);
47
+ await cdp.detach();
48
+ } finally {
49
+ configureBrowserCoverage(null);
50
+ takeBrowserCoverageReport("navigation-test");
51
+ await browser.close();
52
+ server.stop(true);
53
+ }
54
+ }, 15000);
@@ -87,7 +87,9 @@ export async function attachBrowserCoverage(cdp: CDPSession): Promise<void> {
87
87
  COLLECTORS.set(cdp, collector);
88
88
  LIVE_COLLECTORS.add(collector);
89
89
  cdp.on("Debugger.scriptParsed", (ev: { scriptId: string; url: string; sourceMapURL?: string }) => {
90
- if (!ev.url) return;
90
+ // IDs belong to a renderer, not the lifetime of this CDP session. A
91
+ // cross-site navigation can reuse an old named ID for anonymous code.
92
+ // Replace anonymous entries too, invalidating the previous URL/source/map.
91
93
  collector.scripts.set(ev.scriptId, {
92
94
  url: ev.url,
93
95
  sourceMapURL: ev.sourceMapURL || undefined,
@@ -141,6 +143,7 @@ export async function harvestBrowserCoverage(cdp: CDPSession, signal?: AbortSign
141
143
  }
142
144
 
143
145
  async function harvestInner(c: Collector, config: BrowserCoverageConfig, signal: AbortSignal): Promise<void> {
146
+ const scriptsAtTake = new Map(c.scripts);
144
147
  let result: { scriptId: string; url: string; functions: V8FunctionCoverage[] }[];
145
148
  try {
146
149
  ({ result } = (await c.cdp.send("Profiler.takePreciseCoverage")) as { result: typeof result });
@@ -150,29 +153,42 @@ async function harvestInner(c: Collector, config: BrowserCoverageConfig, signal:
150
153
  throw err;
151
154
  }
152
155
  for (const script of result) {
153
- const info = c.scripts.get(script.scriptId) ?? (script.url ? { url: script.url } : undefined);
154
- if (!info) continue;
156
+ // The profiler's URL identifies this execution. Never supply a URL from
157
+ // an earlier renderer to an anonymous result.
158
+ if (!script.url) continue;
155
159
  let url: URL;
156
160
  try {
157
- url = new URL(info.url);
161
+ url = new URL(script.url);
158
162
  } catch {
159
163
  continue;
160
164
  }
161
165
  if (url.protocol !== "http:" && url.protocol !== "https:") continue;
162
166
  const service = config.serviceForHost(url.hostname);
163
167
  if (!service) continue;
168
+ let info = c.scripts.get(script.scriptId);
169
+ const before = scriptsAtTake.get(script.scriptId);
170
+ const changed = () => {
171
+ c.incomplete = new Error("Browser script identity changed during coverage capture; coverage is incomplete");
172
+ return c.incomplete;
173
+ };
174
+ if ((before && before !== info) || (info && info.url !== script.url)) throw changed();
175
+ if (!info) {
176
+ info = { url: script.url };
177
+ c.scripts.set(script.scriptId, info);
178
+ }
164
179
  if (info.source === undefined) {
165
180
  try {
166
181
  const r = (await c.cdp.send("Debugger.getScriptSource", {
167
182
  scriptId: script.scriptId,
168
183
  })) as { scriptSource: string };
169
184
  signal.throwIfAborted();
185
+ if (c.scripts.get(script.scriptId) !== info) throw changed();
170
186
  info.source = r.scriptSource;
171
187
  } catch {
172
188
  signal.throwIfAborted();
189
+ if (c.incomplete) throw c.incomplete;
173
190
  continue; // script gone with its document
174
191
  }
175
- c.scripts.set(script.scriptId, info);
176
192
  }
177
193
  const generated = rangesToLineCounts(info.source, script.functions);
178
194
  if (generated.size === 0) continue;