@piwitests/reporter 0.60.0 → 0.61.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/index.js CHANGED
@@ -203,6 +203,7 @@ var PIWI_ENV_KEYS = {
203
203
  codeReachRoots: "PIWI_CODE_REACH_ROOTS",
204
204
  coverageBrowser: "PIWI_COVERAGE_BROWSER",
205
205
  coverageServer: "PIWI_COVERAGE_SERVER",
206
+ coverageCss: "PIWI_COVERAGE_CSS",
206
207
  externalIdPattern: "PIWI_EXTERNAL_ID_PATTERN",
207
208
  externalIdOnDuplicate: "PIWI_EXTERNAL_ID_ON_DUPLICATE",
208
209
  captureServerTraces: "PIWI_CAPTURE_SERVER_TRACES",
@@ -357,10 +358,14 @@ function resolveOptions(raw, configDir) {
357
358
  const coverage = { ...mergedRaw.coverage };
358
359
  const coverageBrowser = env[PIWI_ENV_KEYS.coverageBrowser];
359
360
  const coverageServer = env[PIWI_ENV_KEYS.coverageServer];
361
+ const coverageCss = env[PIWI_ENV_KEYS.coverageCss];
360
362
  if (coverage.browser === void 0 && coverageBrowser)
361
363
  coverage.browser = coverageBrowser;
362
364
  if (coverage.server === void 0 && coverageServer) coverage.server = coverageServer;
363
- if (coverage.browser !== void 0 || coverage.server !== void 0) mergedRaw.coverage = coverage;
365
+ if (coverage.css === void 0 && coverageCss) coverage.css = coverageCss;
366
+ if (coverage.browser !== void 0 || coverage.server !== void 0 || coverage.css !== void 0) {
367
+ mergedRaw.coverage = coverage;
368
+ }
364
369
  const externalId = { ...mergedRaw.externalId };
365
370
  const externalIdPattern = env[PIWI_ENV_KEYS.externalIdPattern];
366
371
  const externalIdOnDuplicate = env[PIWI_ENV_KEYS.externalIdOnDuplicate];
@@ -406,9 +411,11 @@ function applyOptionsToEnv(options) {
406
411
  if (options.collectPerformanceMetrics === false) {
407
412
  env[PIWI_ENV_KEYS.coverageBrowser] = "off";
408
413
  env[PIWI_ENV_KEYS.coverageServer] = "off";
414
+ env[PIWI_ENV_KEYS.coverageCss] = "off";
409
415
  } else {
410
416
  if (options.coverage?.browser !== void 0) env[PIWI_ENV_KEYS.coverageBrowser] = options.coverage.browser;
411
417
  if (options.coverage?.server !== void 0) env[PIWI_ENV_KEYS.coverageServer] = options.coverage.server;
418
+ if (options.coverage?.css !== void 0) env[PIWI_ENV_KEYS.coverageCss] = options.coverage.css;
412
419
  }
413
420
  if (options.captureServerTraces === false || options.collectPerformanceMetrics === false)
414
421
  env[PIWI_ENV_KEYS.captureServerTraces] = "false";
@@ -1312,6 +1319,8 @@ var ATTACHMENT_NAMES = {
1312
1319
  codeReach: "piwi-code-reach",
1313
1320
  browserCoverage: "piwi-browser-coverage",
1314
1321
  browserFunctions: "piwi-browser-functions",
1322
+ cssCoverage: "piwi-css-coverage",
1323
+ cssRules: "piwi-css-rules",
1315
1324
  serverCoverageRequests: "piwi-server-coverage-requests",
1316
1325
  webVitals: "piwi-web-vitals",
1317
1326
  locatorSuggestion: "piwi-locator-suggestion",
@@ -1478,10 +1487,10 @@ var FileHandler = class {
1478
1487
  /** Parse Piwi-internal attachment bodies (`piwi-network`, `piwi-web-vitals`, `piwi-console`, `piwi-aria-snapshot`, `piwi-aria-snapshot-json`) into structured fields on the test case */
1479
1488
  parsePerformanceAttachments(testCase, attachments) {
1480
1489
  const find = (name) => attachments.find((a) => a.name === name);
1481
- const net2 = find(ATTACHMENT_NAMES.network);
1482
- if (net2?.body) {
1490
+ const net3 = find(ATTACHMENT_NAMES.network);
1491
+ if (net3?.body) {
1483
1492
  try {
1484
- testCase.networkRequests = JSON.parse(net2.body.toString());
1493
+ testCase.networkRequests = JSON.parse(net3.body.toString());
1485
1494
  } catch {
1486
1495
  }
1487
1496
  }
@@ -2119,6 +2128,8 @@ var StreamManager = class {
2119
2128
  this._token = null;
2120
2129
  this._auth = null;
2121
2130
  this._startPromise = null;
2131
+ /** Called when the dashboard says someone opened the run. */
2132
+ this.onWatched = null;
2122
2133
  const maxBytes = this.options.maxStreamBufferBytes ?? 0;
2123
2134
  this.pendingEvents = new BoundedEventQueue(maxBytes);
2124
2135
  this.pendingBeginEvents = new BoundedEventQueue(maxBytes);
@@ -2143,6 +2154,10 @@ var StreamManager = class {
2143
2154
  get startPromise() {
2144
2155
  return this._startPromise;
2145
2156
  }
2157
+ /** Whether anyone has the run open live, as the dashboard last said; `null` until it says. */
2158
+ get isWatched() {
2159
+ return this.watched;
2160
+ }
2146
2161
  /**
2147
2162
  * Whether a test-result (`complete`) event never reached the server through
2148
2163
  * the live stream: shed under buffer pressure, too large for any request the
@@ -2393,8 +2408,11 @@ var StreamManager = class {
2393
2408
  if (this.pendingEvents.length > 0) this.flush();
2394
2409
  }, delay);
2395
2410
  }
2396
- // Take the dashboard's answer on whether anyone watches the run. An answer
2397
- // without the flag (an older dashboard) keeps step events flowing.
2411
+ /**
2412
+ * Take the dashboard's answer on whether anyone watches the run, from any
2413
+ * request of the stream. An answer without the flag (an older dashboard)
2414
+ * keeps step events flowing.
2415
+ */
2398
2416
  noteWatched(response) {
2399
2417
  const watched = response?.watched;
2400
2418
  const next = typeof watched === "boolean" ? watched : null;
@@ -2403,7 +2421,9 @@ var StreamManager = class {
2403
2421
  next ? "The run is open on the dashboard: streaming live steps." : "Nobody is watching the run: live steps are not sent."
2404
2422
  );
2405
2423
  }
2424
+ const opened = next === true && this.watched !== true;
2406
2425
  this.watched = next;
2426
+ if (opened) this.onWatched?.();
2407
2427
  }
2408
2428
  // Idle time after which a heartbeat goes out.
2409
2429
  heartbeatDelay() {
@@ -3903,7 +3923,7 @@ function collectSourceFrames(errorText, testFile2, declLine, opts = {}) {
3903
3923
  const maxFrames = opts.maxFrames ?? 4;
3904
3924
  const picked = [];
3905
3925
  const seen = /* @__PURE__ */ new Set();
3906
- const add = (absFile, line) => {
3926
+ const add2 = (absFile, line) => {
3907
3927
  const key = `${absFile}:${line}`;
3908
3928
  if (seen.has(key)) return;
3909
3929
  seen.add(key);
@@ -3920,12 +3940,12 @@ function collectSourceFrames(errorText, testFile2, declLine, opts = {}) {
3920
3940
  }
3921
3941
  const rel = path11.relative(projectRoot, abs);
3922
3942
  if (rel.startsWith("..") || path11.isAbsolute(rel)) continue;
3923
- if (!isNaN(frame.line)) add(abs, frame.line);
3943
+ if (!isNaN(frame.line)) add2(abs, frame.line);
3924
3944
  }
3925
3945
  }
3926
3946
  const absTest = path11.resolve(testFile2);
3927
3947
  if (!picked.some((p) => p.absFile === absTest)) {
3928
- add(absTest, extractFailingLine(errorText, testFile2, declLine));
3948
+ add2(absTest, extractFailingLine(errorText, testFile2, declLine));
3929
3949
  }
3930
3950
  const frames = [];
3931
3951
  for (const p of picked.slice(0, maxFrames)) {
@@ -4085,6 +4105,7 @@ function isDueForAriaSample(testInfo) {
4085
4105
  }
4086
4106
 
4087
4107
  // src/internal/support/base-url-check.ts
4108
+ var net = __toESM(require("net"));
4088
4109
  var BASE_URL_CHECK_ATTEMPTS = 3;
4089
4110
  var BASE_URL_CHECK_TIMEOUT_MS = 1e4;
4090
4111
  var BASE_URL_CHECK_DELAY_MS = 2e3;
@@ -4106,9 +4127,9 @@ function cliProjectNames(argv = process.argv) {
4106
4127
  }
4107
4128
  return names;
4108
4129
  }
4109
- function wildcard(pattern) {
4130
+ function wildcard(pattern, flags = "i") {
4110
4131
  const escaped = pattern.split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
4111
- return new RegExp(`^${escaped.join(".*")}$`, "i");
4132
+ return new RegExp(`^${escaped.join(".*")}$`, flags);
4112
4133
  }
4113
4134
  function selectedProjects(projects, argv = process.argv, filtered) {
4114
4135
  let chosen;
@@ -4118,7 +4139,7 @@ function selectedProjects(projects, argv = process.argv, filtered) {
4118
4139
  } else {
4119
4140
  const names = cliProjectNames(argv);
4120
4141
  if (!names || names.length === 0) return projects;
4121
- const patterns = names.map(wildcard);
4142
+ const patterns = names.map((name) => wildcard(name));
4122
4143
  chosen = new Set(projects.filter((p) => patterns.some((re) => re.test(p.name ?? ""))));
4123
4144
  }
4124
4145
  if (!argv.includes("--no-deps")) {
@@ -4140,23 +4161,58 @@ function asProxy(value) {
4140
4161
  const proxy = value;
4141
4162
  return proxy && typeof proxy === "object" && typeof proxy.server === "string" ? proxy : void 0;
4142
4163
  }
4143
- function baseUrlTargets(config, argv = process.argv) {
4164
+ function exposeNetworkCovers(rule, url) {
4165
+ if (!rule) return false;
4166
+ let parsed2;
4167
+ try {
4168
+ parsed2 = new URL(url);
4169
+ } catch {
4170
+ return false;
4171
+ }
4172
+ const host = parsed2.hostname;
4173
+ const port = parsed2.port ? Number(parsed2.port) : parsed2.protocol === "https:" ? 443 : 80;
4174
+ return rule.split(",").some((token) => {
4175
+ const match = token.match(/^(.*?)(?::(\d+))?$/);
4176
+ if (!match) return false;
4177
+ if (match[2] !== void 0 && Number(match[2]) !== port) return false;
4178
+ const tokenHost = match[1];
4179
+ if (tokenHost === "<loopback>") {
4180
+ return host === "localhost" || host.endsWith(".localhost") || host === "127.0.0.1" || host === "[::1]";
4181
+ }
4182
+ if (tokenHost === "*") return true;
4183
+ if (net.isIP(tokenHost)) return host === tokenHost;
4184
+ if (net.isIP(host)) return false;
4185
+ return wildcard(tokenHost.startsWith(".") ? `*${tokenHost}` : tokenHost, "").test(host);
4186
+ });
4187
+ }
4188
+ function reachedRemotely(use, url, env) {
4189
+ if (use?.connectOptions === void 0) {
4190
+ return !!env.PW_TEST_CONNECT_WS_ENDPOINT && !exposeNetworkCovers(env.PW_TEST_CONNECT_EXPOSE_NETWORK, url);
4191
+ }
4192
+ const connect2 = use.connectOptions;
4193
+ if (!connect2 || typeof connect2 !== "object" || typeof connect2.wsEndpoint !== "string") return false;
4194
+ return !exposeNetworkCovers(typeof connect2.exposeNetwork === "string" ? connect2.exposeNetwork : void 0, url);
4195
+ }
4196
+ function baseUrlTargets(config, argv = process.argv, env = process.env) {
4144
4197
  const projects = config.projects?.length ? config.projects : [{ name: "", use: config.use }];
4145
4198
  const filtered = config.projects?.length ? config.filteredProjects : void 0;
4146
4199
  const targets = /* @__PURE__ */ new Map();
4147
4200
  for (const project of selectedProjects(projects, argv, filtered)) {
4148
4201
  const url = project.use?.baseURL;
4149
4202
  if (typeof url !== "string" || !/^https?:\/\//i.test(url)) continue;
4203
+ const remoteBrowser = reachedRemotely(project.use, url, env);
4150
4204
  const target = targets.get(url);
4151
4205
  if (target) {
4152
4206
  if (project.name) target.projects.push(project.name);
4207
+ target.remoteBrowser ||= remoteBrowser;
4153
4208
  continue;
4154
4209
  }
4155
4210
  targets.set(url, {
4156
4211
  url,
4157
4212
  projects: project.name ? [project.name] : [],
4158
4213
  ignoreHTTPSErrors: project.use?.ignoreHTTPSErrors === true,
4159
- proxy: asProxy(project.use?.proxy)
4214
+ proxy: asProxy(project.use?.proxy),
4215
+ remoteBrowser
4160
4216
  });
4161
4217
  }
4162
4218
  return [...targets.values()];
@@ -4197,14 +4253,21 @@ async function checkBaseUrl(target, options = {}) {
4197
4253
  }
4198
4254
  return `${reason} (${attempts} ${attempts === 1 ? "attempt" : "attempts"})`;
4199
4255
  }
4256
+ function remoteBrowserNote(target) {
4257
+ if (!target.remoteBrowser) return null;
4258
+ const { host } = new URL(target.url);
4259
+ return `The check ran from this machine's network, but a project's remote browser reaches ${host} from its own, since its exposeNetwork rule does not cover that address.`;
4260
+ }
4200
4261
  async function checkBaseUrls(config, options = {}) {
4201
- const targets = baseUrlTargets(config, options.argv);
4262
+ const targets = baseUrlTargets(config, options.argv, options.env);
4202
4263
  const reasons = await Promise.all(targets.map((target) => checkBaseUrl(target, options)));
4203
4264
  const down = targets.flatMap((target, i) => reasons[i] ? [{ target, reason: reasons[i] }] : []);
4204
4265
  if (down.length === 0) return targets;
4205
4266
  const lines = down.map(({ target, reason }) => {
4206
4267
  const used = target.projects.length > 0 ? ` (${target.projects.join(", ")})` : "";
4207
- return ` ${target.url}${used}: ${reason}`;
4268
+ const note = remoteBrowserNote(target);
4269
+ return ` ${target.url}${used}: ${reason}${note ? `
4270
+ ${note}` : ""}`;
4208
4271
  });
4209
4272
  const message = [
4210
4273
  `[Piwi Dashboard] The app under test did not answer, so no test ran:`,
@@ -4709,7 +4772,7 @@ function instanceOf(answer) {
4709
4772
  return { id: instance.id, startedAt };
4710
4773
  }
4711
4774
  function processKeyOf(id) {
4712
- const dot = id.lastIndexOf(".");
4775
+ const dot = id.indexOf(".");
4713
4776
  return dot > 0 ? id.slice(0, dot) : null;
4714
4777
  }
4715
4778
  function withTestCoverage(files, tests) {
@@ -5127,7 +5190,16 @@ function createGlobalSetup(options, userSetup) {
5127
5190
  }
5128
5191
  if (opts.checkBaseUrl === true) {
5129
5192
  const checked = await checkBaseUrls(config ?? {});
5130
- for (const target of checked) logger.debug(`Base URL answered: ${target.url}`);
5193
+ for (const target of checked) {
5194
+ const note = remoteBrowserNote(target);
5195
+ if (note) {
5196
+ logger.info(
5197
+ `Base URL answered: ${target.url}. ${note} A navigation failure from that browser can mean its machine cannot reach the app, not that the app is down.`
5198
+ );
5199
+ } else {
5200
+ logger.debug(`Base URL answered: ${target.url}`);
5201
+ }
5202
+ }
5131
5203
  }
5132
5204
  if (opts.enabled === false || !opts.serverUrl) {
5133
5205
  logger.info("Not enabled \u2014 set PIWI_DASHBOARD_URL or serverUrl to enable.");
@@ -8446,6 +8518,12 @@ async function dirBytes(dir, budget) {
8446
8518
  }
8447
8519
  return bytes;
8448
8520
  }
8521
+ function memoryOf(container) {
8522
+ const totalMemory = os8.totalmem();
8523
+ const constrained = typeof process.constrainedMemory === "function" ? process.constrainedMemory() : void 0;
8524
+ const limit = container?.memoryLimitBytes ?? (constrained && constrained > 0 ? constrained : null);
8525
+ return { memoryBytes: totalMemory, memoryLimitBytes: limit !== null && limit < totalMemory ? limit : null };
8526
+ }
8449
8527
  var RunSampler = class {
8450
8528
  constructor(options = {}) {
8451
8529
  this.timer = null;
@@ -8514,6 +8592,18 @@ var RunSampler = class {
8514
8592
  }
8515
8593
  return this.profile(await this.leftoverBytes());
8516
8594
  }
8595
+ /**
8596
+ * What a live run's memory is drawn against: the machine's memory, or its
8597
+ * container's limit when lower, read once, and how the run's memory is
8598
+ * measured so far.
8599
+ */
8600
+ liveMemory() {
8601
+ if (this.capacityBytes === void 0) {
8602
+ const machine = memoryOf(this.linux ? this.cgroup.read() : null);
8603
+ this.capacityBytes = machine.memoryLimitBytes ?? machine.memoryBytes;
8604
+ }
8605
+ return { capacityBytes: this.capacityBytes || null, kind: this.memoryKind };
8606
+ }
8517
8607
  /** The series read so far, each point at the time it was read. */
8518
8608
  timedSeries() {
8519
8609
  return {
@@ -8810,9 +8900,6 @@ var RunSampler = class {
8810
8900
  if (cpuPressure === null) notMeasured.push("CPU pressure");
8811
8901
  const container = this.linux ? this.cgroup.read() : null;
8812
8902
  const startContainer = this.containerStart;
8813
- const totalMemory = os8.totalmem();
8814
- const constrained = typeof process.constrainedMemory === "function" ? process.constrainedMemory() : void 0;
8815
- const limit = container?.memoryLimitBytes ?? (constrained && constrained > 0 ? constrained : null);
8816
8903
  const raisedPeak = container?.memoryPeakBytes != null && startContainer?.memoryPeakBytes != null && container.memoryPeakBytes > startContainer.memoryPeakBytes ? container.memoryPeakBytes : null;
8817
8904
  const delta = (to, from) => to != null && from != null ? Math.max(0, to - from) : null;
8818
8905
  return {
@@ -8820,8 +8907,7 @@ var RunSampler = class {
8820
8907
  wallMs,
8821
8908
  machine: {
8822
8909
  cores: typeof os8.availableParallelism === "function" ? os8.availableParallelism() : os8.cpus().length,
8823
- memoryBytes: totalMemory,
8824
- memoryLimitBytes: limit !== null && limit < totalMemory ? limit : null,
8910
+ ...memoryOf(container),
8825
8911
  cpuQuotaCores: container?.cpuQuotaCores ?? null
8826
8912
  },
8827
8913
  cpu: {
@@ -9093,7 +9179,7 @@ function openPagesByWorker(censuses) {
9093
9179
  }
9094
9180
  return out;
9095
9181
  }
9096
- function pagesOverTime(censuses) {
9182
+ function pagesOverTime(censuses, live = false) {
9097
9183
  const byWorker = /* @__PURE__ */ new Map();
9098
9184
  for (const census of censuses) {
9099
9185
  const list = byWorker.get(census.worker) ?? [];
@@ -9126,7 +9212,7 @@ function pagesOverTime(censuses) {
9126
9212
  if (last && last[0] === at) last[1] = open;
9127
9213
  else events.push([at, open]);
9128
9214
  }
9129
- if (open > 0 && lastAt >= events[events.length - 1][0]) events.push([lastAt, 0]);
9215
+ if (!live && open > 0 && lastAt >= events[events.length - 1][0]) events.push([lastAt, 0]);
9130
9216
  out.push({ worker, events });
9131
9217
  }
9132
9218
  return out;
@@ -9195,6 +9281,144 @@ function resourceReportWire(input) {
9195
9281
  };
9196
9282
  }
9197
9283
 
9284
+ // src/internal/streaming/live-resources.ts
9285
+ var LIVE_INTERVAL_MS = 5e3;
9286
+ function since(points, cursor, cap, merge) {
9287
+ const fresh = cursor === void 0 ? points : points.filter(([at]) => at > cursor);
9288
+ return downsamplePoints(fresh, cap, merge);
9289
+ }
9290
+ var LiveResources = class {
9291
+ constructor(stream, httpClient, options, logger = new Logger()) {
9292
+ this.stream = stream;
9293
+ this.httpClient = httpClient;
9294
+ this.options = options;
9295
+ this.logger = logger;
9296
+ this.timer = null;
9297
+ this.inFlight = null;
9298
+ this.stopped = false;
9299
+ /** Whether the next post must carry every point: the first one, and after the dashboard lost them. */
9300
+ this.reset = true;
9301
+ /** The time of the last point the dashboard took, per series. */
9302
+ this.sent = /* @__PURE__ */ new Map();
9303
+ /** How many censuses the open pages last posted came from. */
9304
+ this.censusCount = 0;
9305
+ }
9306
+ /** Post on a timer that never keeps the process alive. */
9307
+ start() {
9308
+ if (this.timer || this.stopped) return;
9309
+ this.timer = setInterval(() => this.tick(), this.options.intervalMs ?? LIVE_INTERVAL_MS);
9310
+ this.timer.unref?.();
9311
+ }
9312
+ /** Post nothing more, and wait for a post in flight. */
9313
+ async stop() {
9314
+ this.stopped = true;
9315
+ if (this.timer) clearInterval(this.timer);
9316
+ this.timer = null;
9317
+ await this.inFlight;
9318
+ }
9319
+ /** Post now, when the run is watched and no post is in flight: the dashboard says someone just opened it. */
9320
+ tick() {
9321
+ if (this.stopped || this.inFlight) return null;
9322
+ const { enabled, runId, token } = this.stream;
9323
+ if (!enabled || runId === null || !token || this.stream.isWatched !== true) return null;
9324
+ let draft;
9325
+ try {
9326
+ draft = this.draft();
9327
+ } catch (error) {
9328
+ this.logger.debug(`Live resources not read: ${errorMessage(error)}`);
9329
+ return null;
9330
+ }
9331
+ if (!draft) return null;
9332
+ this.inFlight = this.send(runId, token, draft).finally(() => {
9333
+ this.inFlight = null;
9334
+ });
9335
+ return this.inFlight;
9336
+ }
9337
+ /** What is new since the last post the dashboard took; null when nothing is. */
9338
+ draft() {
9339
+ const samples = this.options.sampler?.timedSeries() ?? null;
9340
+ const startedAt = samples?.startedAt || this.options.runStartedAt;
9341
+ const censuses = this.options.censuses();
9342
+ const cursors = this.reset ? /* @__PURE__ */ new Map() : this.sent;
9343
+ const sent = new Map(cursors);
9344
+ let fresh = false;
9345
+ const take = (key, points, cap, merge) => {
9346
+ const out = since(points, cursors.get(key), cap, merge);
9347
+ const last = points[points.length - 1];
9348
+ if (out.length > 0 && last) {
9349
+ sent.set(key, last[0]);
9350
+ fresh = true;
9351
+ }
9352
+ return out;
9353
+ };
9354
+ const timeline = { startedAt, cpuPct: [], memoryBytes: [], pages: [], workers: [] };
9355
+ if (samples) {
9356
+ timeline.cpuPct = take("cpu", samples.cpuPct, TIMELINE_SERIES_CAP, "mean");
9357
+ timeline.memoryBytes = take("memory", samples.memoryBytes, TIMELINE_SERIES_CAP, "max");
9358
+ for (const tree of workerTreesByIndex(samples, censuses)) {
9359
+ const cpuCores = take(`cores:${tree.worker}`, tree.cpuCores, TIMELINE_WORKER_POINTS_CAP, "mean");
9360
+ const memoryBytes = take(`bytes:${tree.worker}`, tree.memoryBytes, TIMELINE_WORKER_POINTS_CAP, "max");
9361
+ if (cpuCores.length > 0 || memoryBytes.length > 0) {
9362
+ timeline.workers.push({ worker: tree.worker, cpuCores, memoryBytes });
9363
+ }
9364
+ }
9365
+ }
9366
+ if (this.reset || censuses.length !== this.censusCount) {
9367
+ for (const { worker, events } of pagesOverTime(censuses, true)) {
9368
+ const points = events.map(([at, open]) => [at - startedAt, open]);
9369
+ const last = points.pop();
9370
+ timeline.pages.push({
9371
+ worker,
9372
+ points: [...downsamplePoints(points, TIMELINE_WORKER_POINTS_CAP - 1, "max"), last]
9373
+ });
9374
+ }
9375
+ fresh ||= timeline.pages.length > 0;
9376
+ }
9377
+ if (!fresh) return null;
9378
+ const memory = this.options.sampler?.liveMemory() ?? { capacityBytes: null, kind: null };
9379
+ return {
9380
+ post: {
9381
+ shardIndex: this.options.shardIndex,
9382
+ reset: this.reset,
9383
+ timeline,
9384
+ memoryCapacityBytes: memory.capacityBytes,
9385
+ memoryKind: memory.kind
9386
+ },
9387
+ sent,
9388
+ censusCount: censuses.length
9389
+ };
9390
+ }
9391
+ async send(runId, token, draft) {
9392
+ try {
9393
+ const answer = await this.httpClient.postJSON(
9394
+ `/api/test-runs/${runId}/live-resources`,
9395
+ { streamToken: token, ...draft.post },
9396
+ this.stream.auth
9397
+ );
9398
+ if (answer?.declined) {
9399
+ this.logger.debug("The project declined resources: live resources are not sent.");
9400
+ this.stopped = true;
9401
+ return;
9402
+ }
9403
+ this.stream.noteWatched(answer);
9404
+ if (answer?.resync) {
9405
+ this.reset = true;
9406
+ return;
9407
+ }
9408
+ this.sent = draft.sent;
9409
+ this.censusCount = draft.censusCount;
9410
+ this.reset = false;
9411
+ } catch (error) {
9412
+ if (error instanceof HttpError && error.status === 404) {
9413
+ this.logger.debug("The dashboard takes no live resources.");
9414
+ this.stopped = true;
9415
+ return;
9416
+ }
9417
+ this.logger.debug(`Live resources not sent: ${errorMessage(error)}`);
9418
+ }
9419
+ }
9420
+ };
9421
+
9198
9422
  // ../core/src/describe-failure.ts
9199
9423
  var HEADLINE_MAX_CHARS = 120;
9200
9424
  var VALUE_MAX_CHARS = 40;
@@ -9724,10 +9948,14 @@ function isServerGranularity(value) {
9724
9948
  return typeof value === "string" && SERVER_VALUES.has(value);
9725
9949
  }
9726
9950
  function coverageSettings(options) {
9727
- if (options.collectPerformanceMetrics === false) return { browser: "off", server: "off" };
9951
+ if (options.collectPerformanceMetrics === false) return { browser: "off", server: "off", css: "off" };
9728
9952
  const browser = options.coverage?.browser === "functions" ? "functions" : options.coverage?.browser === "files" || options.captureCodeReach === true ? "files" : "off";
9729
9953
  const server = isServerGranularity(options.coverage?.server) ? options.coverage.server : "off";
9730
- return { browser, server };
9954
+ const css = options.coverage?.css === "rules" ? "rules" : "off";
9955
+ return { browser, server, css };
9956
+ }
9957
+ function cssCoverageInWorker() {
9958
+ return process.env[PIWI_ENV_KEYS.coverageCss] === "rules";
9731
9959
  }
9732
9960
  function browserCoverageInWorker() {
9733
9961
  const browser = process.env[PIWI_ENV_KEYS.coverageBrowser];
@@ -9847,10 +10075,10 @@ function joinRoot(root, source) {
9847
10075
  function decodeLines(raw) {
9848
10076
  const sources = [];
9849
10077
  const decoded = [];
9850
- const add = (map, lineOffset, columnOffset) => {
10078
+ const add2 = (map, lineOffset, columnOffset) => {
9851
10079
  if (map.sections) {
9852
10080
  for (const section of map.sections) {
9853
- if (!add(section.map, section.offset.line, section.offset.column)) return false;
10081
+ if (!add2(section.map, section.offset.line, section.offset.column)) return false;
9854
10082
  }
9855
10083
  return true;
9856
10084
  }
@@ -9871,7 +10099,7 @@ function decodeLines(raw) {
9871
10099
  });
9872
10100
  return true;
9873
10101
  };
9874
- return add(raw, 0, 0) ? { sources, lines: decoded } : null;
10102
+ return add2(raw, 0, 0) ? { sources, lines: decoded } : null;
9875
10103
  }
9876
10104
  function compactSegments(lines) {
9877
10105
  let total = 0;
@@ -10047,7 +10275,7 @@ function functionLines(entry, map, text, all) {
10047
10275
  const read = textOf(entry, text);
10048
10276
  const ranges = executedFunctionRanges(entry, read?.length, all);
10049
10277
  if (!ranges.length || !read) return out;
10050
- const add = (source, range) => {
10278
+ const add2 = (source, range) => {
10051
10279
  const list = out.get(source);
10052
10280
  if (list) list.push(range);
10053
10281
  else out.set(source, [range]);
@@ -10057,7 +10285,7 @@ function functionLines(entry, map, text, all) {
10057
10285
  const start = offsetToPosition(starts, range.start);
10058
10286
  if (!map) {
10059
10287
  const last = offsetToPosition(starts, Math.max(range.start, range.end - 1));
10060
- add(-1, [start.line + 1, last.line + 1]);
10288
+ add2(-1, [start.line + 1, last.line + 1]);
10061
10289
  continue;
10062
10290
  }
10063
10291
  const at = map.originalAt(start.line, start.column);
@@ -10067,7 +10295,7 @@ function functionLines(entry, map, text, all) {
10067
10295
  if (first === null) continue;
10068
10296
  const header = first === at.line || first === at.line + 1 ? at.line : first;
10069
10297
  const lines = map.originalLinesBetween(at.source, start, end, header);
10070
- if (lines) add(at.source, [header + 1, lines[1] + 1]);
10298
+ if (lines) add2(at.source, [header + 1, lines[1] + 1]);
10071
10299
  }
10072
10300
  return out;
10073
10301
  }
@@ -10261,7 +10489,7 @@ async function resolveFunctions(entries, roots, fetchMap, pageUrl, withKnown) {
10261
10489
  if (listed) listed.push(...functions);
10262
10490
  else into.set(path34, [...functions]);
10263
10491
  };
10264
- const add = (file, functions) => addTo(files, file, functions);
10492
+ const add2 = (file, functions) => addTo(files, file, functions);
10265
10493
  const bases = viteBases(entries);
10266
10494
  const scripts = entries.filter((entry) => entry.url && entry.functions?.length && !REPLAYED_SCRIPT.test(entry.url));
10267
10495
  const cached = scripts.map((entry) => scriptCache.get(entry.url, scriptVersion(entry)));
@@ -10288,14 +10516,14 @@ async function resolveFunctions(entries, roots, fetchMap, pageUrl, withKnown) {
10288
10516
  const text = textOf2(covered, resolution);
10289
10517
  if (resolution.lookup) {
10290
10518
  const reached = reachedFunctionLines(covered, resolution.lookup, text);
10291
- if (reached.size > 0) add(resolution.file, [...reached.values()].flat());
10519
+ if (reached.size > 0) add2(resolution.file, [...reached.values()].flat());
10292
10520
  } else if (scriptRan(covered, text)) {
10293
- add(resolution.file, reachedFunctionLines(covered, null, text).get(-1) ?? []);
10521
+ add2(resolution.file, reachedFunctionLines(covered, null, text).get(-1) ?? []);
10294
10522
  }
10295
10523
  } else if (resolution.kind === "map") {
10296
10524
  for (const [index, functions] of reachedFunctionLines(covered, resolution.lookup, textOf2(covered, resolution))) {
10297
10525
  const file = mapFile(resolution, index);
10298
- if (file) add(file, functions);
10526
+ if (file) add2(file, functions);
10299
10527
  }
10300
10528
  }
10301
10529
  if (!withKnown || resolution.kind === "none") continue;
@@ -10374,10 +10602,13 @@ async function markCurrentDocument(page, cdp) {
10374
10602
  }
10375
10603
  function onPaused(page, capture, event) {
10376
10604
  const leaving = event.reason === "EventListener" && event.data?.eventName === "listener:beforeunload" && (event.callFrames?.[0]?.functionName ?? MARK_FUNCTION) === MARK_FUNCTION;
10377
- const read = leaving ? readCoverage(page, capture.cdp) : null;
10605
+ const read = leaving ? Promise.all([
10606
+ capture.js ? readCoverage(page, capture.cdp) : Promise.resolve([]),
10607
+ capture.css ? readCssUsage(page, capture.cdp, capture.css) : Promise.resolve()
10608
+ ]) : null;
10378
10609
  if (read) {
10379
10610
  const merging = read.then(
10380
- (coverage) => mergeCoverageRead(capture.ran, capture.scripts, coverage, capture.keepIdle)
10611
+ ([coverage]) => mergeCoverageRead(capture.ran, capture.scripts, coverage, capture.keepIdle)
10381
10612
  );
10382
10613
  capture.reading.add(merging);
10383
10614
  void merging.finally(() => capture.reading.delete(merging));
@@ -10391,7 +10622,7 @@ function versionKey(url, hash) {
10391
10622
  return `${hash} ${url}`;
10392
10623
  }
10393
10624
  function onScriptParsed(page, capture, event) {
10394
- if (!event.url || REPLAYED_SCRIPT.test(event.url)) return;
10625
+ if (!capture.js || !event.url || REPLAYED_SCRIPT.test(event.url)) return;
10395
10626
  capture.scripts.set(event.scriptId, { url: event.url, hash: event.hash, pageUrl: capture.pageUrl });
10396
10627
  if (NEVER_REACHED.test(event.url)) return;
10397
10628
  const key = versionKey(event.url, event.hash);
@@ -10420,6 +10651,53 @@ function readCoverage(page, cdp) {
10420
10651
  );
10421
10652
  return Promise.race([taken, late]).finally(() => clearTimeout(timer));
10422
10653
  }
10654
+ function onStyleSheetAdded(page, cdp, css, pageUrl, header) {
10655
+ if (header.isConstructed) return;
10656
+ const sheet = {
10657
+ sourceURL: header.sourceURL,
10658
+ ...header.sourceMapURL ? { sourceMapURL: header.sourceMapURL } : {},
10659
+ isInline: header.isInline,
10660
+ startLine: header.startLine,
10661
+ pageUrl,
10662
+ used: /* @__PURE__ */ new Map()
10663
+ };
10664
+ css.sheets.set(header.styleSheetId, sheet);
10665
+ const reading = (async () => {
10666
+ try {
10667
+ if (header.ownerNode !== void 0 && !/\.css(?:[?#]|$)/i.test(header.sourceURL)) {
10668
+ const { node } = await internalCall(
10669
+ page,
10670
+ () => cdp.send("DOM.describeNode", { backendNodeId: header.ownerNode })
10671
+ );
10672
+ const attributes = node.attributes ?? [];
10673
+ const at = attributes.indexOf("data-vite-dev-id");
10674
+ if (at >= 0 && typeof attributes[at + 1] === "string") sheet.devId = attributes[at + 1];
10675
+ }
10676
+ if (/\/node_modules\//.test(sheet.devId ?? header.sourceURL)) return;
10677
+ const { text } = await internalCall(
10678
+ page,
10679
+ () => cdp.send("CSS.getStyleSheetText", { styleSheetId: header.styleSheetId })
10680
+ );
10681
+ sheet.text = text;
10682
+ } catch {
10683
+ }
10684
+ })();
10685
+ css.reading.add(reading);
10686
+ void reading.finally(() => css.reading.delete(reading));
10687
+ }
10688
+ async function readCssUsage(page, cdp, css) {
10689
+ try {
10690
+ const { coverage } = await internalCall(page, () => cdp.send("CSS.takeCoverageDelta"));
10691
+ for (const rule of coverage) {
10692
+ if (!rule.used) continue;
10693
+ css.sheets.get(rule.styleSheetId)?.used.set(`${rule.startOffset}:${rule.endOffset}`, {
10694
+ startOffset: rule.startOffset,
10695
+ endOffset: rule.endOffset
10696
+ });
10697
+ }
10698
+ } catch {
10699
+ }
10700
+ }
10423
10701
  function mergeCoverageRead(ran, scripts, read, keepIdle = true) {
10424
10702
  for (const covered of read) {
10425
10703
  const script = scripts.get(covered.scriptId);
@@ -10454,11 +10732,15 @@ async function detach(page, cdp) {
10454
10732
  }
10455
10733
  async function startCodeReach(page) {
10456
10734
  if (!isChromiumPage(page) || captures.has(page)) return false;
10735
+ const withCss = cssCoverageInWorker();
10736
+ const js = browserCoverageInWorker() || !withCss;
10457
10737
  let cdp = null;
10458
10738
  try {
10459
10739
  cdp = await internalCall(page, () => page.context().newCDPSession(page));
10460
10740
  const capture = {
10461
10741
  cdp,
10742
+ js,
10743
+ css: withCss ? { sheets: /* @__PURE__ */ new Map(), reading: /* @__PURE__ */ new Set() } : null,
10462
10744
  scripts: /* @__PURE__ */ new Map(),
10463
10745
  sources: /* @__PURE__ */ new Map(),
10464
10746
  ran: /* @__PURE__ */ new Map(),
@@ -10468,6 +10750,9 @@ async function startCodeReach(page) {
10468
10750
  };
10469
10751
  const marks = marksOf(page);
10470
10752
  cdp.on("Debugger.scriptParsed", (event) => onScriptParsed(page, capture, event));
10753
+ const css = capture.css;
10754
+ if (css)
10755
+ cdp.on("CSS.styleSheetAdded", (event) => onStyleSheetAdded(page, cdp, css, capture.pageUrl, event.header));
10471
10756
  cdp.on("Debugger.paused", (event) => onPaused(page, capture, event));
10472
10757
  cdp.on("Page.frameNavigated", (event) => {
10473
10758
  if (event.frame.parentId) return;
@@ -10478,8 +10763,17 @@ async function startCodeReach(page) {
10478
10763
  page,
10479
10764
  () => Promise.all([
10480
10765
  capture.cdp.send("Debugger.enable"),
10481
- capture.cdp.send("Profiler.enable"),
10482
- capture.cdp.send("Profiler.startPreciseCoverage", { callCount: false, detailed: false }),
10766
+ ...js ? [
10767
+ capture.cdp.send("Profiler.enable"),
10768
+ capture.cdp.send("Profiler.startPreciseCoverage", { callCount: false, detailed: false })
10769
+ ] : [],
10770
+ // The style sheets' rule usage records nothing without the DOM and CSS domains. A failure there leaves the
10771
+ // scripts' coverage running.
10772
+ ...css ? [
10773
+ capture.cdp.send("DOM.enable").then(() => capture.cdp.send("CSS.enable")).then(() => capture.cdp.send("CSS.startRuleUsageTracking")).catch(() => {
10774
+ capture.css = null;
10775
+ })
10776
+ ] : [],
10483
10777
  capture.cdp.send("Page.enable"),
10484
10778
  capture.cdp.send("Page.addScriptToEvaluateOnNewDocument", { source: MARK_SOURCE, worldName: MARK_WORLD }),
10485
10779
  capture.cdp.send("DOMDebugger.setEventListenerBreakpoint", { eventName: "beforeunload" })
@@ -10493,18 +10787,26 @@ async function startCodeReach(page) {
10493
10787
  return false;
10494
10788
  }
10495
10789
  }
10496
- async function stopCodeReach(page) {
10790
+ async function stopPageCoverage(page) {
10497
10791
  const capture = captures.get(page);
10498
- if (!capture) return null;
10792
+ if (!capture) return { scripts: null, sheets: [] };
10499
10793
  captures.delete(page);
10500
10794
  const { cdp } = capture;
10501
- try {
10795
+ const sheets = async () => {
10796
+ if (!capture.css) return [];
10797
+ await readCssUsage(page, cdp, capture.css);
10798
+ await Promise.all(capture.css.reading);
10799
+ await internalCall(page, () => cdp.send("CSS.stopRuleUsageTracking")).catch(() => {
10800
+ });
10801
+ return [...capture.css.sheets.values()].filter((sheet) => sheet.used.size > 0 || sheet.text !== void 0);
10802
+ };
10803
+ const scripts = async () => {
10804
+ if (!capture.js) return null;
10502
10805
  const last = await internalCall(page, async () => {
10503
10806
  const taken = await cdp.send("Profiler.takePreciseCoverage");
10504
10807
  await cdp.send("Profiler.stopPreciseCoverage");
10505
10808
  return taken.result;
10506
10809
  });
10507
- await Promise.all(capture.reading);
10508
10810
  mergeCoverageRead(capture.ran, capture.scripts, last, capture.keepIdle);
10509
10811
  const entries = [];
10510
10812
  for (const [key, { script, functions }] of capture.ran) {
@@ -10514,8 +10816,11 @@ async function stopCodeReach(page) {
10514
10816
  entries.push(entry);
10515
10817
  }
10516
10818
  return entries;
10517
- } catch {
10518
- return null;
10819
+ };
10820
+ try {
10821
+ await Promise.all(capture.reading);
10822
+ const [read, styles] = await Promise.all([scripts().catch(() => null), sheets().catch(() => [])]);
10823
+ return { scripts: read, sheets: styles };
10519
10824
  } finally {
10520
10825
  await detach(page, cdp);
10521
10826
  }
@@ -10632,7 +10937,7 @@ var SETTLE_WAIT_MS = 15e3;
10632
10937
  var RUN_COVERAGE_FILES_PER_REQUEST = 5e3;
10633
10938
  var RUN_COVERAGE_BYTES_PER_REQUEST = 8 * 1024 * 1024;
10634
10939
  var INCOMPLETE = { files: [], incomplete: true };
10635
- function composeTestCoverage(server, browserFiles, settings) {
10940
+ function composeTestCoverage(server, browserFiles, settings, cssFiles) {
10636
10941
  const serverCapture = settings.server !== "off" && server ? reduceCoverage(
10637
10942
  {
10638
10943
  v: 1,
@@ -10650,7 +10955,19 @@ function composeTestCoverage(server, browserFiles, settings) {
10650
10955
  },
10651
10956
  settings.browser
10652
10957
  ) : null;
10653
- const merged = mergeCoverage(serverCapture, browserCapture);
10958
+ const wholeFiles = new Set(
10959
+ settings.browser === "functions" ? [] : (browserFiles ?? []).map((file) => typeof file === "string" ? file : file.path)
10960
+ );
10961
+ const cssCapture = settings.css === "rules" && cssFiles?.length ? reduceCoverage(
10962
+ {
10963
+ v: 1,
10964
+ files: cssFiles.map(
10965
+ (file) => wholeFiles.has(file.path) ? { path: file.path, runtime: "browser" } : { ...file, runtime: "browser" }
10966
+ )
10967
+ },
10968
+ "functions"
10969
+ ) : null;
10970
+ const merged = mergeCoverage(serverCapture, mergeCoverage(browserCapture, cssCapture));
10654
10971
  return merged ? sanitizeCoverage(merged) : null;
10655
10972
  }
10656
10973
  function addRanges(into, filePath2, ranges) {
@@ -10710,7 +11027,7 @@ var CoverageCollection = class {
10710
11027
  this.browserKnown = /* @__PURE__ */ new Map();
10711
11028
  this.logger = options.logger ?? new Logger();
10712
11029
  this.waitMs = options.waitMs ?? TEST_COVERAGE_WAIT_MS;
10713
- this.browserBlobs = options.settings.browser === "functions" ? new BlobIds(() => repositoryRoot(options.configDir)) : null;
11030
+ this.browserBlobs = options.settings.browser === "functions" || options.settings.css === "rules" ? new BlobIds(() => repositoryRoot(options.configDir)) : null;
10714
11031
  const granularity = options.settings.server;
10715
11032
  this.collector = granularity === "off" ? null : new ServerCoverageCollector({
10716
11033
  granularity,
@@ -10727,28 +11044,40 @@ var CoverageCollection = class {
10727
11044
  /**
10728
11045
  * The browser side of a test's coverage: its code reach paths, or, at
10729
11046
  * `functions` granularity, the files of its `piwi-browser-coverage` attachment
10730
- * with their blob ids, once read. Never rejects.
11047
+ * with their blob ids, once read; with `coverage.css: 'rules'`, the style
11048
+ * sheet files of its `piwi-css-coverage` attachment too. Never rejects.
10731
11049
  */
10732
11050
  browserCoverage(testCase, attachments) {
10733
- if (this.options.settings.browser === "off") return void 0;
10734
- const paths = testCase.codeReach;
10735
- const attachment = this.browserBlobs ? attachments?.find((a) => a.name === ATTACHMENT_NAMES.browserCoverage) : void 0;
10736
- if (this.browserBlobs) {
10737
- for (const known of attachments ?? []) {
10738
- if (known.name === ATTACHMENT_NAMES.browserFunctions) this.addKnownFunctions(known);
10739
- }
10740
- }
10741
- if (!this.browserBlobs || !attachment?.body) return paths;
11051
+ const { browser, css } = this.options.settings;
11052
+ if (browser === "off" && css !== "rules") return {};
11053
+ const paths = browser === "off" ? void 0 : testCase.codeReach;
11054
+ for (const known of attachments ?? []) {
11055
+ if (browser === "functions" && known.name === ATTACHMENT_NAMES.browserFunctions) this.addKnownFunctions(known);
11056
+ if (css === "rules" && known.name === ATTACHMENT_NAMES.cssRules) this.addKnownFunctions(known);
11057
+ }
11058
+ const scripts = browser === "functions" ? this.rangedFiles(attachments, ATTACHMENT_NAMES.browserCoverage) : [];
11059
+ const sheets = css === "rules" ? this.rangedFiles(attachments, ATTACHMENT_NAMES.cssCoverage) : [];
11060
+ const blobs = this.browserBlobs;
11061
+ if (!blobs || scripts.length === 0 && sheets.length === 0) return { files: paths };
11062
+ const withBlobs = (files) => blobs.withBlobs(files).catch(() => files);
11063
+ return Promise.all([
11064
+ scripts.length > 0 ? withBlobs(scripts) : Promise.resolve(null),
11065
+ sheets.length > 0 ? withBlobs(sheets) : Promise.resolve([])
11066
+ ]).then(([files, rules]) => ({ files: files ?? paths, css: rules }));
11067
+ }
11068
+ /** The browser files of a test's attachment holding line ranges, added to the run's. */
11069
+ rangedFiles(attachments, name) {
11070
+ const attachment = attachments?.find((a) => a.name === name);
11071
+ if (!attachment?.body) return [];
10742
11072
  let files = [];
10743
11073
  try {
10744
11074
  files = sanitizeCoverage(JSON.parse(attachment.body.toString("utf8")))?.files ?? [];
10745
11075
  } catch {
10746
- return paths;
11076
+ return [];
10747
11077
  }
10748
11078
  const browser = files.filter((file) => file.runtime === "browser");
10749
- if (browser.length === 0) return paths;
10750
11079
  for (const file of browser) addRanges(this.browserRan, file.path, file.functions ?? []);
10751
- return this.browserBlobs.withBlobs(browser).catch(() => browser);
11080
+ return browser;
10752
11081
  }
10753
11082
  /** Add the functions of a `piwi-browser-functions` attachment, one of a test's, to the run's. */
10754
11083
  addKnownFunctions(attachment) {
@@ -10803,11 +11132,11 @@ var CoverageCollection = class {
10803
11132
  const requests = attachment?.body ? parseServerCoverageRequests(attachment.body) : null;
10804
11133
  if (!this.collector || !requests) {
10805
11134
  if (!(browser instanceof Promise)) {
10806
- this.assign(testCase, composeTestCoverage(null, browser, this.options.settings));
11135
+ this.assign(testCase, composeTestCoverage(null, browser.files, this.options.settings, browser.css));
10807
11136
  return null;
10808
11137
  }
10809
11138
  const ready = browser.then(
10810
- (files) => this.assign(testCase, composeTestCoverage(null, files, this.options.settings))
11139
+ (side) => this.assign(testCase, composeTestCoverage(null, side.files, this.options.settings, side.css))
10811
11140
  );
10812
11141
  this.browserPending.add(ready);
10813
11142
  return ready.finally(() => this.browserPending.delete(ready));
@@ -10821,8 +11150,8 @@ var CoverageCollection = class {
10821
11150
  settled = true;
10822
11151
  clearTimeout(timer);
10823
11152
  this.pending.delete(entry);
10824
- void Promise.resolve(browser).then((files) => {
10825
- this.assign(testCase, composeTestCoverage(server, files, this.options.settings));
11153
+ void Promise.resolve(browser).then((side) => {
11154
+ this.assign(testCase, composeTestCoverage(server, side.files, this.options.settings, side.css));
10826
11155
  resolve11();
10827
11156
  });
10828
11157
  };
@@ -10959,6 +11288,8 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
10959
11288
  this.resourcesFile = null;
10960
11289
  /** Samples the run's processes and the machine from `onBegin` to `onEnd`. */
10961
11290
  this.sampler = null;
11291
+ /** Posts what the sampler and the censuses add while someone has the run open on the dashboard. */
11292
+ this.liveResources = null;
10962
11293
  /** Bytes of the files the tests attached, by kind. */
10963
11294
  this.artifactBytes = emptyArtifacts();
10964
11295
  /** The run's resource report for the dashboard, built in `onEnd`. */
@@ -11117,8 +11448,9 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
11117
11448
  );
11118
11449
  }
11119
11450
  if (coverage.server !== "off") this.lastTestWatch = new LastTestWatch(this.plannedTests.length);
11120
- if (coverage.browser !== "off" || coverage.server !== "off") {
11451
+ if (coverage.browser !== "off" || coverage.server !== "off" || coverage.css === "rules") {
11121
11452
  if (coverage.browser !== "off") process.env[PIWI_ENV_KEYS.coverageBrowser] = coverage.browser;
11453
+ if (coverage.css === "rules") process.env[PIWI_ENV_KEYS.coverageCss] = coverage.css;
11122
11454
  if (coverage.server !== "off") {
11123
11455
  process.env[PIWI_ENV_KEYS.coverageServer] = coverage.server;
11124
11456
  }
@@ -11143,6 +11475,22 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
11143
11475
  // the dashboard shows the real total from the first render.
11144
11476
  this.plannedTests.length
11145
11477
  );
11478
+ if (this.streamManager && this.options.captureResources !== false) {
11479
+ const live = new LiveResources(
11480
+ this.streamManager,
11481
+ this.httpClient,
11482
+ {
11483
+ sampler: this.sampler,
11484
+ censuses: () => this.resourceCensuses,
11485
+ shardIndex: this.shardInfo?.current ?? null,
11486
+ runStartedAt: Date.now()
11487
+ },
11488
+ this.logger
11489
+ );
11490
+ this.streamManager.onWatched = () => void live.tick();
11491
+ live.start();
11492
+ this.liveResources = live;
11493
+ }
11146
11494
  }
11147
11495
  /**
11148
11496
  * Settle each planned test's external ID before any test runs, name the IDs two tests share, and record them on
@@ -11445,6 +11793,8 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
11445
11793
  */
11446
11794
  async reportResources() {
11447
11795
  if (this.options.captureResources === false) return;
11796
+ await this.liveResources?.stop();
11797
+ this.liveResources = null;
11448
11798
  const profile = this.sampler ? await this.sampler.stop() : null;
11449
11799
  const samples = this.sampler?.timedSeries() ?? null;
11450
11800
  this.sampler = null;
@@ -13011,7 +13361,7 @@ var TEST_DATA_ATTRS = /* @__PURE__ */ new Set(["data-test", "data-test-id", "dat
13011
13361
  function generateAlternatives(attrs, options = {}) {
13012
13362
  const alts = [];
13013
13363
  const seen = /* @__PURE__ */ new Set();
13014
- const add = (loc) => {
13364
+ const add2 = (loc) => {
13015
13365
  if (!seen.has(loc.locator)) {
13016
13366
  seen.add(loc.locator);
13017
13367
  alts.push(loc);
@@ -13028,7 +13378,7 @@ function generateAlternatives(attrs, options = {}) {
13028
13378
  const selectable = (name2) => name2.toLowerCase() !== testIdAttribute.toLowerCase();
13029
13379
  const testId = attr(attrs, testIdAttribute);
13030
13380
  if (testId && isUnique(counts?.testId)) {
13031
- add({
13381
+ add2({
13032
13382
  locator: `getByTestId(${quoteJsString(testId)})`,
13033
13383
  method: "getByTestId",
13034
13384
  args: { testId },
@@ -13039,7 +13389,7 @@ function generateAlternatives(attrs, options = {}) {
13039
13389
  const levelPart = level != null ? `, level: ${level}` : "";
13040
13390
  const withLevel = (base) => level != null ? { ...base, level } : base;
13041
13391
  if (role && accessibleName) {
13042
- add({
13392
+ add2({
13043
13393
  locator: `getByRole(${quoteJsString(role)}, { name: ${quoteJsString(accessibleName)}${levelPart} })`,
13044
13394
  method: "getByRole",
13045
13395
  args: withLevel({ role, name: accessibleName }),
@@ -13048,7 +13398,7 @@ function generateAlternatives(attrs, options = {}) {
13048
13398
  }
13049
13399
  const ariaLabel = attr(attrs, "aria-label");
13050
13400
  if (role && ariaLabel && ariaLabel !== accessibleName) {
13051
- add({
13401
+ add2({
13052
13402
  locator: `getByRole(${quoteJsString(role)}, { name: ${quoteJsString(ariaLabel)}${levelPart} })`,
13053
13403
  method: "getByRole",
13054
13404
  args: withLevel({ role, name: ariaLabel }),
@@ -13059,7 +13409,7 @@ function generateAlternatives(attrs, options = {}) {
13059
13409
  const label = attr(attrs, "aria-label");
13060
13410
  const labelBacked = attrs.hasLabel === void 0 ? true : attrs.hasLabel === true || label === accessibleName;
13061
13411
  if (labelBacked) {
13062
- add({
13412
+ add2({
13063
13413
  locator: `getByLabel(${quoteJsString(accessibleName)})`,
13064
13414
  method: "getByLabel",
13065
13415
  args: { label: accessibleName },
@@ -13069,7 +13419,7 @@ function generateAlternatives(attrs, options = {}) {
13069
13419
  }
13070
13420
  const placeholder = attr(attrs, "placeholder");
13071
13421
  if (placeholder) {
13072
- add({
13422
+ add2({
13073
13423
  locator: `getByPlaceholder(${quoteJsString(placeholder)})`,
13074
13424
  method: "getByPlaceholder",
13075
13425
  args: { placeholder },
@@ -13077,7 +13427,7 @@ function generateAlternatives(attrs, options = {}) {
13077
13427
  });
13078
13428
  }
13079
13429
  if (text && text.length < 80) {
13080
- add({
13430
+ add2({
13081
13431
  locator: `getByText(${quoteJsString(text)})`,
13082
13432
  method: "getByText",
13083
13433
  args: { text },
@@ -13087,7 +13437,7 @@ function generateAlternatives(attrs, options = {}) {
13087
13437
  const id = attr(attrs, "id");
13088
13438
  if (id && selectable("id") && !isAutoGenerated(id) && isUnique(counts?.id)) {
13089
13439
  const selector = isCssSafeId(id) ? `#${id}` : `[id="${escCssAttrValue(id)}"]`;
13090
- add({
13440
+ add2({
13091
13441
  locator: `locator(${quoteJsString(selector)})`,
13092
13442
  method: "locator",
13093
13443
  args: { selector },
@@ -13097,7 +13447,7 @@ function generateAlternatives(attrs, options = {}) {
13097
13447
  const name = attr(attrs, "name");
13098
13448
  if (name && selectable("name") && isUnique(counts?.name)) {
13099
13449
  const selector = `[name="${escCssAttrValue(name)}"]`;
13100
- add({
13450
+ add2({
13101
13451
  locator: `locator(${quoteJsString(selector)})`,
13102
13452
  method: "locator",
13103
13453
  args: { selector },
@@ -13106,7 +13456,7 @@ function generateAlternatives(attrs, options = {}) {
13106
13456
  }
13107
13457
  const alt = attr(attrs, "alt");
13108
13458
  if (alt) {
13109
- add({
13459
+ add2({
13110
13460
  locator: `getByAltText(${quoteJsString(alt)})`,
13111
13461
  method: "getByAltText",
13112
13462
  args: { text: alt },
@@ -13115,7 +13465,7 @@ function generateAlternatives(attrs, options = {}) {
13115
13465
  }
13116
13466
  const title = attr(attrs, "title");
13117
13467
  if (title) {
13118
- add({
13468
+ add2({
13119
13469
  locator: `getByTitle(${quoteJsString(title)})`,
13120
13470
  method: "getByTitle",
13121
13471
  args: { title },
@@ -13133,7 +13483,7 @@ function generateAlternatives(attrs, options = {}) {
13133
13483
  if (scopedCount(anc) !== 1) continue;
13134
13484
  if (!testIdAnchorDone && anc.testId && anc.testIdCount === 1) {
13135
13485
  testIdAnchorDone = true;
13136
- add({
13486
+ add2({
13137
13487
  locator: `getByTestId(${quoteJsString(anc.testId)}).${leaf.expr}`,
13138
13488
  method: leaf.method,
13139
13489
  args: { ...leaf.args, anchorTestId: anc.testId },
@@ -13143,7 +13493,7 @@ function generateAlternatives(attrs, options = {}) {
13143
13493
  if (!idAnchorDone && anc.id && selectable("id") && !isAutoGenerated(anc.id) && anc.idCount === 1) {
13144
13494
  idAnchorDone = true;
13145
13495
  const anchorSelector = isCssSafeId(anc.id) ? `#${anc.id}` : `[id="${escCssAttrValue(anc.id)}"]`;
13146
- add({
13496
+ add2({
13147
13497
  locator: `locator(${quoteJsString(anchorSelector)}).${leaf.expr}`,
13148
13498
  method: leaf.method,
13149
13499
  args: { ...leaf.args, anchorSelector },
@@ -13154,7 +13504,7 @@ function generateAlternatives(attrs, options = {}) {
13154
13504
  dataAnchorDone = true;
13155
13505
  const { name: name2, value } = anc.dataAttr;
13156
13506
  const anchorSelector = `[${name2}="${escCssAttrValue(value)}"]`;
13157
- add({
13507
+ add2({
13158
13508
  locator: `locator(${quoteJsString(anchorSelector)}).${leaf.expr}`,
13159
13509
  method: leaf.method,
13160
13510
  args: { ...leaf.args, anchorSelector },
@@ -13164,7 +13514,7 @@ function generateAlternatives(attrs, options = {}) {
13164
13514
  const ancestorRole = anc.role || TAG_TO_ROLE[anc.tag] || null;
13165
13515
  if (!roleAnchorDone && ancestorRole && ancestorRole !== leaf.role && anc.roleCount === 1) {
13166
13516
  roleAnchorDone = true;
13167
- add({
13517
+ add2({
13168
13518
  locator: `getByRole(${quoteJsString(ancestorRole)}).${leaf.expr}`,
13169
13519
  method: leaf.method,
13170
13520
  args: { ...leaf.args, anchorRole: ancestorRole },
@@ -13173,7 +13523,7 @@ function generateAlternatives(attrs, options = {}) {
13173
13523
  }
13174
13524
  if (!filterAnchorDone && ancestorRole && anc.filterText && anc.filterRoleCount === 1) {
13175
13525
  filterAnchorDone = true;
13176
- add({
13526
+ add2({
13177
13527
  locator: `getByRole(${quoteJsString(ancestorRole)}).filter({ hasText: ${quoteJsString(anc.filterText)} }).${leaf.expr}`,
13178
13528
  method: leaf.method,
13179
13529
  args: { ...leaf.args, anchorRole: ancestorRole, anchorHasText: anc.filterText },
@@ -13192,7 +13542,7 @@ function generateAlternatives(attrs, options = {}) {
13192
13542
  );
13193
13543
  const pos = attrs.rolePosition;
13194
13544
  if (pos && pos.role === role && (pos.count === 1 || level != null && pos.levelCount === 1)) {
13195
- add({
13545
+ add2({
13196
13546
  locator: `getByRole(${rolePart})`,
13197
13547
  method: "getByRole",
13198
13548
  args: leafArgs,
@@ -13211,7 +13561,7 @@ function generateAlternatives(attrs, options = {}) {
13211
13561
  const countsClasses = counts?.classes;
13212
13562
  const classes = clsStr.split(/\s+/).filter((c) => c.length > 1 && /^[A-Za-z_-][A-Za-z0-9_-]*$/.test(c) && isUnique(countsClasses?.[c])).map((cls) => ({ cls, score: classifyCssStability(cls) })).sort((a, b) => b.score - a.score).slice(0, 3);
13213
13563
  for (const { cls, score } of classes) {
13214
- add({
13564
+ add2({
13215
13565
  locator: `locator(${quoteJsString(`.${cls}`)})`,
13216
13566
  method: "locator",
13217
13567
  args: { selector: `.${cls}` },
@@ -13268,7 +13618,7 @@ function generateAnchoredAlternatives(leaf, anchors, chainLeafCount) {
13268
13618
  const seen = /* @__PURE__ */ new Set();
13269
13619
  const rolePart = leaf.level != null ? `${quoteJsString(leaf.role)}, { level: ${leaf.level} }` : quoteJsString(leaf.role);
13270
13620
  const leafArgs = leaf.level != null ? { role: leaf.role, level: leaf.level } : { role: leaf.role };
13271
- const add = (l) => {
13621
+ const add2 = (l) => {
13272
13622
  if (!seen.has(l.locator)) {
13273
13623
  seen.add(l.locator);
13274
13624
  out.push(l);
@@ -13278,7 +13628,7 @@ function generateAnchoredAlternatives(leaf, anchors, chainLeafCount) {
13278
13628
  if (anchor.scopedLeafCount !== 1) continue;
13279
13629
  const seg = anchorSegment(anchor);
13280
13630
  if (!seg) continue;
13281
- add({
13631
+ add2({
13282
13632
  locator: `${seg.code}.getByRole(${rolePart})`,
13283
13633
  method: "getByRole",
13284
13634
  args: { ...leafArgs, ...seg.flatArgs },
@@ -13291,7 +13641,7 @@ function generateAnchoredAlternatives(leaf, anchors, chainLeafCount) {
13291
13641
  if (segments.every((s) => s !== null)) {
13292
13642
  const innermost = segments[segments.length - 1];
13293
13643
  const score = Math.min(...segments.map((s) => ANCHOR_KIND_SCORES[s.kind]));
13294
- add({
13644
+ add2({
13295
13645
  locator: `${segments.map((s) => s.code).join(".")}.getByRole(${rolePart})`,
13296
13646
  method: "getByRole",
13297
13647
  // Flat args describe the innermost anchor (what the healing fingerprint
@@ -14488,7 +14838,7 @@ async function pauseHere(page, point, probe, onPick, onRecord, ended) {
14488
14838
 
14489
14839
  // src/internal/capture/record-handoff.ts
14490
14840
  var fs26 = __toESM(require("fs"));
14491
- var net = __toESM(require("net"));
14841
+ var net2 = __toESM(require("net"));
14492
14842
  var path29 = __toESM(require("path"));
14493
14843
 
14494
14844
  // ../core/src/ide-recorder.ts
@@ -14577,7 +14927,7 @@ async function recordFromHere(options) {
14577
14927
  const use = testInfo.project?.use ?? {};
14578
14928
  const context = page.context();
14579
14929
  return new Promise((resolve11) => {
14580
- const socket = net.connect(channel.path);
14930
+ const socket = net2.connect(channel.path);
14581
14931
  socket.setEncoding("utf8");
14582
14932
  const answers = /* @__PURE__ */ new Map();
14583
14933
  let calls = 0;
@@ -14726,6 +15076,267 @@ async function recordFromHere(options) {
14726
15076
  });
14727
15077
  }
14728
15078
 
15079
+ // src/internal/capture/css-coverage.ts
15080
+ var crypto7 = __toESM(require("crypto"));
15081
+ var fs27 = __toESM(require("fs"));
15082
+
15083
+ // ../core/src/css-rules.ts
15084
+ var GROUPING = /* @__PURE__ */ new Set(["media", "supports", "layer", "container", "scope", "starting-style", "document"]);
15085
+ function parseCssRules(text) {
15086
+ const rules = [];
15087
+ const open = [];
15088
+ let preludeStart = -1;
15089
+ for (let i = 0; i < text.length; i++) {
15090
+ const c = text[i];
15091
+ if (c === "/" && text[i + 1] === "*") {
15092
+ const end = text.indexOf("*/", i + 2);
15093
+ i = end < 0 ? text.length : end + 1;
15094
+ continue;
15095
+ }
15096
+ if (c === '"' || c === "'") {
15097
+ if (preludeStart < 0) preludeStart = i;
15098
+ for (i++; i < text.length && text[i] !== c && text[i] !== "\n"; i++) if (text[i] === "\\") i++;
15099
+ continue;
15100
+ }
15101
+ if (c === "\\") {
15102
+ if (preludeStart < 0) preludeStart = i;
15103
+ i++;
15104
+ continue;
15105
+ }
15106
+ if ((c === "u" || c === "U") && /^url\(/i.test(text.slice(i, i + 4)) && !/[\w-]/.test(text[i - 1] ?? "")) {
15107
+ let j = i + 4;
15108
+ while (j < text.length && /\s/.test(text[j])) j++;
15109
+ if (text[j] !== '"' && text[j] !== "'") {
15110
+ if (preludeStart < 0) preludeStart = i;
15111
+ for (; j < text.length && text[j] !== ")"; j++) if (text[j] === "\\") j++;
15112
+ i = j;
15113
+ continue;
15114
+ }
15115
+ }
15116
+ if (text.startsWith("<!--", i) || text.startsWith("-->", i)) {
15117
+ i += text.startsWith("<!--", i) ? 3 : 2;
15118
+ continue;
15119
+ }
15120
+ if (c === "{") {
15121
+ const start = preludeStart < 0 ? i : preludeStart;
15122
+ const atRule = /^@([\w-]+)/.exec(text.slice(start, i));
15123
+ const parent = open[open.length - 1];
15124
+ const kind = atRule ? GROUPING.has(atRule[1].toLowerCase()) ? "group" : "other" : parent?.kind === "other" ? "other" : "style";
15125
+ open.push({ kind, start, open: i });
15126
+ preludeStart = -1;
15127
+ continue;
15128
+ }
15129
+ if (c === "}") {
15130
+ const block = open.pop();
15131
+ if (block && block.kind !== "other")
15132
+ rules.push({ kind: block.kind, start: block.start, open: block.open, end: i + 1 });
15133
+ preludeStart = -1;
15134
+ continue;
15135
+ }
15136
+ if (c === ";") {
15137
+ preludeStart = -1;
15138
+ continue;
15139
+ }
15140
+ if (preludeStart < 0 && !/\s/.test(c)) preludeStart = i;
15141
+ }
15142
+ for (const block of open) {
15143
+ if (block.kind !== "other")
15144
+ rules.push({ kind: block.kind, start: block.start, open: block.open, end: text.length });
15145
+ }
15146
+ return rules.sort((a, b) => a.start - b.start);
15147
+ }
15148
+ function usedCssRules(rules, used) {
15149
+ const styleAt = /* @__PURE__ */ new Map();
15150
+ const groups = [];
15151
+ for (const rule of rules) {
15152
+ if (rule.kind === "style") styleAt.set(rule.start, rule);
15153
+ else groups.push(rule);
15154
+ }
15155
+ const out = /* @__PURE__ */ new Map();
15156
+ for (const range of used) {
15157
+ const style = styleAt.get(range.startOffset);
15158
+ if (style) {
15159
+ out.set(`${style.start}`, [style.start, style.end]);
15160
+ continue;
15161
+ }
15162
+ const group = groups.find((rule) => rule.start <= range.startOffset && range.startOffset <= rule.open);
15163
+ if (group) out.set(`${group.start}`, [group.start, group.start + 1]);
15164
+ }
15165
+ return [...out.values()].sort((a, b) => a[0] - b[0]);
15166
+ }
15167
+ function allCssRules(rules) {
15168
+ return rules.map((rule) => rule.kind === "style" ? [rule.start, rule.end] : [rule.start, rule.start + 1]);
15169
+ }
15170
+ function cssRuleLines(text, offsets, options = {}) {
15171
+ const starts = lineStarts(text);
15172
+ const lineOffset = options.lineOffset ?? 0;
15173
+ const out = /* @__PURE__ */ new Map();
15174
+ const add2 = (source, range) => {
15175
+ const ranges = out.get(source);
15176
+ if (ranges) ranges.push(range);
15177
+ else out.set(source, [range]);
15178
+ };
15179
+ for (const [startOffset, endOffset] of offsets) {
15180
+ const start = offsetToPosition(starts, startOffset);
15181
+ const end = offsetToPosition(starts, Math.max(startOffset, endOffset - 1));
15182
+ if (!options.lookup) {
15183
+ add2(-1, [start.line + lineOffset + 1, end.line + lineOffset + 1]);
15184
+ continue;
15185
+ }
15186
+ const first = options.lookup.originalAt(start.line, start.column);
15187
+ if (!first) continue;
15188
+ const last = options.lookup.originalAt(end.line, end.column);
15189
+ const lastLine = last && last.source === first.source && last.line >= first.line ? last.line : first.line;
15190
+ add2(first.source, [first.line + 1, lastLine + 1]);
15191
+ }
15192
+ return out;
15193
+ }
15194
+
15195
+ // src/internal/capture/css-coverage.ts
15196
+ var STYLE_SOURCES = /\.(?:css|scss|sass|less|styl|stylus|pcss|postcss|vue|svelte|astro|html?)$/i;
15197
+ var BUILD_DIRS2 = /(?:^|\/)(?:dist|build|out|\.output|\.next|\.nuxt|\.svelte-kit)\//;
15198
+ var resolutions = /* @__PURE__ */ new Map();
15199
+ var MAX_CACHED_SHEETS = 2e3;
15200
+ function fileForStyleUrl(url, roots) {
15201
+ let pathname;
15202
+ try {
15203
+ const parsed2 = new URL(url);
15204
+ if (parsed2.protocol !== "http:" && parsed2.protocol !== "https:" && parsed2.protocol !== "file:") return null;
15205
+ pathname = parsed2.pathname;
15206
+ } catch {
15207
+ return null;
15208
+ }
15209
+ return sourceFile(decodeURIComponent(pathname), roots);
15210
+ }
15211
+ function sourceFile(source, roots) {
15212
+ const bare = source.replace(/[?#].*$/, "");
15213
+ if (!STYLE_SOURCES.test(bare) || /(?:^|\/)node_modules\//.test(bare)) return null;
15214
+ const file = resolveSourcePath(bare, roots);
15215
+ if (!file || BUILD_DIRS2.test(file.replace(/\\/g, "/")) || /(?:^|[\\/])node_modules[\\/]/.test(file)) return null;
15216
+ return file;
15217
+ }
15218
+ function styleBlockLine(file, index) {
15219
+ let text;
15220
+ try {
15221
+ text = fs27.readFileSync(file, "utf-8");
15222
+ } catch {
15223
+ return 0;
15224
+ }
15225
+ const blocks = [...text.matchAll(/^<style\b[^>]*>/gim)];
15226
+ const block = blocks[index];
15227
+ if (!block) return 0;
15228
+ return text.slice(0, block.index + block[0].length).split("\n").length - 1;
15229
+ }
15230
+ async function sheetMap(sheet, text, fetchMap) {
15231
+ const ref = sourceMappingUrl(text) ?? sheet.sourceMapURL ?? null;
15232
+ if (!ref) return null;
15233
+ let raw;
15234
+ if (ref.startsWith("data:")) raw = decodeDataUrl(ref);
15235
+ else {
15236
+ try {
15237
+ raw = await fetchMap(new URL(ref, sheet.sourceURL).href);
15238
+ } catch {
15239
+ raw = null;
15240
+ }
15241
+ }
15242
+ if (!raw) return null;
15243
+ try {
15244
+ return decodeSourceMap(JSON.parse(raw));
15245
+ } catch {
15246
+ return null;
15247
+ }
15248
+ }
15249
+ function sameLines(text, rules, file, lineOffset) {
15250
+ let source;
15251
+ try {
15252
+ source = fs27.readFileSync(file, "utf-8");
15253
+ } catch {
15254
+ return false;
15255
+ }
15256
+ const fileLines = source.split(/\r?\n/);
15257
+ const sheetLineStarts = [0];
15258
+ for (let i = 0; i < text.length; i++) if (text[i] === "\n") sheetLineStarts.push(i + 1);
15259
+ let low = 0;
15260
+ for (const rule of rules) {
15261
+ while (low + 1 < sheetLineStarts.length && sheetLineStarts[low + 1] <= rule.start) low++;
15262
+ const head = text.slice(rule.start, rule.open).split("\n")[0].trim();
15263
+ if (head && !(fileLines[low + lineOffset] ?? "").includes(head)) return false;
15264
+ }
15265
+ return true;
15266
+ }
15267
+ async function resolveSheet(sheet, text, dirs, fetchMap) {
15268
+ const lookup = await sheetMap(sheet, text, fetchMap);
15269
+ const rules = parseCssRules(text);
15270
+ if (lookup) {
15271
+ const sources = lookup.sources.map((source) => {
15272
+ const direct = sourceFile(source, dirs);
15273
+ if (direct) return direct;
15274
+ try {
15275
+ return /^[a-z][\w+.-]*:/i.test(source) ? null : fileForStyleUrl(new URL(source, sheet.sourceURL).href, dirs);
15276
+ } catch {
15277
+ return null;
15278
+ }
15279
+ });
15280
+ return { rules, lookup, file: null, lineOffset: 0, sources, exact: true };
15281
+ }
15282
+ if (sheet.devId) {
15283
+ const file2 = sourceFile(sheet.devId, dirs);
15284
+ if (!file2) return null;
15285
+ const index = /[?&]index=(\d+)/.exec(sheet.devId);
15286
+ const lineOffset2 = /\.(?:vue|svelte|astro)$/i.test(file2) && index ? styleBlockLine(file2, Number(index[1])) : 0;
15287
+ return { rules, lookup: null, file: file2, lineOffset: lineOffset2, sources: [], exact: sameLines(text, rules, file2, lineOffset2) };
15288
+ }
15289
+ const file = fileForStyleUrl(sheet.sourceURL, dirs);
15290
+ if (!file) return null;
15291
+ const lineOffset = sheet.isInline ? sheet.startLine : 0;
15292
+ return { rules, lookup: null, file, lineOffset, sources: [], exact: sameLines(text, rules, file, lineOffset) };
15293
+ }
15294
+ function cachedResolution(sheet, text, dirs, fetchMap) {
15295
+ const version = crypto7.createHash("sha1").update(text).digest("hex");
15296
+ const key = [sheet.devId ?? sheet.sourceURL, sheet.isInline ? sheet.startLine : "", dirs.join("\0"), version].join(
15297
+ " "
15298
+ );
15299
+ let pending = resolutions.get(key);
15300
+ if (!pending) {
15301
+ pending = resolveSheet(sheet, text, dirs, fetchMap).catch(() => null);
15302
+ resolutions.set(key, pending);
15303
+ if (resolutions.size > MAX_CACHED_SHEETS) resolutions.delete(resolutions.keys().next().value);
15304
+ }
15305
+ return pending;
15306
+ }
15307
+ function add(into, file, ranges, repoRoot, fileLevel = false) {
15308
+ if (ranges.length === 0 && !fileLevel) return;
15309
+ const key = relativeToRepo(file, repoRoot);
15310
+ into.set(key, [...into.get(key) ?? [], ...ranges]);
15311
+ }
15312
+ async function resolveCssCoverage(sheets, roots, fetchMap) {
15313
+ const reached = /* @__PURE__ */ new Map();
15314
+ const known = /* @__PURE__ */ new Map();
15315
+ for (const sheet of sheets) {
15316
+ if (sheet.text === void 0) continue;
15317
+ const dirs = scriptRoots(roots, sheet.sourceURL, sheet.pageUrl);
15318
+ const resolution = await cachedResolution(sheet, sheet.text, dirs, fetchMap);
15319
+ if (!resolution) continue;
15320
+ const used = usedCssRules(resolution.rules, [...sheet.used.values()]);
15321
+ if (!resolution.exact) {
15322
+ if (resolution.file && used.length > 0) add(reached, resolution.file, [], roots.repoRoot, true);
15323
+ continue;
15324
+ }
15325
+ const options = { lookup: resolution.lookup, lineOffset: resolution.lineOffset };
15326
+ const fileOf2 = (source) => source === -1 ? resolution.file : resolution.sources[source] ?? null;
15327
+ const matched = cssRuleLines(sheet.text, used, options);
15328
+ for (const [source, ranges] of matched) {
15329
+ const file = fileOf2(source);
15330
+ if (file) add(reached, file, ranges, roots.repoRoot);
15331
+ }
15332
+ for (const [source, ranges] of cssRuleLines(sheet.text, allCssRules(resolution.rules), options)) {
15333
+ const file = fileOf2(source);
15334
+ if (file) add(known, file, ranges, roots.repoRoot);
15335
+ }
15336
+ }
15337
+ return { reached, known };
15338
+ }
15339
+
14729
15340
  // src/internal/capture/page-interaction.ts
14730
15341
  function readPageInteraction() {
14731
15342
  const doc = globalThis.document;
@@ -15029,16 +15640,19 @@ function createSink() {
15029
15640
  onReleasedRequestsEnded: null,
15030
15641
  releasedQuietSince: 0,
15031
15642
  locatorPages: new LocatorPageLog(),
15032
- codeReach: { sessions: /* @__PURE__ */ new Map(), files: /* @__PURE__ */ new Map(), known: /* @__PURE__ */ new Map() },
15643
+ codeReach: { sessions: /* @__PURE__ */ new Map(), files: /* @__PURE__ */ new Map(), known: /* @__PURE__ */ new Map(), cssFiles: /* @__PURE__ */ new Map(), cssKnown: /* @__PURE__ */ new Map() },
15033
15644
  serverCoverage: /* @__PURE__ */ new Map(),
15034
15645
  baseURL: null
15035
15646
  };
15036
15647
  }
15037
15648
  var MAX_CODE_REACH_PAGES = 16;
15038
15649
  var CODE_REACH_START_WAIT_MS = 2e3;
15650
+ function pageCoverageInWorker() {
15651
+ return browserCoverageInWorker() || cssCoverageInWorker();
15652
+ }
15039
15653
  function maybeStartCodeReach(page, alreadyOpen = false) {
15040
15654
  const sink = currentSink;
15041
- if (!sink || !browserCoverageInWorker() || isPageClosed(page)) return Promise.resolve(false);
15655
+ if (!sink || !pageCoverageInWorker() || isPageClosed(page)) return Promise.resolve(false);
15042
15656
  const known = sink.codeReach.sessions.get(page);
15043
15657
  if (known) return known.started;
15044
15658
  if (alreadyOpen) {
@@ -15051,7 +15665,7 @@ function maybeStartCodeReach(page, alreadyOpen = false) {
15051
15665
  return started;
15052
15666
  }
15053
15667
  async function startCodeReachOnOpenPages() {
15054
- if (!browserCoverageInWorker() || OPEN_PAGES.size === 0) return;
15668
+ if (!pageCoverageInWorker() || OPEN_PAGES.size === 0) return;
15055
15669
  const started = Promise.all([...OPEN_PAGES].map((page) => maybeStartCodeReach(page, true)));
15056
15670
  await Promise.race([started, new Promise((resolve11) => setTimeout(resolve11, CODE_REACH_START_WAIT_MS).unref())]);
15057
15671
  }
@@ -15094,22 +15708,28 @@ async function stopSinkCodeReach(sink, closing) {
15094
15708
  async function stopPageCodeReach(sink, page, session, roots) {
15095
15709
  try {
15096
15710
  if (!await session.started || isPageClosed(page)) return;
15097
- const entries = await stopCodeReach(page);
15098
- if (!entries) return;
15099
- const { reached, known } = await resolveCodeReachFunctions(entries, roots, pageMapFetcher(page), page.url(), {
15711
+ const { scripts, sheets } = await stopPageCoverage(page);
15712
+ const fetchMap = pageMapFetcher(page);
15713
+ const append = (into, from) => {
15714
+ for (const [file, ranges] of from) into.set(file, [...into.get(file) ?? [], ...ranges]);
15715
+ };
15716
+ if (sheets.length > 0) {
15717
+ const rules = await resolveCssCoverage(sheets, roots, fetchMap);
15718
+ append(sink.codeReach.cssFiles, rules.reached);
15719
+ append(sink.codeReach.cssKnown, rules.known);
15720
+ }
15721
+ if (!scripts) return;
15722
+ const { reached, known } = await resolveCodeReachFunctions(scripts, roots, fetchMap, page.url(), {
15100
15723
  known: browserFunctionsInWorker()
15101
15724
  });
15102
- for (const [file, functions] of reached) {
15103
- sink.codeReach.files.set(file, [...sink.codeReach.files.get(file) ?? [], ...functions]);
15104
- }
15105
- for (const [file, functions] of known) {
15106
- sink.codeReach.known.set(file, [...sink.codeReach.known.get(file) ?? [], ...functions]);
15107
- }
15725
+ append(sink.codeReach.files, reached);
15726
+ append(sink.codeReach.known, known);
15108
15727
  } catch {
15109
15728
  }
15110
15729
  }
15111
15730
  var currentSink = null;
15112
15731
  var KNOWN_FUNCTIONS_SENT = /* @__PURE__ */ new Map();
15732
+ var KNOWN_CSS_RULES_SENT = /* @__PURE__ */ new Map();
15113
15733
  var PENDING_PROBES = /* @__PURE__ */ new Set();
15114
15734
  async function drainPendingProbes(capMs) {
15115
15735
  if (PENDING_PROBES.size === 0) return;
@@ -16265,6 +16885,21 @@ async function flushSink(sink, testInfo) {
16265
16885
  body: Buffer.from(JSON.stringify(part))
16266
16886
  });
16267
16887
  }
16888
+ if (cssCoverageInWorker()) {
16889
+ const rules = sink.codeReach.cssFiles.size > 0 ? browserFunctionCoverage(sink.codeReach.cssFiles) : null;
16890
+ if (rules) {
16891
+ await testInfo.attach(ATTACHMENT_NAMES.cssCoverage, {
16892
+ contentType: "application/json",
16893
+ body: Buffer.from(JSON.stringify(rules))
16894
+ });
16895
+ }
16896
+ for (const part of newKnownFunctions(sink.codeReach.cssKnown, KNOWN_CSS_RULES_SENT)) {
16897
+ await testInfo.attach(ATTACHMENT_NAMES.cssRules, {
16898
+ contentType: "application/json",
16899
+ body: Buffer.from(JSON.stringify(part))
16900
+ });
16901
+ }
16902
+ }
16268
16903
  if (sink.serverCoverage.size > 0) {
16269
16904
  const requests = {};
16270
16905
  for (const [origin, ids] of sink.serverCoverage) requests[origin] = [...ids];
@@ -16546,12 +17181,12 @@ function extendPiwiFixtures(test) {
16546
17181
  }
16547
17182
 
16548
17183
  // src/internal/ai/ai-fixtures.ts
16549
- var fs28 = __toESM(require("fs"));
17184
+ var fs29 = __toESM(require("fs"));
16550
17185
  var path33 = __toESM(require("path"));
16551
17186
  var import_test = require("@playwright/test");
16552
17187
 
16553
17188
  // src/internal/ai/artifact.ts
16554
- var fs27 = __toESM(require("fs"));
17189
+ var fs28 = __toESM(require("fs"));
16555
17190
  var path31 = __toESM(require("path"));
16556
17191
  var ARTIFACT_VERSION = 1;
16557
17192
  var LOCATOR_METHOD_SET2 = new Set(LOCATOR_METHODS);
@@ -16645,7 +17280,7 @@ function parseEntry(text) {
16645
17280
  function readEntry(file) {
16646
17281
  let text;
16647
17282
  try {
16648
- text = fs27.readFileSync(file, "utf8");
17283
+ text = fs28.readFileSync(file, "utf8");
16649
17284
  } catch (error) {
16650
17285
  if (error.code === "ENOENT") return null;
16651
17286
  throw error;
@@ -16655,28 +17290,28 @@ function readEntry(file) {
16655
17290
  function writeEntry(file, entry) {
16656
17291
  const canonical = serializeEntry(entry);
16657
17292
  return withEntryLock(file, () => {
16658
- if (fs27.existsSync(file) && fs27.readFileSync(file, "utf8") === canonical) return { written: false };
16659
- fs27.mkdirSync(path31.dirname(file), { recursive: true });
17293
+ if (fs28.existsSync(file) && fs28.readFileSync(file, "utf8") === canonical) return { written: false };
17294
+ fs28.mkdirSync(path31.dirname(file), { recursive: true });
16660
17295
  const tmp = `${file}.${process.pid}.tmp`;
16661
- fs27.writeFileSync(tmp, canonical);
16662
- fs27.renameSync(tmp, file);
17296
+ fs28.writeFileSync(tmp, canonical);
17297
+ fs28.renameSync(tmp, file);
16663
17298
  return { written: true };
16664
17299
  });
16665
17300
  }
16666
17301
  function withEntryLock(file, fn) {
16667
17302
  const lock = `${file}.lock`;
16668
- fs27.mkdirSync(path31.dirname(file), { recursive: true });
17303
+ fs28.mkdirSync(path31.dirname(file), { recursive: true });
16669
17304
  const deadline = Date.now() + 5e3;
16670
17305
  for (; ; ) {
16671
17306
  try {
16672
- const fd = fs27.openSync(lock, "wx");
16673
- fs27.closeSync(fd);
17307
+ const fd = fs28.openSync(lock, "wx");
17308
+ fs28.closeSync(fd);
16674
17309
  break;
16675
17310
  } catch (error) {
16676
17311
  if (error.code !== "EEXIST") throw error;
16677
17312
  if (Date.now() > deadline) {
16678
17313
  try {
16679
- fs27.unlinkSync(lock);
17314
+ fs28.unlinkSync(lock);
16680
17315
  } catch {
16681
17316
  }
16682
17317
  }
@@ -16686,14 +17321,14 @@ function withEntryLock(file, fn) {
16686
17321
  return fn();
16687
17322
  } finally {
16688
17323
  try {
16689
- fs27.unlinkSync(lock);
17324
+ fs28.unlinkSync(lock);
16690
17325
  } catch {
16691
17326
  }
16692
17327
  }
16693
17328
  }
16694
17329
 
16695
17330
  // src/internal/ai/keys.ts
16696
- var crypto7 = __toESM(require("crypto"));
17331
+ var crypto8 = __toESM(require("crypto"));
16697
17332
  var path32 = __toESM(require("path"));
16698
17333
  var DEFAULT_AI_DIR = "__piwi__";
16699
17334
  function normalizeTemplate(template) {
@@ -16701,7 +17336,7 @@ function normalizeTemplate(template) {
16701
17336
  }
16702
17337
  function hashTemplate(normalized, ordinal = 0) {
16703
17338
  const input = ordinal > 0 ? `${normalized}#${ordinal}` : normalized;
16704
- return crypto7.createHash("sha256").update(input).digest("hex").slice(0, 8);
17339
+ return crypto8.createHash("sha256").update(input).digest("hex").slice(0, 8);
16705
17340
  }
16706
17341
  function slug(text, maxLength = 40) {
16707
17342
  const base = text.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
@@ -17320,15 +17955,15 @@ function relativeToCwd(file) {
17320
17955
  return path33.relative(process.cwd(), file).split(path33.sep).join("/");
17321
17956
  }
17322
17957
  function recordIntents(intents, entry) {
17323
- const add = (locator, kind) => {
17958
+ const add2 = (locator, kind) => {
17324
17959
  intents.set(`${entry.template}\0${locator}`, { template: entry.template, locator, kind });
17325
17960
  };
17326
17961
  if (entry.kind === "locator") {
17327
- add(locatorSource2(entry.locator), "locator");
17962
+ add2(locatorSource2(entry.locator), "locator");
17328
17963
  return;
17329
17964
  }
17330
- for (const step of entry.steps) add(locatorSource2(step.locator), "run");
17331
- if (entry.postcondition.locator) add(locatorSource2(entry.postcondition.locator), "run");
17965
+ for (const step of entry.steps) add2(locatorSource2(step.locator), "run");
17966
+ if (entry.postcondition.locator) add2(locatorSource2(entry.postcondition.locator), "run");
17332
17967
  }
17333
17968
  function missMessage(template, testTitle, relPath) {
17334
17969
  return [
@@ -17344,7 +17979,7 @@ function createAiApi(page, testInfo, config, used, intents) {
17344
17979
  const readSource = () => {
17345
17980
  if (source === null) {
17346
17981
  try {
17347
- source = fs28.readFileSync(testInfo.file, "utf8");
17982
+ source = fs29.readFileSync(testInfo.file, "utf8");
17348
17983
  } catch {
17349
17984
  source = "";
17350
17985
  }