@specific.dev/spectest 0.39.0 → 0.43.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.
Files changed (105) hide show
  1. package/dist/browser.d.ts +21 -8
  2. package/dist/browser.js +78 -36
  3. package/dist/components/supabase.d.ts +87 -27
  4. package/dist/components/supabase.js +352 -69
  5. package/dist/daemon.d.ts +38 -0
  6. package/dist/daemon.js +464 -987
  7. package/dist/harness/build-context.d.ts +82 -0
  8. package/dist/harness/build-context.js +113 -0
  9. package/dist/harness/buildkit-progress.d.ts +37 -0
  10. package/dist/harness/buildkit-progress.js +66 -0
  11. package/dist/harness/container-run.d.ts +89 -0
  12. package/dist/harness/container-run.js +118 -0
  13. package/dist/harness/file-mounts.d.ts +91 -0
  14. package/dist/harness/file-mounts.js +119 -0
  15. package/dist/harness/hostmatch.d.ts +65 -0
  16. package/dist/harness/hostmatch.js +108 -0
  17. package/dist/harness/http-proxy.d.ts +62 -0
  18. package/dist/harness/http-proxy.js +104 -0
  19. package/dist/harness/ingress-table.d.ts +148 -0
  20. package/dist/harness/ingress-table.js +129 -0
  21. package/dist/harness/log-delta.d.ts +54 -0
  22. package/dist/harness/log-delta.js +83 -0
  23. package/dist/harness/main.d.ts +47 -0
  24. package/dist/harness/main.js +164 -0
  25. package/dist/harness/methods.d.ts +54 -0
  26. package/dist/harness/methods.js +65 -0
  27. package/dist/harness/names-registry.d.ts +63 -0
  28. package/dist/harness/names-registry.js +90 -0
  29. package/dist/harness/protocol.d.ts +88 -0
  30. package/dist/harness/protocol.js +96 -0
  31. package/dist/harness/ready-poll.d.ts +47 -0
  32. package/dist/harness/ready-poll.js +67 -0
  33. package/dist/harness/service-graph.d.ts +29 -0
  34. package/dist/harness/service-graph.js +92 -0
  35. package/dist/harness/volume-paths.d.ts +70 -0
  36. package/dist/harness/volume-paths.js +81 -0
  37. package/dist/index.d.ts +58 -16
  38. package/dist/ingress.d.ts +1 -1
  39. package/dist/mobile.d.ts +9 -5
  40. package/dist/mobile.js +7 -6
  41. package/dist/recorder.d.ts +10 -0
  42. package/dist/resolver.js +5 -8
  43. package/dist/vendor/rrweb-plugin-console-record.umd.js +521 -0
  44. package/dist/vendor/rrweb-record.min.js +5061 -0
  45. package/package.json +7 -1
  46. package/src/aws-sigv4.ts +218 -0
  47. package/src/browser.ts +2095 -0
  48. package/src/components/aws.ts +554 -0
  49. package/src/components/email.ts +398 -0
  50. package/src/components/expo.ts +167 -0
  51. package/src/components/index.ts +81 -0
  52. package/src/components/k3s.ts +2061 -0
  53. package/src/components/postgres.ts +132 -0
  54. package/src/components/replayFake.ts +1015 -0
  55. package/src/components/s3.ts +132 -0
  56. package/src/components/supabase.ts +1699 -0
  57. package/src/daemon.ts +5537 -0
  58. package/src/harness/build-context.test.ts +0 -0
  59. package/src/harness/build-context.ts +146 -0
  60. package/src/harness/buildkit-progress.test.ts +98 -0
  61. package/src/harness/buildkit-progress.ts +74 -0
  62. package/src/harness/container-run.test.ts +209 -0
  63. package/src/harness/container-run.ts +158 -0
  64. package/src/harness/file-mounts.test.ts +185 -0
  65. package/src/harness/file-mounts.ts +145 -0
  66. package/src/harness/hostmatch.test.ts +148 -0
  67. package/src/harness/hostmatch.ts +109 -0
  68. package/src/harness/http-proxy.test.ts +156 -0
  69. package/src/harness/http-proxy.ts +119 -0
  70. package/src/harness/ingress-rebind.test.ts +125 -0
  71. package/src/harness/ingress-table.test.ts +172 -0
  72. package/src/harness/ingress-table.ts +186 -0
  73. package/src/harness/log-delta.test.ts +125 -0
  74. package/src/harness/log-delta.ts +100 -0
  75. package/src/harness/main.test.ts +211 -0
  76. package/src/harness/main.ts +196 -0
  77. package/src/harness/methods.test.ts +63 -0
  78. package/src/harness/methods.ts +92 -0
  79. package/src/harness/names-registry.test.ts +137 -0
  80. package/src/harness/names-registry.ts +108 -0
  81. package/src/harness/protocol.test.ts +148 -0
  82. package/src/harness/protocol.ts +163 -0
  83. package/src/harness/ready-poll.test.ts +172 -0
  84. package/src/harness/ready-poll.ts +93 -0
  85. package/src/harness/service-graph.test.ts +97 -0
  86. package/src/harness/service-graph.ts +97 -0
  87. package/src/harness/volume-paths.test.ts +102 -0
  88. package/src/harness/volume-paths.ts +112 -0
  89. package/src/ids.ts +89 -0
  90. package/src/index.ts +2767 -0
  91. package/src/ingress.ts +305 -0
  92. package/src/inspect.ts +739 -0
  93. package/src/locator.ts +716 -0
  94. package/src/mobile.ts +138 -0
  95. package/src/record-secrets.ts +41 -0
  96. package/src/recorder.ts +856 -0
  97. package/src/redis.ts +202 -0
  98. package/src/replay-bundle.ts +108 -0
  99. package/src/resolver.ts +348 -0
  100. package/src/s3.ts +333 -0
  101. package/src/sql.ts +243 -0
  102. package/src/terminal.ts +740 -0
  103. package/src/url-match.ts +67 -0
  104. package/src/vendor/rrweb-plugin-console-record.umd.js +521 -0
  105. package/src/vendor/rrweb-record.min.js +5061 -0
