@specific.dev/spectest 0.32.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/browser.js CHANGED
@@ -32,7 +32,7 @@ import { readFileSync } from "node:fs";
32
32
  import path from "node:path";
33
33
  import { fileURLToPath } from "node:url";
34
34
  import { generateId } from "./ids.js";
35
- import { recordBrowser, reserveEvent, truncateUtf8 } from "./recorder.js";
35
+ import { recordBrowser, reserveBackdated, reserveEvent, truncateUtf8 } from "./recorder.js";
36
36
  import { wrap } from "./inspect.js";
37
37
  import { describeUrlPattern, matchesUrl } from "./url-match.js";
38
38
  import { attachBrowserProbe, DEFAULT_ACTION_TIMEOUT_MS, desktopStrategy, makeLocator, mobileStrategy, } from "./locator.js";
@@ -1407,7 +1407,9 @@ function buildBackend(holder, recorder, buildOpts) {
1407
1407
  // No page work — the matcher already read the value via silentRead. We
1408
1408
  // only mint the timeline anchor: the seq the assertion nests under, plus
1409
1409
  // `sessionTimestamp` (post-settle wall clock) so the dashboard seeks the
1410
- // replay to the frame the assertion observed.
1410
+ // replay to the frame the assertion observed. The offset is backdated to
1411
+ // where the matcher started polling, since that's what `tOffsetMs` means
1412
+ // everywhere else (the ops that reserve up front stamp their start).
1411
1413
  const endT = Date.now();
1412
1414
  const seq = recordBrowser({
1413
1415
  action,
@@ -1417,7 +1419,7 @@ function buildBackend(holder, recorder, buildOpts) {
1417
1419
  : {}),
1418
1420
  durationMs: waitedMs,
1419
1421
  ...(error ? { error } : {}),
1420
- });
1422
+ }, reserveBackdated(waitedMs));
1421
1423
  // Drain the rrweb the page buffered while the matcher waited into this
1422
1424
  // step's chunk, so `settledTarget` has bounds to seek into.
1423
1425
  await drain(action);
package/dist/daemon.js CHANGED
@@ -511,6 +511,17 @@ async function recordAbsoluteVolumeDir(host) {
511
511
  ABS_VOLUME_DIRS.add(host);
512
512
  await fs.writeFile(VOLUME_DIRS_MANIFEST, [...ABS_VOLUME_DIRS].join("\n") + "\n");
513
513
  }
514
+ /// True when `p` exists and is not a directory (a socket, a regular file,
515
+ /// a device). Used to tell "a volume dir we must create" from "a VM-host
516
+ /// object the service asked to bind-mount as-is".
517
+ async function isExistingNonDirectory(p) {
518
+ try {
519
+ return !(await fs.lstat(p)).isDirectory();
520
+ }
521
+ catch {
522
+ return false;
523
+ }
524
+ }
514
525
  async function ensureVolumes(svc) {
515
526
  const flags = [];
516
527
  if (!svc.volumes || svc.volumes.length === 0)
@@ -522,9 +533,18 @@ async function ensureVolumes(svc) {
522
533
  throw new Error(`service "${svc.name}" volume for ${JSON.stringify(vol.target)} sets both \`name\` and \`source\``);
523
534
  }
524
535
  const host = resolveHostPath(svc.name, vol);
525
- await fs.mkdir(host, { recursive: true });
526
- if (vol.source?.startsWith("/") && !host.startsWith("/var/cache/spectest/")) {
527
- await recordAbsoluteVolumeDir(host);
536
+ // An absolute `source` can name something that already exists and is
537
+ // NOT a directory — the canonical case is the VM's docker socket
538
+ // (`/var/run/docker.sock`), bind-mounted into a service that drives
539
+ // docker itself (LocalStack's Lambda executor, dind-style builders).
540
+ // `mkdir -p` fails on those with EEXIST. They are also VM-host
541
+ // infrastructure rather than environment state, so they are neither
542
+ // created here nor recorded for the delta-restore wipe.
543
+ if (!(await isExistingNonDirectory(host))) {
544
+ await fs.mkdir(host, { recursive: true });
545
+ if (vol.source?.startsWith("/") && !host.startsWith("/var/cache/spectest/")) {
546
+ await recordAbsoluteVolumeDir(host);
547
+ }
528
548
  }
529
549
  flags.push(`--volume=${host}:${vol.target}${vol.readOnly ? ":ro" : ""}`);
530
550
  }
@@ -1575,17 +1595,22 @@ async function startIngress() {
1575
1595
  routes.set(p.hostname, { kind: "proxy", service: p.service, port: p.port });
1576
1596
  }
1577
1597
  }
