@specific.dev/spectest 0.35.1 → 0.36.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/browser.js CHANGED
@@ -305,15 +305,29 @@ const RRWEB_BOOTSTRAP = `
305
305
  // src points at a same-origin URL (e.g. next/font's
306
306
  // /_next/static/media/*.woff2) is unreachable at replay time and the
307
307
  // page silently falls back to a system font. collectFonts above won't
308
- // help (FontFace-API fonts only), so inline the bytes ourselves: once
309
- // fonts have loaded, fetch each url() (same-origin -> allowed, and the
310
- // page already trusts the in-VM CA), rewrite the rule's src to a data:
308
+ // help (FontFace-API fonts only), so inline the bytes ourselves: fetch
309
+ // each url() (same-origin -> allowed, and the page already trusts the
310
+ // in-VM CA), rewrite the rule's src to a data:
311
311
  // URI, then take a fresh full snapshot so the now-self-contained CSS
312
312
  // is captured. Plain string ops, no regex — this whole script ships
313
313
  // inside a TS template literal where regex backslashes get mangled.
314
+ //
315
+ // Do NOT start this scan from document.fonts.ready. This script runs at
316
+ // document-start. At that moment the font set is idle, so Chrome settles
317
+ // that first ready promise immediately, before the parser has registered
318
+ // one stylesheet. The scan then saw 0 @font-face rules, and a one-shot
319
+ // latch stopped it from looking again, so no font was ever inlined.
320
+ // (Found on run_03j0mxtvp6x0p16d9y83e: every next/font face fell back.
321
+ // The diagnostics were invisible too, because rrweb's console plugin
322
+ // only patches console at the load event.)
323
+ //
324
+ // Two triggers instead: the load event, by which all render-blocking CSS
325
+ // is parsed, and each \`loadingdone\` on document.fonts, which catches CSS
326
+ // added later by an HMR update or a client-side route change. The scan
327
+ // skips a rule it already inlined, so repeated triggers are cheap.
314
328
  (function () {
315
- if (window.__spectestFontsInlined) return;
316
- window.__spectestFontsInlined = true;
329
+ if (window.__spectestFontInliner) return;
330
+ window.__spectestFontInliner = true;
317
331
  // Diagnostics ride the console-record plugin, so they land in the
318
332
  // recording (and state.db) — our only debugging window into the
319
333
  // headless page. Prefix is grep-able; keep them terse.
@@ -370,7 +384,12 @@ const RRWEB_BOOTSTRAP = `
370
384
  }
371
385
  }
372
386
  }
373
- function run() {
387
+ // Every @font-face cssText this run already rewrote. A rule stays out
388
+ // of later scans, so an HMR update that adds one stylesheet costs one
389
+ // fetch, not a re-inline of the whole page.
390
+ var inlined = {};
391
+ var scans = 0, busy = false, rescan = false;
392
+ function collect() {
374
393
  // Collect each @font-face's *cssText* (reliable for any CSSRule —
375
394
  // unlike .style.setProperty('src',…), which silently no-ops on
376
395
  // CSSFontFaceRule in Chrome) plus the http url()s inside it.
@@ -384,13 +403,26 @@ const RRWEB_BOOTSTRAP = `
384
403
  eachFontFace(rules, function (rule) {
385
404
  faceCount++;
386
405
  var cssText = rule.cssText || "";
406
+ if (inlined[cssText]) return;
387
407
  var urls = urlsIn(cssText, base);
388
408
  if (urls.length) faces.push({ cssText: cssText, urls: urls });
389
409
  });
390
410
  }
391
- log("sheets=" + sheets.length + " readable=" + readable +
392
- " fontFaces=" + faceCount + " withUrl=" + faces.length);
411
+ // Log the first scan (so a page with no web fonts still says so) and
412
+ // every scan with work to do. A quiet re-trigger stays silent.
413
+ if (!scans++ || faces.length) {
414
+ log("sheets=" + sheets.length + " readable=" + readable +
415
+ " fontFaces=" + faceCount + " new=" + faces.length);
416
+ }
417
+ return faces;
418
+ }
419
+ function run() {
420
+ // One pass at a time. A trigger during a pass queues exactly one
421
+ // more, which then sees the marks the pass just wrote.
422
+ if (busy) { rescan = true; return; }
423
+ var faces = collect();
393
424
  if (!faces.length) return;
425
+ busy = true;
394
426
  var jobs = faces.map(function (face) {
395
427
  return Promise.all(face.urls.map(function (u) {
396
428
  return fetchDataUri(u.abs)
@@ -403,7 +435,9 @@ const RRWEB_BOOTSTRAP = `
403
435
  text = text.split(pairs[k].token).join(pairs[k].dataUri);
404
436
  changed = true;
405
437
  }
406
- return changed ? text : null;
438
+ if (!changed) return null;
439
+ inlined[face.cssText] = true;
440
+ return text;
407
441
  });
408
442
  });