package/dist/index.d.ts CHANGED
@@ -84,7 +84,7 @@ export interface ServiceConfig {
84
84
  hostnames?: readonly string[];
85
85
  /**
86
86
  * Expose this service over HTTPS via a TLS-terminating reverse proxy
87
- * hosted in the spectest-daemon. Each entry maps a fully-qualified
87
+ * hosted in the harness. Each entry maps a fully-qualified
88
88
  * hostname to the HTTP port the service listens on inside its
89
89
  * container; the daemon binds the hostname on `:443` (SNI-multiplexed,
90
90
  * with a leaf cert signed by the in-VM root CA) and on `:80`, and
@@ -936,20 +936,57 @@ export interface TestContext<P = undefined, S extends ServicesMap = ServicesMap,
936
936
  /**
937
937
  * Open the headless browser. Backed by Chromium-over-CDP inside the VM.
938
938
  *
939
- * There is ONE persistent browser per environment: every `ctx.browser()`
940
- * call returns it, and it stays alive across tests — the browser is part
941
- * of the state a test's snapshot captures, so a `dependsOn` child resumes
942
- * the exact live page its parent left (cookies, localStorage, signed-in
943
- * SPA state). Sign in once in a parent test; every descendant is already
944
- * signed in. Sibling tests fork from the same parent snapshot, so they
945
- * can't see each other's browsing. A test with no browser-using ancestor
946
- * gets a fresh browser on first call (first call's options win).
939
+ * There is one persistent browser PER NAME, and `ctx.browser()` is the
940
+ * default (unnamed) one: every call returns it, and it stays alive across
941
+ * tests — the browser is part of the state a test's snapshot captures, so
942
+ * a `dependsOn` child resumes the exact live page its parent left
943
+ * (cookies, localStorage, signed-in SPA state). Sign in once in a parent
944
+ * test; every descendant is already signed in. Sibling tests fork from
945
+ * the same parent snapshot, so they can't see each other's browsing. A
946
+ * test with no browser-using ancestor gets a fresh browser on first call
947
+ * (first call's options win).
948
+ *
949
+ * For a second, independent browser — a second user — name it:
950
+ * `ctx.browser("alice")`. See the named overload.
947
951
  *
948
952
  * `.close()` destroys the shared instance — the next `ctx.browser()`
949
953
  * starts fresh. Don't call it for routine cleanup; recording is detached
950
954
  * automatically at test end.
951
955
  */
952
956
  browser(opts?: BrowserOptions): Promise<Browser>;
957
+ /**
958
+ * Open the persistent browser called `name`, creating it on first use.
959
+ * Each name is its own browser — its own cookies, localStorage and page —
960
+ * so naming them is how one test drives two users.
961
+ *
962
+ * **Only name a browser when the test needs two or more isolated sessions
963
+ * at once.** Otherwise use `ctx.browser()`: a name is a second identity,
964
+ * not a label, and a lone `ctx.browser("main")` buys nothing over the
965
+ * default while adding its name to every step title in the dashboard.
966
+ *
967
+ * ```ts
968
+ * const alice = await ctx.browser("alice", { url: "https://app.test" });
969
+ * const bob = await ctx.browser("bob", { url: "https://app.test" });
970
+ * await alice.getByRole("button", { name: "Share" }).click();
971
+ * await bob.reload();
972
+ * await expect(bob.getByText("Shared with you")).toBeVisible();
973
+ * ```
974
+ *
975
+ * Every rule of the default browser applies per name: the session rides
976
+ * the snapshot, so a `dependsOn` child inherits *each* named browser
977
+ * exactly where its parent left it (sign both users in once, in the
978
+ * parent); repeat calls with the same name in one test return the same
979
+ * handle; the first call for a name wins its options; `.close()` discards
980
+ * only that name. Sessions are named for the roles under test, not per
981
+ * row of data — each one is a live browser captured in every snapshot
982
+ * from here on, and opening too many is an error.
983
+ *
984
+ * The dashboard titles each step with the browser that performed it
985
+ * (`bob: click "Share"`) and labels the replay with the same name, so a
986
+ * two-user timeline stays readable and selecting a step shows that
987
+ * browser's session.
988
+ */
989
+ browser(name: string, opts?: BrowserOptions): Promise<Browser>;
953
990
  /**
954
991
  * Open a phone-emulated session for a mobile app and return a {@link Mobile}
955
992
  * handle already pointed at it — no `navigate`. Pass the app handle a
@@ -968,13 +1005,18 @@ export interface TestContext<P = undefined, S extends ServicesMap = ServicesMap,
968
1005
  * The session emulates the latest iPhone (viewport + DPR + mobile UA +
969
1006
  * touch) and the dashboard replays it inside a phone bezel.
970
1007
  *
971
- * Sessions are persistent, one per app: like `ctx.browser()`, the live
972
- * session is captured in the test's snapshot, so a `dependsOn` child
973
- * picks up the app exactly where the parent left it (already signed in,
974
- * mid-flow) instead of reloading it. `.close()` discards the session;
1008
+ * Sessions are persistent, one per app per `name`: like `ctx.browser()`,
1009
+ * the live session is captured in the test's snapshot, so a `dependsOn`
1010
+ * child picks up the app exactly where the parent left it (already signed
1011
+ * in, mid-flow) instead of reloading it. `.close()` discards the session;
975
1012
  * the next `ctx.mobile(app)` opens the app fresh.
1013
+ *
1014
+ * Pass a `name` for a second phone running the same app — two users in
1015
+ * one test: `ctx.mobile(ctx.svc.app, "alice")`. Names are independent
1016
+ * sessions and are inherited per name by `dependsOn` children, exactly
1017
+ * like named desktop browsers.
976
1018
  */
977
- mobile(app: MobileApp): Promise<Mobile>;
1019
+ mobile(app: MobileApp, name?: string): Promise<Mobile>;
978
1020
  /** The test's display name. */
979
1021
  readonly testName: string;
980
1022
  /**
@@ -1038,7 +1080,7 @@ export interface TestSuite<S extends ServicesMap = ServicesMap, F extends FakesM
1038
1080
  tests: TestCase<unknown, S, F>[];
1039
1081
  }
1040
1082
  /**
1041
- * A fake server hosted in the spectest-daemon. Use this to stand in for
1083
+ * A fake server hosted in the harness. Use this to stand in for
1042
1084
  * external HTTP APIs (auth providers, payment gateways, …) that you can't
1043
1085
  * call directly from the hermetic test VM.
1044
1086
  *
@@ -1176,7 +1218,7 @@ export interface TypedTest<S extends ServicesMap, F extends FakesMap = FakesMap>
1176
1218
  export interface Project<S extends ServicesMap = ServicesMap, F extends FakesMap = FakesMap> {
1177
1219
  environment: EnvironmentConfig<S>;
1178
1220
  /**
1179
- * Fake servers hosted in the spectest-daemon (see {@link defineFake}).
1221
+ * Fake servers hosted in the harness (see {@link defineFake}).
1180
1222
  * Keyed by stable fake name; the key becomes the slot in
1181
1223
  * `ctx.fakes.<key>` from test code. Populated from the `fakes` declared
1182
1224
  * in `defineEnvironment(...)`.
package/dist/ingress.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Project, ServiceConfig } from "./index.js";
2
- /** Where a DNS name points. `ingress` = the spectest-daemon listener on the
2
+ /** Where a DNS name points. `ingress` = the harness's listener on the
3
3
  * bridge gateway (fakes, TLS-terminated proxies); `service` = a container's
4
4
  * live IP on spectest-net (a plain peer alias, no daemon hop). */
5
5
  export type DnsTarget = {
package/dist/mobile.d.ts CHANGED
@@ -49,14 +49,18 @@ export declare function openMobile(opts: {
49
49
  recorder: BrowserSessionRecorder | null;
50
50
  }): Promise<Mobile>;
51
51
  /**
52
- * Acquire the persistent phone-emulated session for an app (one per app URL,
53
- * created on first use). The daemon calls this from `ctx.mobile(app)` with a
54
- * per-test rrweb recorder; the resulting record carries `frame: "mobile"` so
55
- * the dashboard renders a phone bezel. `detach` is the test-end hook;
56
- * `mobile.close()` destroys the session for real.
52
+ * Acquire the persistent phone-emulated session for an app (one per app URL
53
+ * per `name`, created on first use). The daemon calls this from
54
+ * `ctx.mobile(app, name?)` with a per-test rrweb recorder; the resulting
55
+ * record carries `frame: "mobile"` so the dashboard renders a phone bezel.
56
+ * `detach` is the test-end hook; `mobile.close()` destroys the session for
57
+ * real.
57
58
  */
58
59
  export declare function openPersistentMobile(opts: {
59
60
  url: string;
61
+ /** Session name — `""` (the default) is the unnamed session every
62
+ * `ctx.mobile(app)` call shares. Two names on one app are two phones. */
63
+ name?: string;
60
64
  recorder: BrowserSessionRecorder | null;
61
65
  /** Installed before the fresh session's first navigation (ignored on an
62
66
  * attached session, which already carries it on the forked holder). */
package/dist/mobile.js CHANGED
@@ -48,14 +48,15 @@ export async function openMobile(opts) {
48
48
  return backend;
49
49
  }
50
50
  /**
51
- * Acquire the persistent phone-emulated session for an app (one per app URL,
52
- * created on first use). The daemon calls this from `ctx.mobile(app)` with a
53
- * per-test rrweb recorder; the resulting record carries `frame: "mobile"` so
54
- * the dashboard renders a phone bezel. `detach` is the test-end hook;
55
- * `mobile.close()` destroys the session for real.
51
+ * Acquire the persistent phone-emulated session for an app (one per app URL
52
+ * per `name`, created on first use). The daemon calls this from
53
+ * `ctx.mobile(app, name?)` with a per-test rrweb recorder; the resulting
54
+ * record carries `frame: "mobile"` so the dashboard renders a phone bezel.
55
+ * `detach` is the test-end hook; `mobile.close()` destroys the session for
56
+ * real.
56
57
  */
57
58
  export async function openPersistentMobile(opts) {
58
- const { browser, attached, detach } = await acquirePersistentMobileBackend(opts.url, opts.recorder, opts.initScript);
59
+ const { browser, attached, detach } = await acquirePersistentMobileBackend(opts.url, opts.name ?? "", opts.recorder, opts.initScript);
59
60
  return {
60
61
  mobile: browser,
61
62
  attached,
@@ -379,6 +379,16 @@ export interface BrowserEvent extends BaseEvent {
379
379
  * with a `BrowserSessionRecorder` attached (the daemon always does).
380
380
  */
381
381
  sessionId?: string;
382
+ /**
383
+ * Author-given name of that session — `ctx.browser("alice")` — absent
384
+ * for the default unnamed browser, so a single-browser test's steps
385
+ * carry no name and render exactly as they always have. Present, it is
386
+ * what the step list badges to say which browser acted. Duplicated here
387
+ * rather than resolved from the session record because the CLI's
388
+ * failure detail renders from persisted events alone, with no replay
389
+ * bundle to look the session up in.
390
+ */
391
+ sessionName?: string;
382
392
  /**
383
393
  * Wall-clock `Date.now()` captured at the moment this op *finished*.
384
394
  * Lives in the same time base as rrweb's `event.timestamp` fields,
package/dist/resolver.js CHANGED
@@ -27,6 +27,7 @@
27
27
  import { createSocket } from "node:dgram";
28
28
  import { request as httpRequest } from "node:http";
29
29
  import { readFile, stat } from "node:fs/promises";
30
+ import { lookup } from "./harness/names-registry.js";
30
31
  import * as dnsPacket from "dns-packet";
31
32
  const NETWORK_NAME = process.env.SPECTEST_NETWORK ?? "spectest-net";
32
33
  const DOCKER_SOCKET = process.env.DOCKER_SOCKET ?? "/var/run/docker.sock";
@@ -37,7 +38,7 @@ const UPSTREAM_PORT = Number(process.env.SPECTEST_UPSTREAM_PORT ?? "53");
37
38
  const LISTEN_ADDR = process.env.SPECTEST_RESOLVER_ADDR ?? "127.0.0.53";
38
39
  const LISTEN_PORT = Number(process.env.SPECTEST_RESOLVER_PORT ?? "53");
39
40
  const TTL_SECONDS = Number(process.env.SPECTEST_RESOLVER_TTL ?? "5");
40
- /** Path the spectest-daemon writes when it brings fakes up. */
41
+ /** Path the harness writes when it brings fakes up. */
41
42
  const FAKES_REGISTRY_PATH = process.env.SPECTEST_FAKES_REGISTRY ?? "/run/spectest-fakes.json";
42
43
  function dockerGet(path) {
43
44
  return new Promise((resolve) => {
@@ -129,13 +130,9 @@ async function lookupFake(name) {
129
130
  * longest (most specific) matching suffix wins. */
130
131
  async function lookupWildcard(name) {
131
132
  await refreshRegistry();
132
- let best = null;
133
- for (const w of registryCache.wildcards) {
134
- if (name.endsWith(w.suffix) && (!best || w.suffix.length > best.suffix.length)) {
135
- best = w;
136
- }
137
- }
138
- return best?.ip ?? null;
133
+ // Same module the harness writes with, so the two halves of this file
134
+ // format cannot drift apart.
135
+ return lookup({ hosts: {}, wildcards: registryCache.wildcards }, name);
139
136
  }
140
137
  async function forwardUpstream(query) {
141
138
  return new Promise((resolve) => {