1578
- // The :443 route table mirrors every certificated hostname's handler.
1598
+ // The :443 route table mirrors every handler whose hostname a cert
1599
+ // covers — exactly, or through a wildcard SAN, so `*.example.com` in a
1600
+ // service's `tls` also puts an unrelated exact `api.example.com` route on
1601
+ // HTTPS. Proxies are applied after fakes so a proxy wins a shared host.
1579
1602
  if (HTTPS_CERT_BY_HOST.size > 0) {
1580
1603
  const httpsRoutes = ensurePort(INGRESS_HTTPS_PORT);
1581
- for (const h of HTTPS_CERT_BY_HOST.keys()) {
1582
- for (const fake of FAKES.values()) {
1583
- if (fake.hostnames.includes(h))
1604
+ for (const fake of FAKES.values()) {
1605
+ for (const h of fake.hostnames) {
1606
+ if (certCovers(h))
1584
1607
  httpsRoutes.set(h, { kind: "fake", fake });
1585
1608
  }
1586
- const proxy = LOWERED.proxies.find((p) => p.hostname === h);
1587
- if (proxy)
1588
- httpsRoutes.set(h, { kind: "proxy", service: proxy.service, port: proxy.port });
1609
+ }
1610
+ for (const p of LOWERED.proxies) {
1611
+ if (certCovers(p.hostname)) {
1612
+ httpsRoutes.set(p.hostname, { kind: "proxy", service: p.service, port: p.port });
1613
+ }
1589
1614
  }
1590
1615
  }
1591
1616
  // ── HTTP listeners (one per non-443 port).
@@ -1649,12 +1674,26 @@ function rebindHttpsListener(Bun) {
1649
1674
  // eslint-disable-next-line no-console
1650
1675
  console.log(`[ingress] https :${INGRESS_HTTPS_PORT} for ${[...routes.keys()].join(", ")}`);
1651
1676
  }
1677
+ /**
1678
+ * RFC 6125 wildcard match: `*.example.com` covers `api.example.com` but NOT
1679
+ * `a.b.example.com`. Deliberately stricter than {@link matchIngressRoute}'s
1680
+ * suffix matching — this one has to agree with the TLS *client*, and every
1681
+ * client stops at one label. Claiming a deeper name is covered would skip
1682
+ * minting the leaf it actually needs and hand it a cert it rejects.
1683
+ */
1684
+ function wildcardCoversHost(pattern, hostname) {
1685
+ const suffix = wildcardSuffix(pattern); // "*.example.com" → ".example.com"
1686
+ if (!hostname.endsWith(suffix))
1687
+ return false;
1688
+ const label = hostname.slice(0, -suffix.length);
1689
+ return label.length > 0 && !label.includes(".");
1690
+ }
1652
1691
  /** True if an exact or wildcard cert already covers `hostname` for SNI. */
1653
1692
  function certCovers(hostname) {
1654
1693
  if (HTTPS_CERT_BY_HOST.has(hostname))
1655
1694
  return true;
1656
1695
  for (const serverName of HTTPS_CERT_BY_HOST.keys()) {
1657
- if (isWildcard(serverName) && hostname.endsWith(wildcardSuffix(serverName))) {
1696
+ if (isWildcard(serverName) && wildcardCoversHost(serverName, hostname)) {
1658
1697
  return true;
1659
1698
  }
1660
1699
  }
@@ -1706,8 +1745,16 @@ async function bindRuntimeTls(hostname, service, port) {
1706
1745
  rebindHttpsListener(Bun);
1707
1746
  }
1708
1747
  // Resolve the hostname to the daemon gateway (where :443/:80 listen).
1748
+ // A wildcard can only live in the resolver's suffix table.
1709
1749
  const gw = await bridgeGatewayIp();
1710
- REGISTRY.hosts[host] = gw;
1750
+ if (isWildcard(host)) {
1751
+ const suffix = wildcardSuffix(host);
1752
+ REGISTRY.wildcards = REGISTRY.wildcards.filter((w) => w.suffix !== suffix);
1753
+ REGISTRY.wildcards.push({ suffix, ip: gw });
1754
+ }
1755
+ else {
1756
+ REGISTRY.hosts[host] = gw;
1757
+ }
1711
1758
  await writeRegistry();
1712
1759
  // eslint-disable-next-line no-console
1713
1760
  console.log(`[ingress] runtime https ${host} -> ${service}:${port}`);
@@ -1722,7 +1769,14 @@ async function unbindRuntimeTls(hostname) {
1722
1769
  const host = hostname.toLowerCase();
1723
1770
  INGRESS_ROUTES_BY_PORT.get(INGRESS_HTTP_PORT)?.delete(host);
1724
1771
  INGRESS_ROUTES_BY_PORT.get(INGRESS_HTTPS_PORT)?.delete(host);
1725
- if (host in REGISTRY.hosts) {
1772
+ if (isWildcard(host)) {
1773
+ const suffix = wildcardSuffix(host);
1774
+ const before = REGISTRY.wildcards.length;
1775
+ REGISTRY.wildcards = REGISTRY.wildcards.filter((w) => w.suffix !== suffix);
1776
+ if (REGISTRY.wildcards.length !== before)
1777
+ await writeRegistry();
1778
+ }
1779
+ else if (host in REGISTRY.hosts) {
1726
1780
  delete REGISTRY.hosts[host];
1727
1781
  await writeRegistry();
1728
1782
  }
@@ -1838,6 +1892,35 @@ Bun, port, byHost, listenerLabel, tlsEntries) {
1838
1892
  opts.tls = tlsEntries;
1839
1893
  return Bun.serve(opts);
1840
1894
  }
1895
+ /**
1896
+ * Resolve a Host header to a route: an exact entry first, then the longest
1897
+ * matching `*.suffix` wildcard. Same precedence the resolver applies to DNS
1898
+ * (exact beats wildcard, longest suffix beats shorter), so a name that DNS
1899
+ * pointed at the ingress finds the route that claimed it.
1900
+ *
1901
+ * A wildcard *route* matches any depth of subdomain, while a wildcard
1902
+ * *cert* covers exactly one label ({@link wildcardCoversHost} — RFC 6125,
1903
+ * what TLS clients enforce). So `a.b.example.com` under a `*.example.com`
1904
+ * proxy reverse-proxies over `http://` but needs its own `tls` entry to
1905
+ * present a valid cert over `https://`.
1906
+ */
1907
+ function matchIngressRoute(byHost, host) {
1908
+ const exact = byHost.get(host);
1909
+ if (exact)
1910
+ return exact;
1911
+ let best;
1912
+ let bestLen = -1;
1913
+ for (const [pattern, route] of byHost) {
1914
+ if (!isWildcard(pattern))
1915
+ continue;
1916
+ const suffix = wildcardSuffix(pattern);
1917
+ if (host.endsWith(suffix) && suffix.length > bestLen) {
1918
+ best = route;
1919
+ bestLen = suffix.length;
1920
+ }
1921
+ }
1922
+ return best;
1923
+ }
1841
1924
  /**
1842
1925
  * Per-request dispatch shared by every ingress listener. Looks up the
1843
1926
  * Route by Host header (port stripped) and either:
@@ -1852,7 +1935,7 @@ server, byHost, listenerLabel, proto) {
1852
1935
  .toLowerCase()
1853
1936
  .split(":")[0]
1854
1937
  .trim();
1855
- const route = byHost.get(host);
1938
+ const route = matchIngressRoute(byHost, host);
1856
1939
  if (!route) {
1857
1940
  return new Response(`spectest-daemon: no ingress route bound to Host=${JSON.stringify(host)} on ${listenerLabel}\n`, { status: 404, headers: { "content-type": "text/plain" } });
1858
1941
  }
package/dist/index.d.ts CHANGED
@@ -72,6 +72,11 @@ export interface ServiceConfig {
72
72
  * instead — those names terminate TLS in the daemon and reverse-proxy
73
73
  * to the service's HTTP port.
74
74
  *
75
+ * A `*.suffix` wildcard (e.g. `"*.example.com"`) points a whole domain
76
+ * at this service. Wildcards are answered by spectest-resolver only —
77
+ * they never land in a container's `/etc/hosts` — which peer containers
78
+ * still reach, since Docker forwards unknown names to that resolver.
79
+ *
75
80
  * The `.internal` TLD is reserved: every service automatically
76
81
  * answers to `<name>.internal` in addition to its bare `<name>`, and
77
82
  * user-supplied hostnames may not end in `.internal`.
@@ -92,6 +97,15 @@ export interface ServiceConfig {
92
97
  * tests and from peer services. Hostname rules match {@link hostnames}:
93
98
  * multi-label, lowercase, no `.internal` suffix, no collision with
94
99
  * services, other service TLS hostnames, or fakes.
100
+ *
101
+ * A `*.suffix` wildcard claims a whole domain with one entry — e.g.
102
+ * `{ hostname: "*.us-east-1.amazonaws.com", port: 4566 }` puts every
103
+ * AWS regional endpoint on one emulator container, so an unmodified SDK
104
+ * reaches it at its production URL. The leaf cert gets a wildcard SAN,
105
+ * which (as in every TLS client) covers exactly **one** label: declare
106
+ * a separate entry for anything deeper. An exact `tls` hostname always
107
+ * beats a wildcard, so a single endpoint can be split off to another
108
+ * service.
95
109
  */
96
110
  tls?: readonly ServiceTls[];
97
111
  /** Bind-mounted volumes for state that survives snapshot/fork. */
@@ -625,6 +639,12 @@ export interface VolumeMount {
625
639
  /**
626
640
  * Host path. Relative paths resolve under
627
641
  * `.spectest/volumes/<service>/`. Defaults to a path derived from `target`.
642
+ *
643
+ * An absolute path that already exists and is not a directory is
644
+ * bind-mounted as-is — the way to hand a service the VM's docker socket
645
+ * (`source: "/var/run/docker.sock"`), which LocalStack's Lambda executor
646
+ * and dind-style builders need. Such a mount is VM-host infrastructure,
647
+ * so unlike a volume directory it is never created or wiped by spectest.
628
648
  */
629
649
  source?: string;
630
650
  /** Container path. */
package/dist/index.js CHANGED
@@ -31,6 +31,7 @@ import { describeUrlPattern, matchesUrl } from "./url-match.js";
31
31
  // `tls` / `hostnames` fields and `defineFake(...)` are built on. See
32
32
  // `ingress.ts`.
33
33
  export { certificate, dnsName, proxy, provides, lowerIngress, isWildcard, SELF_SERVICE_TOKEN, } from "./ingress.js";
34
+ import { isWildcard as isWildcardHost } from "./ingress.js";
34
35
  // ──────────────────────────────────────────────────────────────────────────
35
36
  // Service groups — one services-map entry that expands to several services.
36
37
  //
@@ -234,8 +235,8 @@ function validateEnvironmentConfig(config) {
234
235
  }
235
236
  for (const raw of svc.hostnames ?? []) {
236
237
  const h = raw.toLowerCase();
237
- if (!HOSTNAME_RE.test(h)) {
238
- throw new Error(`service "${name}" declares invalid hostname ${JSON.stringify(raw)} — must be a multi-label DNS name (e.g. "api.stripe.com")`);
238
+ if (!HOSTNAME_RE.test(hostPatternBody(h))) {
239
+ throw new Error(`service "${name}" declares invalid hostname ${JSON.stringify(raw)} — must be a multi-label DNS name or a wildcard (e.g. "api.stripe.com", "*.stripe.com")`);
239
240
  }
240
241
  if (h === "internal" || h.endsWith(".internal")) {
241
242
  throw new Error(`service "${name}" declares hostname ${JSON.stringify(raw)} — the ".internal" TLD is reserved; every service already answers to "<name>.internal" automatically`);
@@ -247,8 +248,8 @@ function validateEnvironmentConfig(config) {
247
248
  throw new Error(`service "${name}" tls entry must be { hostname: string, port: number }; got ${JSON.stringify(entry)}`);
248
249
  }
249
250
  const h = entry.hostname.toLowerCase();
250
- if (!HOSTNAME_RE.test(h)) {
251
- throw new Error(`service "${name}" tls hostname ${JSON.stringify(entry.hostname)} is not a multi-label DNS name (e.g. "app.test")`);
251
+ if (!HOSTNAME_RE.test(hostPatternBody(h))) {
252
+ throw new Error(`service "${name}" tls hostname ${JSON.stringify(entry.hostname)} is not a multi-label DNS name or wildcard (e.g. "app.test", "*.us-east-1.amazonaws.com")`);
252
253
  }
253
254
  if (h === "internal" || h.endsWith(".internal")) {
254
255
  throw new Error(`service "${name}" tls hostname ${JSON.stringify(entry.hostname)} ends in reserved ".internal" TLD`);
@@ -263,6 +264,13 @@ function validateEnvironmentConfig(config) {
263
264
  // Multi-label hostname: at least one dot, each label 1–63 chars of
264
265
  // [a-z0-9-], no leading/trailing hyphen.
265
266
  const HOSTNAME_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/;
267
+ /** The part of a declared name that must be a valid hostname: a `*.suffix`
268
+ * wildcard is checked by its suffix, so `*.example.com` passes but `*.com`
269
+ * (single-label, too broad) and `*` do not. Keeps the `.internal` checks
270
+ * honest too — `*.foo.internal` reduces to `foo.internal`. */
271
+ function hostPatternBody(lowercased) {
272
+ return isWildcardHost(lowercased) ? lowercased.slice(2) : lowercased;
273
+ }
266
274
  /**
267
275
  * Define a fake. `defineFake({ ... })` is a thin wrapper that pins the
268
276
  * generic `S` and `H` so `helpers`/`handler` see the inferred state type
package/dist/ingress.d.ts CHANGED
@@ -38,6 +38,11 @@ export declare function isWildcard(hostname: string): boolean;
38
38
  * `hostnames`. The daemon binds it on the HTTPS ingress (:443, SNI per
39
39
  * hostname). Pairs with a `proxy(...)` (TLS-terminated reverse proxy) or a
40
40
  * fake handler; on its own it just makes those hostnames serve HTTPS.
41
+ *
42
+ * A `*.suffix` wildcard is allowed and becomes a wildcard SAN, so one cert
43
+ * covers a whole domain (`*.us-east-1.amazonaws.com`). As in any TLS
44
+ * client, a wildcard SAN covers exactly **one** label — `*.example.com`
45
+ * matches `api.example.com`, not `a.b.example.com`.
41
46
  */
42
47
  export declare function certificate(hostnames: string[]): CertificateDecl;
43
48
  /**
@@ -54,6 +59,12 @@ export declare function dnsName(hostname: string, target: DnsTarget): DnsDecl;
54
59
  * Implies the hostname resolves to the daemon ingress, so containers reach
55
60
  * it without any extra `dnsName(...)`. Add a `certificate([hostname])` to
56
61
  * serve it over HTTPS as well as HTTP.
62
+ *
63
+ * `hostname` may be a `*.suffix` wildcard, which sends every name under
64
+ * that domain to the same upstream — one route for a whole API surface
65
+ * (`*.us-east-1.amazonaws.com` → a LocalStack container). Exact routes
66
+ * always win over wildcards, and the longest wildcard suffix wins among
67
+ * wildcards.
57
68
  */
58
69
  export declare function proxy(hostname: string, upstream: {
59
70
  service: string;
package/dist/ingress.js CHANGED
@@ -53,13 +53,18 @@ function assertNameOrWildcard(h, ctx) {
53
53
  * `hostnames`. The daemon binds it on the HTTPS ingress (:443, SNI per
54
54
  * hostname). Pairs with a `proxy(...)` (TLS-terminated reverse proxy) or a
55
55
  * fake handler; on its own it just makes those hostnames serve HTTPS.
56
+ *
57
+ * A `*.suffix` wildcard is allowed and becomes a wildcard SAN, so one cert
58
+ * covers a whole domain (`*.us-east-1.amazonaws.com`). As in any TLS
59
+ * client, a wildcard SAN covers exactly **one** label — `*.example.com`
60
+ * matches `api.example.com`, not `a.b.example.com`.
56
61
  */
57
62
  export function certificate(hostnames) {
58
63
  if (!Array.isArray(hostnames) || hostnames.length === 0) {
59
64
  throw new Error("certificate(): at least one hostname is required");
60
65
  }
61
66
  for (const h of hostnames)
62
- assertHostname(h, "certificate()");
67
+ assertNameOrWildcard(h, "certificate()");
63
68
  return { kind: "certificate", hostnames: hostnames.map((h) => h.toLowerCase()) };
64
69
  }
65
70
  /**
@@ -82,9 +87,15 @@ export function dnsName(hostname, target) {
82
87
  * Implies the hostname resolves to the daemon ingress, so containers reach
83
88
  * it without any extra `dnsName(...)`. Add a `certificate([hostname])` to
84
89
  * serve it over HTTPS as well as HTTP.
90
+ *
91
+ * `hostname` may be a `*.suffix` wildcard, which sends every name under
92
+ * that domain to the same upstream — one route for a whole API surface
93
+ * (`*.us-east-1.amazonaws.com` → a LocalStack container). Exact routes
94
+ * always win over wildcards, and the longest wildcard suffix wins among
95
+ * wildcards.
85
96
  */
86
97
  export function proxy(hostname, upstream) {
87
- assertHostname(hostname, "proxy()");
98
+ assertNameOrWildcard(hostname, "proxy()");
88
99
  if (!upstream || typeof upstream.service !== "string" || typeof upstream.port !== "number") {
89
100
  throw new Error(`proxy(${JSON.stringify(hostname)}): upstream must be { service: string, port: number }`);
90
101
  }
@@ -153,8 +164,15 @@ export function lowerIngress(project) {
153
164
  case "proxy": {
154
165
  const service = resolveSelf(decl.upstream.service, selfKey);
155
166
  proxies.push({ hostname: decl.hostname, service, port: decl.upstream.port });
156
- // A proxied hostname must route to the daemon.
157
- ingressSet.add(decl.hostname);
167
+ // A proxied hostname must route to the daemon. A wildcard one can
168
+ // only be answered by the resolver (there is no `--add-host` for a
169
+ // pattern), so it goes in the wildcard table pointed at the ingress.
170
+ if (isWildcard(decl.hostname)) {
171
+ wildcards.push({ pattern: decl.hostname, target: { ingress: true } });
172
+ }
173
+ else {
174
+ ingressSet.add(decl.hostname);
175
+ }
158
176
  break;
159
177
  }
160
178
  case "dns": {
@@ -5,8 +5,11 @@ interface BaseEvent {
5
5
  * before it does still sorts ahead of them. Ops that don't reserve get
6
6
  * their seq at record (= finish) time. */
7
7
  seq: number;
8
- /** Milliseconds since the recorder started, captured when the op began
9
- * (reserved) or, absent a reservation, when it was recorded. */
8
+ /** Milliseconds since the recorder started, captured when the op *began* —
9
+ * either reserved up front, or backdated at record time by an op that could
10
+ * only reserve at finish (`reserveBackdated`). Consumers read the step's
11
+ * completion time as `tOffsetMs + durationMs`, so an op that stamps a bare
12
+ * finish offset while also reporting a duration will render late. */
10
13
  tOffsetMs: number;
11
14
  /**
12
15
  * Optional grouping pointer. When set, this event was emitted inside
@@ -475,6 +478,14 @@ export declare function isRecording(): boolean;
475
478
  * when nothing is recording, in which case `record*` falls back to allocating
476
479
  * the seq at record time. */
477
480
  export declare function reserveEvent(): EventReservation | undefined;
481
+ /** Reserve at op *finish* but backdate the offset by `elapsedMs` — for ops that
482
+ * can't reserve up front because they only know they were an op once they're
483
+ * done (the locator matchers: they poll silently and emit one settled step at
484
+ * the end). Keeps `tOffsetMs` meaning "when the op started" for every kind,
485
+ * which is what lets the dashboard render `tOffsetMs + durationMs` as the
486
+ * step's completion time. Ordering is unaffected — the caller records
487
+ * immediately, so the seq is the one it would have got anyway. */
488
+ export declare function reserveBackdated(elapsedMs: number): EventReservation | undefined;
478
489
  export declare function recordExec(ev: Omit<ExecEvent, "seq" | "tOffsetMs" | "kind">, reservation?: EventReservation): number | undefined;
479
490
  export declare function recordAssertion(ev: Omit<AssertionEvent, "seq" | "tOffsetMs" | "kind">): number | undefined;
480
491
  export declare function recordHttp(ev: Omit<HttpEvent, "seq" | "tOffsetMs" | "kind">, reservation?: EventReservation): number | undefined;
package/dist/recorder.js CHANGED
@@ -132,6 +132,19 @@ export function isRecording() {
132
132
  export function reserveEvent() {
133
133
  return active() ? current.reserve() : undefined;
134
134
  }
135
+ /** Reserve at op *finish* but backdate the offset by `elapsedMs` — for ops that
136
+ * can't reserve up front because they only know they were an op once they're
137
+ * done (the locator matchers: they poll silently and emit one settled step at
138
+ * the end). Keeps `tOffsetMs` meaning "when the op started" for every kind,
139
+ * which is what lets the dashboard render `tOffsetMs + durationMs` as the
140
+ * step's completion time. Ordering is unaffected — the caller records
141
+ * immediately, so the seq is the one it would have got anyway. */
142
+ export function reserveBackdated(elapsedMs) {
143
+ const resv = reserveEvent();
144
+ if (!resv)
145
+ return undefined;
146
+ return { seq: resv.seq, tOffsetMs: Math.max(0, resv.tOffsetMs - Math.max(0, elapsedMs)) };
147
+ }
135
148
  export function recordExec(ev, reservation) {
136
149
  return active() ? current.push({ kind: "exec", ...ev }, reservation) : undefined;
137
150
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",