409
443
  Promise.all(jobs).then(function (texts) {
@@ -426,15 +460,21 @@ const RRWEB_BOOTSTRAP = `
426
460
  log("took full snapshot");
427
461
  }
428
462
  } catch (e) { log("snapshot FAIL " + (e && e.message)); }
463
+ }).catch(function (e) {
464
+ log("run FAIL " + (e && e.message));
465
+ }).then(function () {
466
+ busy = false;
467
+ if (rescan) { rescan = false; setTimeout(run, 0); }
429
468
  });
430
469
  }
470
+ function schedule() { setTimeout(run, 0); }
431
471
  try {
432
- if (document.fonts && document.fonts.ready && document.fonts.ready.then) {
433
- document.fonts.ready.then(function () { setTimeout(run, 0); });
434
- } else if (document.readyState === "complete") {
435
- run();
436
- } else {
437
- window.addEventListener("load", run);
472
+ if (document.readyState === "complete") schedule();
473
+ else window.addEventListener("load", schedule);
474
+ // Fires each time a batch of fonts finishes loading, including fonts
475
+ // pulled in by CSS that arrives after the load event.
476
+ if (document.fonts && document.fonts.addEventListener) {
477
+ document.fonts.addEventListener("loadingdone", schedule);
438
478
  }
439
479
  } catch (e) { log("init FAIL " + (e && e.message)); }
440
480
  })();
@@ -1,3 +1,4 @@
1
+ import type { ServiceSetupContext } from "../index.js";
1
2
  import { type SqlClient } from "../sql.js";
2
3
  export interface PostgresOptions {
3
4
  /** Image tag for the official `postgres` image. Default `"18-alpine"`.
@@ -32,6 +33,28 @@ export interface PostgresOptions {
32
33
  args?: string[];
33
34
  /** Extra environment variables forwarded to the container. */
34
35
  env?: Record<string, string>;
36
+ /**
37
+ * One-shot hook that runs after the ready probe passes, before any
38
+ * dependent service starts and before any test. This is where schema
39
+ * and seed data go: the daemon captures the result into the
40
+ * warm-template snapshot, so it never runs again on a warm start or a
41
+ * fork.
42
+ *
43
+ * The argument is the full test context plus this service's `name` and
44
+ * its {@link PostgresHelpers}, so the instrumented client is already
45
+ * built:
46
+ *
47
+ * ```ts
48
+ * db: postgres({
49
+ * database: "todos", user: "todos", password: "todos",
50
+ * setup: async ({ helpers }) => {
51
+ * await helpers.client`CREATE TABLE todos (
52
+ * id SERIAL PRIMARY KEY, text TEXT NOT NULL)`;
53
+ * },
54
+ * }),
55
+ * ```
56
+ */
57
+ setup?: (args: ServiceSetupContext<PostgresHelpers>) => void | Promise<void>;
35
58
  }
36
59
  /** Helpers a `postgres(...)` service exposes on `ctx.svc.<name>`. */
37
60
  export interface PostgresHelpers {
@@ -67,6 +90,7 @@ export interface PostgresHelpers {
67
90
  * ```
68
91
  */
69
92
  export declare function postgres(opts: PostgresOptions): {
93
+ setup?: ((args: ServiceSetupContext<PostgresHelpers>) => void | Promise<void>) | undefined;
70
94
  env: {
71
95
  POSTGRES_DB: string;
72
96
  POSTGRES_USER: string;
@@ -54,5 +54,10 @@ export function postgres(opts) {
54
54
  `@${name}:${port}/${encodeURIComponent(opts.database)}`;
55
55
  return { client: new SQL(url, { label: name }) };
56
56
  },
57
+ // The user's hook, forwarded verbatim — postgres() declares none of
58
+ // its own. If it ever gains one, CHAIN the user's after it (the way
59
+ // `serviceGroup` chains a group hook after the primary part's) rather
60
+ // than letting either one win.
61
+ ...(opts.setup ? { setup: opts.setup } : {}),
57
62
  };
58
63
  }
package/dist/index.d.ts CHANGED
@@ -455,8 +455,17 @@ export interface ServiceDefinition<H extends Record<string, any> = Record<string
455
455
  }
456
456
  /** A services map — what users pass to `environment.services`. Entries
457
457
  * can be plain `ServiceConfig` literals or `ServiceDefinition`s that
458
- * carry a `helpers` factory (e.g. what `postgres(...)` returns). */
459
- export type ServicesMap = Record<string, ServiceConfig>;
458
+ * carry a `helpers` factory (e.g. what `postgres(...)` returns).
459
+ *
460
+ * The entry type is `ServiceDefinition`, not `ServiceConfig`: at runtime
461
+ * the map really does hold `helpers`/`setup` (the daemon reads them off
462
+ * each entry), and both are optional, so every plain `ServiceConfig`
463
+ * still satisfies it. Narrowing this to `ServiceConfig` — the wire type,
464
+ * which by definition carries no functions — made the natural way to
465
+ * hoist a services map out of `index.ts` (`… satisfies ServicesMap`) fail
466
+ * the typecheck on any service declaring a hook, while the same literal
467
+ * written inline passed. */
468
+ export type ServicesMap = Record<string, ServiceDefinition<any>>;
460
469
  /** Awaited return type of a service's `helpers` factory, or `never` if
461
470
  * the service doesn't ship one. */
462
471
  type HelpersOf<D> = D extends {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.35.1",
3
+ "version": "0.36.1",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",