@specific.dev/spectest 0.83.0 → 0.85.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 (46) hide show
  1. package/dist/components/index.d.ts +1 -0
  2. package/dist/components/index.js +1 -0
  3. package/dist/components/postgres-tls.d.ts +10 -0
  4. package/dist/components/postgres-tls.js +9 -0
  5. package/dist/components/postgres.d.ts +59 -1
  6. package/dist/components/postgres.js +5 -2
  7. package/dist/components/supabase-config.d.ts +5 -0
  8. package/dist/components/supabase-config.js +12 -4
  9. package/dist/components/supabase-webhooks.d.ts +1 -0
  10. package/dist/components/supabase-webhooks.js +262 -0
  11. package/dist/components/supabase.d.ts +36 -11
  12. package/dist/components/supabase.js +75 -18
  13. package/dist/components/temporal.d.ts +89 -0
  14. package/dist/components/temporal.js +185 -0
  15. package/dist/coverage.d.ts +72 -0
  16. package/dist/coverage.js +226 -6
  17. package/dist/daemon.js +71 -5
  18. package/dist/environment-ca.d.ts +8 -0
  19. package/dist/environment-ca.js +16 -0
  20. package/dist/harness/container-run.js +3 -0
  21. package/dist/harness/coverage.d.ts +15 -4
  22. package/dist/harness/coverage.js +48 -6
  23. package/dist/index.d.ts +1 -0
  24. package/dist/index.js +1 -0
  25. package/package.json +1 -1
  26. package/src/components/index.ts +3 -0
  27. package/src/components/postgres-tls.test.ts +23 -0
  28. package/src/components/postgres-tls.ts +11 -0
  29. package/src/components/postgres.ts +8 -2
  30. package/src/components/supabase-config.ts +11 -2
  31. package/src/components/supabase-db.test.ts +64 -0
  32. package/src/components/supabase-dir.test.ts +71 -0
  33. package/src/components/supabase-webhooks.ts +263 -0
  34. package/src/components/supabase.ts +108 -28
  35. package/src/components/temporal.test.ts +86 -0
  36. package/src/components/temporal.ts +188 -0
  37. package/src/coverage.test.ts +72 -2
  38. package/src/coverage.ts +270 -6
  39. package/src/daemon.ts +68 -3
  40. package/src/environment-ca.test.ts +44 -0
  41. package/src/environment-ca.ts +20 -0
  42. package/src/harness/container-run.test.ts +14 -2
  43. package/src/harness/container-run.ts +3 -0
  44. package/src/harness/coverage.test.ts +32 -1
  45. package/src/harness/coverage.ts +43 -6
  46. package/src/index.ts +1 -0
@@ -27,7 +27,7 @@
27
27
  // supabase: sb.group, // expands to supabase, supabase-db, supabase-auth, ...
28
28
  // app: {
29
29
  // image: { type: "dockerfile", content: "..." },
30
- // env: { ...sb.appEnv }, // SUPABASE_URL / _ANON_KEY / _SERVICE_ROLE_KEY / DATABASE_URL
30
+ // env: { SUPABASE_URL: sb.url, SUPABASE_ANON_KEY: sb.anonKey, DATABASE_URL: sb.dbUrl },
31
31
  // dependsOn: [sb.ready], // wait for the gateway (and the stack behind it)
32
32
  // },
33
33
  // },
@@ -47,7 +47,7 @@
47
47
  // the group naming context, so two Supabase stacks (or a user service
48
48
  // innocently named `db`) never collide. The group must be mounted at the key
49
49
  // matching the `name` option (default `"supabase"`) since the derived surface
50
- // (`sb.url`, `sb.appEnv`) embeds it — a mismatch errors at load.
50
+ // (`sb.url`, `sb.dbUrl`) embeds it — a mismatch errors at load.
51
51
 
52
52
  import { createHmac } from "node:crypto";
53
53
  import { existsSync, readFileSync } from "node:fs";
@@ -69,6 +69,8 @@ import { email, emailHelpers, type EmailHelpers } from "./email.js";
69
69
  import { github } from "./github.js";
70
70
  import { google } from "./google.js";
71
71
  import { microsoft } from "./microsoft.js";
72
+ import { SUPABASE_WEBHOOKS_SQL } from "./supabase-webhooks.js";
73
+ import { postgresTls } from "./postgres-tls.js";
72
74
  import {
73
75
  mapSupabaseConfig,
74
76
  parseSupabaseConfigFile,
@@ -99,6 +101,17 @@ const IMAGES = {
99
101
  templates: "busybox:1.37",
100
102
  } as const;
101
103
 
104
+ /** Override tables without mutating the parsed project config. */
105
+ function mergeConfig(base: unknown, overrides: Record<string, unknown>): Record<string, unknown> {
106
+ const table = (v: unknown): v is Record<string, unknown> =>
107
+ v !== null && typeof v === "object" && !Array.isArray(v);
108
+ const result: Record<string, unknown> = Object.assign(Object.create(null), table(base) ? base : {});
109
+ for (const [key, value] of Object.entries(overrides)) {
110
+ result[key] = table(value) ? mergeConfig(result[key], value) : value;
111
+ }
112
+ return result;
113
+ }
114
+
102
115
  /**
103
116
  * Social sign-in providers this stack can serve, by the name GoTrue (and
104
117
  * `[auth.external.<name>]`) uses. Each factory builds one container answering
@@ -204,6 +217,22 @@ function absProjectPath(p: string): string {
204
217
  }
205
218
 
206
219
  export interface SupabaseOptions {
220
+ /** Database image and extensions installed before project migrations. */
221
+ db?: {
222
+ /** Native database TLS, enabled by default using the environment CA. */
223
+ tls?: boolean;
224
+ /** Full Supabase-compatible Postgres image reference. Defaults to the pinned SDK image. */
225
+ image?: string;
226
+ /**
227
+ * Explicit extension overrides for projects whose migrations pin versions
228
+ * absent from the image. Omit version to use the image's default, or use
229
+ * db.image to supply an image containing the exact required version.
230
+ * Existing extensions must match the requested schema/version.
231
+ * Example: `{ vector: { schema: "public" } }` opts into the image's vector
232
+ * version before a historical CREATE EXTENSION IF NOT EXISTS migration.
233
+ */
234
+ extensions?: Record<string, { schema?: string; version?: string }>;
235
+ };
207
236
  /**
208
237
  * Key of the API-gateway service and the prefix for every other piece.
209
238
  * Default `"supabase"`. The gateway is reachable at `http://<name>:8000`
@@ -239,7 +268,7 @@ export interface SupabaseOptions {
239
268
  /**
240
269
  * Also serve the gateway over **HTTPS** at `https://<hostname>` via the
241
270
  * daemon's CA-trusted TLS reverse proxy (in addition to the plain
242
- * `http://<name>:8000`). `sb.url` and `sb.appEnv` then point at the HTTPS
271
+ * `http://<name>:8000`). `sb.url` then points at the HTTPS
243
272
  * address. Use TLS wherever you can: set this when a browser reaches the
244
273
  * gateway, because a page that is open over `https://` cannot call an
245
274
  * `http://` origin (Chromium blocks the request before it leaves the
@@ -298,14 +327,21 @@ export interface SupabaseOptions {
298
327
  * written one. Nothing is derived unless you name the folder, and each piece
299
328
  * is optional inside it — a folder with only a `config.toml` is fine.
300
329
  *
301
- * There is deliberately no per-piece path option and no way to configure
302
- * mail, OTP, redirect or JWT behaviour here: those live in `config.toml`,
303
- * where your project already declares them and where the Supabase CLI reads
304
- * them too. One source of truth, and the environment matches the one you run
305
- * locally.
330
+ * Use `configOverrides` for environment-specific config values without
331
+ * moving this folder's migrations, seed, templates or functions.
306
332
  */
307
333
  dir?: string;
308
334
 
335
+ /**
336
+ * Values recursively merged into the project's parsed Supabase config.
337
+ * Tables merge; arrays and scalar values replace their originals. Uses the
338
+ * same nested keys as config.toml, e.g.:
339
+ * `{ auth: { hook: { before_user_created: { uri: "https://app.test/hook" } } } }`.
340
+ * `env(...)` in overrides resolves through `env` just as it does in the file.
341
+ * Also works without `dir` for a stack with no project SQL or functions.
342
+ */
343
+ configOverrides?: Record<string, unknown>;
344
+
309
345
  /**
310
346
  * The `.env` of this environment — the values your `config.toml` reads with
311
347
  * `env(...)`.
@@ -484,9 +520,6 @@ export interface SupabaseStack {
484
520
  * share the one mailbox: `env: { SMTP_HOST: sb.smtp.host, SMTP_PORT: String(sb.smtp.port) }`.
485
521
  */
486
522
  smtp?: { host: string; port: number };
487
- /** Ready-to-spread env for the app under test: `SUPABASE_URL`,
488
- * `SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `DATABASE_URL`. */
489
- appEnv: Record<string, string>;
490
523
  }
491
524
 
492
525
  // Supabase's well-known demo secret (≥32 chars). Local-only; override via opts.
@@ -542,6 +575,31 @@ function pgLiteral(s: string): string {
542
575
  return `'${s.replace(/'/g, "''")}'`;
543
576
  }
544
577
 
578
+ function pgIdentifier(s: string): string {
579
+ if (!s || s.includes("\0")) throw new Error("supabase(): an SQL identifier must be non-empty and contain no NUL");
580
+ return `"${s.replace(/"/g, '""')}"`;
581
+ }
582
+
583
+ function extensionSql(extensions: NonNullable<SupabaseOptions["db"]>["extensions"]): string {
584
+ return Object.entries(extensions ?? {}).map(([name, { schema, version }]) => {
585
+ const install = `CREATE EXTENSION IF NOT EXISTS ${pgIdentifier(name)}` +
586
+ (schema ? ` SCHEMA ${pgIdentifier(schema)}` : "") +
587
+ (version ? ` VERSION ${pgLiteral(version)}` : "") + ";";
588
+ // IF NOT EXISTS does not validate an already-installed extension.
589
+ const matches = [
590
+ version ? `e.extversion = ${pgLiteral(version)}` : "true",
591
+ schema ? `n.nspname = ${pgLiteral(schema)}` : "true",
592
+ ].join(" AND ");
593
+ const check = `BEGIN
594
+ IF EXISTS (SELECT 1 FROM pg_extension e JOIN pg_namespace n ON n.oid = e.extnamespace
595
+ WHERE e.extname = ${pgLiteral(name)} AND NOT (${matches})) THEN
596
+ RAISE EXCEPTION USING MESSAGE = ${pgLiteral(`supabase: extension ${name} does not match the requested schema/version`)};
597
+ END IF;
598
+ END`;
599
+ return `${install}\nDO ${pgLiteral(check)};`;
600
+ }).join("\n");
601
+ }
602
+
545
603
  function buildBootstrapSql(
546
604
  dbPassword: string,
547
605
  jwtSecret: string,
@@ -1654,11 +1712,14 @@ async function reloadPostgrestSchema(
1654
1712
  /**
1655
1713
  * A ready-to-use self-hosted Supabase stack. Mount `.group` at the key
1656
1714
  * matching the `name` option (default `"supabase"`) and wire the app with
1657
- * `.appEnv` / `.ready`:
1715
+ * its connection values and `.ready`:
1658
1716
  *
1659
1717
  * ```ts
1660
1718
  * const sb = supabase({ dbPassword: "postgres" });
1661
- * services: { supabase: sb.group, app: { image, env: sb.appEnv, dependsOn: [sb.ready] } }
1719
+ * services: { supabase: sb.group, app: {
1720
+ * image, env: { SUPABASE_URL: sb.url, SUPABASE_ANON_KEY: sb.anonKey },
1721
+ * dependsOn: [sb.ready],
1722
+ * } }
1662
1723
  * ```
1663
1724
  *
1664
1725
  * See the file header for the full picture. Tests get an instrumented DB client
@@ -1722,11 +1783,29 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
1722
1783
  // services map is returned. `config.json` wins where a newer CLI wrote one.
1723
1784
  const configPath = inDir("config.json") ?? inDir("config.toml");
1724
1785
  let mapped: MappedSupabaseConfig | null = null;
1725
- if (configPath !== null) {
1726
- const shown = configPath.startsWith(projectRootPath())
1786
+ if (configPath !== null || opts.configOverrides !== undefined) {
1787
+ const shown = configPath === null ? "configOverrides" : configPath.startsWith(projectRootPath())
1727
1788
  ? configPath.slice(projectRootPath().length + 1)
1728
1789
  : configPath;
1729
- mapped = mapSupabaseConfig(parseSupabaseConfigFile(configPath));
1790
+ mapped = mapSupabaseConfig(mergeConfig(
1791
+ configPath === null ? {} : parseSupabaseConfigFile(configPath),
1792
+ opts.configOverrides ?? {},
1793
+ ));
1794
+ for (const [target, { from, key }] of Object.entries(mapped.deferredAuth)) {
1795
+ const enabled = target.replace(/_(URI|SECRETS)$/, "_ENABLED");
1796
+ if (!withAuth || mapped.auth[enabled] !== "true") continue;
1797
+ const value = opts.env?.[from];
1798
+ if (value === undefined || value === "") {
1799
+ throw new Error(`supabase(): ${key} reads env(${from}); supply a non-empty test value with env: { ${from}: "…" }.`);
1800
+ }
1801
+ mapped.auth[target] = value;
1802
+ }
1803
+ for (const key of mapped.encryptedValues) {
1804
+ const hook = /^auth\.hook\.([^.]+)\.(uri|secrets)$/.exec(key);
1805
+ if (withAuth && hook && mapped.auth[`GOTRUE_HOOK_${hook[1]!.toUpperCase()}_ENABLED`] === "true") {
1806
+ throw new Error(`supabase(): ${key} is encrypted; override it with env(NAME) and supply the test value through env.`);
1807
+ }
1808
+ }
1730
1809
  // What the file asked for and did not get. Reported, never fatal: the
1731
1810
  // config belongs to the project and describes more than this environment,
1732
1811
  // so a key that is meaningless in a hermetic VM is not grounds to refuse
@@ -1756,6 +1835,7 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
1756
1835
  // and nothing says why.
1757
1836
  const referenced = new Set<string>([
1758
1837
  ...Object.values(mapped.deferredSecrets),
1838
+ ...Object.values(mapped.deferredAuth).map(({ from }) => from),
1759
1839
  ...Object.values(mapped.externalProviders).flatMap((p) =>
1760
1840
  [p.clientIdFrom, p.secretFrom].filter((n): n is string => n !== undefined),
1761
1841
  ),
@@ -1837,17 +1917,20 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
1837
1917
  const anonKey = apiKey("anon", jwtSecret);
1838
1918
  const serviceRoleKey = apiKey("service_role", jwtSecret);
1839
1919
 
1840
- // Derived surface, embedded in `appEnv` / `url` before the group expands.
1920
+ // Derived connection values, available before the group expands.
1841
1921
  // `expectKey: name` on the group guarantees these names and the expanded
1842
1922
  // service keys agree (`g.key("db")` at expansion === `${name}-db` here).
1843
1923
  const dbKey = `${name}-db`;
1844
1924
  const url = opts.hostname ? `https://${opts.hostname}` : `http://${name}:8000`;
1845
1925
  // App-facing URL: the conventional `postgres` role (owns migrated tables).
1846
- const dbUrl = `postgresql://postgres:${dbPassword}@${dbKey}:5432/postgres`;
1926
+ const dbTls = opts.db?.tls !== false ? postgresTls() : undefined;
1927
+ const dbUrl = `postgresql://postgres:${encodeURIComponent(dbPassword)}@${dbKey}:5432/postgres` +
1928
+ (dbTls ? "?sslmode=verify-full" : "");
1847
1929
  // The test-side `ctx.svc.<name>.sql` helper connects as the superuser
1848
1930
  // `supabase_admin` so it can inspect everything — public tables plus the
1849
1931
  // `auth`/`storage` schemas — regardless of ownership.
1850
- const adminDbUrl = `postgresql://supabase_admin:${dbPassword}@${dbKey}:5432/postgres`;
1932
+ const adminDbUrl = `postgresql://supabase_admin:${encodeURIComponent(dbPassword)}@${dbKey}:5432/postgres` +
1933
+ (dbTls ? "?sslmode=verify-full" : "");
1851
1934
 
1852
1935
  // ── social sign-in ────────────────────────────────────────────────────
1853
1936
  // Every `[auth.external.<name>]` the config enables: served in-environment
@@ -1900,7 +1983,7 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
1900
1983
 
1901
1984
  // ── db ──────────────────────────────────────────────────────────
1902
1985
  const dbDef: ServiceDefinition = {
1903
- image: { type: "registry", reference: IMAGES.db },
1986
+ image: { type: "registry", reference: opts.db?.image ?? IMAGES.db },
1904
1987
  // `args` (not `command`): keep the image's docker-entrypoint.sh so it
1905
1988
  // runs initdb + its baked init-scripts before starting.
1906
1989
  args: [
@@ -1909,7 +1992,9 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
1909
1992
  "config_file=/etc/postgresql/postgresql.conf",
1910
1993
  "-c",
1911
1994
  "log_min_messages=fatal",
1995
+ ...(dbTls?.args ?? []),
1912
1996
  ],
1997
+ ...(dbTls ? { certificates: dbTls.certificates } : {}),
1913
1998
  env: {
1914
1999
  POSTGRES_HOST: "/var/run/postgresql",
1915
2000
  PGPORT: "5432",
@@ -1940,6 +2025,9 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
1940
2025
  // superuser in modern supabase/postgres; `supabase_admin` is.
1941
2026
  "supabase_admin",
1942
2027
  );
2028
+ await applySqlFile(ctx, SUPABASE_WEBHOOKS_SQL, "database webhooks", "supabase_admin");
2029
+ const extensions = extensionSql(opts.db?.extensions);
2030
+ if (extensions) await applySqlFile(ctx, extensions, "configured extensions", "postgres");
1943
2031
  },
1944
2032
  };
1945
2033
  parts.db = dbDef;
@@ -2345,13 +2433,6 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
2345
2433
  }),
2346
2434
  });
2347
2435
 
2348
- const appEnv = {
2349
- SUPABASE_URL: url,
2350
- SUPABASE_ANON_KEY: anonKey,
2351
- SUPABASE_SERVICE_ROLE_KEY: serviceRoleKey,
2352
- DATABASE_URL: dbUrl,
2353
- };
2354
-
2355
2436
  return {
2356
2437
  group,
2357
2438
  url,
@@ -2359,7 +2440,6 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
2359
2440
  serviceRoleKey,
2360
2441
  dbUrl,
2361
2442
  ready: name,
2362
- appEnv,
2363
2443
  ...(withMail ? { smtp: { host: mailKey, port: 1025 } } : {}),
2364
2444
  };
2365
2445
  }
@@ -0,0 +1,86 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { defineEnvironment } from "../index.js";
3
+ import { temporal, temporalClient } from "./temporal.js";
4
+
5
+ describe("temporal", () => {
6
+ test("uses one prebuilt headless server and gates on the namespace API", () => {
7
+ const t = temporal({ namespace: "specific", namespaces: ["customer"], searchAttributes: { SurveyId: "Keyword" } });
8
+ const env = defineEnvironment({ name: "test", services: { temporal: t.group } });
9
+ expect(Object.keys(env.config.services)).toEqual(["temporal"]);
10
+ const server = env.config.services.temporal;
11
+ expect(server.args).toContain("--headless");
12
+ expect(server.args).toContain("SurveyId=Keyword");
13
+ expect(server.args).toContain("customer");
14
+ expect(server.command).toBeUndefined();
15
+ expect(server.readyCheck).toMatchObject({ type: "http", path: "/api/v1/namespaces/specific" });
16
+ });
17
+ test("TLS has its own certificate and upstream dependency, never HTTP ingress", () => {
18
+ const t = temporal({ name: "cloud", tls: { hostname: "temporal.test" } });
19
+ const env = defineEnvironment({ name: "test", services: { cloud: t.group } });
20
+ expect(Object.keys(env.config.services).sort()).toEqual(["cloud", "cloud-server"]);
21
+ expect(env.config.services.cloud.dependsOn).toContain("cloud-server");
22
+ expect(env.config.services.cloud.certificates?.[0]?.hostnames).toEqual(["temporal.test"]);
23
+ expect(env.config.services.cloud.tls).toBeUndefined();
24
+ expect(t.address).toBe("temporal.test:7233");
25
+ });
26
+ test("rejects invalid ports and search attributes", () => {
27
+ expect(() => temporal({ port: 7243 })).toThrow("distinct");
28
+ expect(() => temporal({ searchAttributes: { "invalid=name": "Keyword" } })).toThrow("search attribute");
29
+ });
30
+ });
31
+
32
+ describe("Temporal API helpers", () => {
33
+ test("namespace registration is idempotent on the structured AlreadyExists code", async () => {
34
+ const methods: string[] = [];
35
+ const c = temporalClient("http://temporal", "default", async (_, init) => {
36
+ methods.push(init?.method ?? "GET");
37
+ return init?.method === "POST" ? Response.json({ code: 6, message: "exists" }, { status: 409 }) :
38
+ Response.json({ namespaceInfo: { name: "new", state: "NAMESPACE_STATE_REGISTERED", id: "id" } });
39
+ });
40
+ expect((await c.registerNamespace("new")).namespaceInfo.name).toBe("new");
41
+ expect(methods).toEqual(["POST", "GET"]);
42
+ });
43
+ test("requires both task types and rejects stale or unrelated pollers", async () => {
44
+ const urls: string[] = [];
45
+ const c = temporalClient("http://temporal", "default", async input => {
46
+ urls.push(String(input));
47
+ return Response.json({ pollers: [{ identity: "other", lastAccessTime: new Date().toISOString() },
48
+ { identity: "expected", lastAccessTime: new Date(Date.now() - 300000).toISOString() }] });
49
+ });
50
+ await expect(c.waitForWorkers("q", { identities: ["expected"], timeoutMs: 20 })).rejects.toThrow("Observed pollers");
51
+ expect(urls.some(u => u.endsWith("taskQueueType=1"))).toBe(true);
52
+ expect(urls.some(u => u.endsWith("taskQueueType=2"))).toBe(true);
53
+ });
54
+ test("recognizes a ready activity-only worker", async () => {
55
+ const c = temporalClient("http://temporal", "default", async () => Response.json({ pollers: [
56
+ { identity: "worker", lastAccessTime: new Date().toISOString() },
57
+ ] }));
58
+ expect((await c.waitForWorkers("q", { types: ["activity"], identities: ["worker"] })).activity).toHaveLength(1);
59
+ });
60
+ test("authentication failures fail immediately rather than waiting out the timeout", async () => {
61
+ let calls = 0;
62
+ const c = temporalClient("http://temporal", "default", async () => { calls++; return Response.json({ code: 16, message: "denied" }, { status: 401 }); });
63
+ await expect(c.waitForWorkers("q", { types: ["workflow"] })).rejects.toThrow("HTTP 401");
64
+ expect(calls).toBe(1);
65
+ });
66
+ test("namespace creation recovers from a lost response without changing an existing namespace", async () => {
67
+ let posts = 0;
68
+ const c = temporalClient("http://temporal", "default", async (_, init) => {
69
+ if (init?.method === "POST") {
70
+ if (++posts === 1) throw new TypeError("connection closed after registration");
71
+ return Response.json({ code: 6 }, { status: 409 });
72
+ }
73
+ return Response.json({ namespaceInfo: { name: "customer", state: "NAMESPACE_STATE_REGISTERED", id: "existing" } });
74
+ });
75
+ expect((await c.registerNamespace("customer")).namespaceInfo.id).toBe("existing");
76
+ expect(posts).toBe(2);
77
+ });
78
+ test("polling retries a gateway's non-JSON transient response", async () => {
79
+ let calls = 0;
80
+ const c = temporalClient("http://temporal", "default", async () => ++calls === 1
81
+ ? new Response("upstream unavailable", { status: 503 })
82
+ : Response.json({ pollers: [{ identity: "worker", lastAccessTime: new Date().toISOString() }] }));
83
+ expect((await c.waitForWorkers("q", { types: ["activity"] })).activity).toHaveLength(1);
84
+ expect(calls).toBe(2);
85
+ });
86
+ });
@@ -0,0 +1,188 @@
1
+ import { serviceGroup, type ServiceDefinition, type ServicesMap } from "../index.js";
2
+ import { rawFetch } from "../harness/raw-fetch.js";
3
+ import { readRaw } from "../inspect.js";
4
+
5
+ export type TemporalSearchAttributeType = "Text" | "Keyword" | "Int" | "Double" | "Bool" | "Datetime" | "KeywordList";
6
+ export type TemporalTaskType = "workflow" | "activity";
7
+ export interface TemporalPoller { identity: string; lastAccessTime?: string }
8
+ export interface TemporalWorkerOptions {
9
+ namespace?: string;
10
+ /** Both by default; activity-only workers can select ["activity"]. */
11
+ types?: TemporalTaskType[];
12
+ /** Require every named worker identity on each requested queue type. */
13
+ identities?: string[];
14
+ minPollers?: number;
15
+ maxAgeMs?: number;
16
+ timeoutMs?: number;
17
+ }
18
+ export interface TemporalNamespace {
19
+ namespaceInfo: { name: string; state: string; id: string };
20
+ config?: { workflowExecutionRetentionTtl?: string };
21
+ }
22
+ export interface TemporalOptions {
23
+ name?: string;
24
+ /** Pinned CLI image, including the real Temporal server and SQLite. */
25
+ image?: string;
26
+ namespace?: string;
27
+ namespaces?: string[];
28
+ searchAttributes?: Record<string, TemporalSearchAttributeType>;
29
+ port?: number;
30
+ httpPort?: number;
31
+ /** Off by default; no UI process or assets are needed by workers. */
32
+ ui?: boolean;
33
+ /** Optional native gRPC TLS endpoint; adds a small TLS terminator. */
34
+ tls?: { hostname: string };
35
+ }
36
+
37
+ class TemporalApiError extends Error {
38
+ constructor(readonly status: number, readonly code: number | undefined, message: string) { super(message); }
39
+ }
40
+ class TemporalTransportError extends Error {}
41
+ const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
42
+ const positive = (name: string, n: number) => {
43
+ if (!Number.isFinite(n) || n <= 0) throw new Error(`temporal(): ${name} must be positive`);
44
+ return n;
45
+ };
46
+ const namespaceName = (name: string) => {
47
+ if (!name || name.includes("\0")) throw new Error("temporal(): namespace must be non-empty and contain no NUL");
48
+ return name;
49
+ };
50
+
51
+ /** Stateless HTTP client, also usable inside a fake that provisions real namespaces.
52
+ * No gRPC channel survives a VM restore, and no CLI process is spawned per request.
53
+ */
54
+ export function temporalClient(httpUrl: string, namespace = "default", fetcher: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> = rawFetch) {
55
+ const nsPath = (ns: string) => `/api/v1/namespaces/${encodeURIComponent(namespaceName(ns))}`;
56
+ async function request<T>(path: string, init?: RequestInit, timeoutMs = 5000): Promise<T> {
57
+ let response: Response;
58
+ let body: string;
59
+ try {
60
+ response = await fetcher(`${httpUrl}${path}`, { ...init, signal: AbortSignal.timeout(Math.max(1, Math.ceil(timeoutMs))) });
61
+ body = await response.text();
62
+ } catch (cause) {
63
+ throw new TemporalTransportError(`Temporal ${path}: ${String(cause)}`, { cause });
64
+ }
65
+ let value: any;
66
+ try { value = body ? JSON.parse(body) : {}; }
67
+ catch {
68
+ if (response.ok) throw new Error(`Temporal ${path}: invalid JSON (HTTP ${response.status}): ${body.slice(0, 500)}`);
69
+ value = {}; // Gateways may return plain text or HTML for a transient error.
70
+ }
71
+ if (!response.ok) throw new TemporalApiError(response.status, value?.code,
72
+ `Temporal ${path}: HTTP ${response.status}: ${value?.message ?? body.slice(0, 500)}`);
73
+ return value as T;
74
+ }
75
+ async function until<T>(description: string, timeoutMs: number, probe: (remaining: number) => Promise<T | null>) {
76
+ const deadline = Date.now() + positive("timeoutMs", timeoutMs);
77
+ let last: unknown = "condition not met";
78
+ let interval = 50;
79
+ do {
80
+ try { const value = await probe(Math.min(5000, Math.max(1, deadline - Date.now()))); if (value !== null) return value; }
81
+ catch (error) {
82
+ if (!(error instanceof TemporalTransportError) &&
83
+ !(error instanceof TemporalApiError && [404, 429, 502, 503, 504].includes(error.status))) throw error;
84
+ last = error;
85
+ }
86
+ const remaining = deadline - Date.now();
87
+ if (remaining <= 0) break;
88
+ await delay(Math.min(interval, remaining));
89
+ interval = Math.min(500, interval * 2);
90
+ } while (Date.now() < deadline);
91
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for ${description}. Last result: ${String(last)}`);
92
+ }
93
+ return {
94
+ httpUrl, namespace,
95
+ describeNamespace(name = namespace) { return request<TemporalNamespace>(nsPath(name)); },
96
+ async registerNamespace(name: string, options: { retentionDays?: number; timeoutMs?: number } = {}) {
97
+ namespaceName(name);
98
+ const timeoutMs = positive("timeoutMs", options.timeoutMs ?? 10000);
99
+ const deadline = Date.now() + timeoutMs;
100
+ const retentionSeconds = positive("retentionDays", options.retentionDays ?? 1) * 86400;
101
+ await until(`Temporal namespace ${JSON.stringify(name)} creation`, timeoutMs, async remaining => {
102
+ try {
103
+ await request("/api/v1/namespaces", { method: "POST", headers: { "content-type": "application/json" },
104
+ body: JSON.stringify({ namespace: name, workflowExecutionRetentionPeriod: `${retentionSeconds}s` }) }, remaining);
105
+ } catch (error) {
106
+ // A retry after a lost response is safe: names are unique, and an
107
+ // existing namespace is never updated by this operation.
108
+ if (!(error instanceof TemporalApiError && error.code === 6)) throw error;
109
+ }
110
+ return true;
111
+ });
112
+ return until(`Temporal namespace ${JSON.stringify(name)} to be registered`, Math.max(1, deadline - Date.now()), async remaining => {
113
+ const result = await request<TemporalNamespace>(nsPath(name), undefined, remaining);
114
+ return result.namespaceInfo?.state === "NAMESPACE_STATE_REGISTERED" ? result : null;
115
+ });
116
+ },
117
+ async waitForWorkers(taskQueue: string, options: TemporalWorkerOptions = {}) {
118
+ if (!taskQueue) throw new Error("temporal(): taskQueue must be non-empty");
119
+ const ns = options.namespace ?? namespace;
120
+ const types = [...new Set(options.types ?? ["workflow", "activity"])];
121
+ if (!types.length || types.some(t => t !== "workflow" && t !== "activity")) throw new Error("temporal(): types must contain workflow and/or activity");
122
+ const minPollers = positive("minPollers", options.minPollers ?? 1);
123
+ if (!Number.isInteger(minPollers)) throw new Error("temporal(): minPollers must be an integer");
124
+ const maxAgeMs = positive("maxAgeMs", options.maxAgeMs ?? 120000);
125
+ let observed: Record<string, string[]> = {};
126
+ try {
127
+ return await until(`Temporal workers on ${ns}/${taskQueue} (${types.join(", ")})`, options.timeoutMs ?? 30000, async remaining => {
128
+ const entries = await Promise.all(types.map(async type => {
129
+ const result = await request<{ pollers?: TemporalPoller[] }>(`${nsPath(ns)}/task-queues/${encodeURIComponent(taskQueue)}?taskQueueType=${type === "workflow" ? 1 : 2}`, undefined, remaining);
130
+ const pollers = (result.pollers ?? []).filter(p => p.identity && p.lastAccessTime && Date.now() - Date.parse(p.lastAccessTime) <= maxAgeMs);
131
+ return [type, pollers] as const;
132
+ }));
133
+ observed = Object.fromEntries(entries.map(([t, ps]) => [t, ps.map(p => p.identity)]));
134
+ return entries.every(([, ps]) => new Set(ps.map(p => p.identity)).size >= minPollers &&
135
+ (options.identities ?? []).every(id => ps.some(p => p.identity === id))) ? Object.fromEntries(entries) : null;
136
+ });
137
+ } catch (error) {
138
+ if (error instanceof Error) error.message += ` Observed pollers: ${JSON.stringify(observed)}`;
139
+ throw error;
140
+ }
141
+ },
142
+ };
143
+ }
144
+ export type TemporalClient = ReturnType<typeof temporalClient>;
145
+
146
+ export function temporal(opts: TemporalOptions = {}) {
147
+ const name = opts.name ?? "temporal";
148
+ const namespace = namespaceName(opts.namespace ?? "default");
149
+ const port = opts.port ?? 7233;
150
+ const httpPort = opts.httpPort ?? 7243;
151
+ for (const p of [port, httpPort]) if (!Number.isInteger(p) || p < 1 || p > 65535) throw new Error("temporal(): ports must be integers between 1 and 65535");
152
+ if (port === httpPort || (opts.ui && [port, httpPort].includes(8233))) throw new Error("temporal(): listener ports must be distinct");
153
+ if (opts.tls && !/^(?:[a-z0-9-]+\.)+[a-z0-9-]+$/.test(opts.tls.hostname)) throw new Error("temporal(): TLS hostname must be a multi-label DNS name");
154
+ const serverName = opts.tls ? `${name}-server` : name;
155
+ const address = `${opts.tls?.hostname ?? name}:${port}`;
156
+ const httpUrl = `http://${serverName}:${httpPort}`;
157
+ const namespaces = [...new Set([namespace, ...(opts.namespaces ?? [])])].map(namespaceName);
158
+ const args = ["server", "start-dev", "--ip", "0.0.0.0", "--port", String(port), "--http-port", String(httpPort), "--log-format", "json"];
159
+ args.push(...(opts.ui ? ["--ui-port", "8233", "--ui-disable-news-fetch"] : ["--headless"]));
160
+ for (const ns of namespaces) if (ns !== "default") args.push("--namespace", ns);
161
+ for (const [key, type] of Object.entries(opts.searchAttributes ?? {})) {
162
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || !["Text", "Keyword", "Int", "Double", "Bool", "Datetime", "KeywordList"].includes(type)) throw new Error(`temporal(): invalid search attribute ${key}=${type}`);
163
+ args.push("--search-attribute", `${key}=${type}`);
164
+ }
165
+ const primary = opts.tls ? "tls" : "server";
166
+ const group = serviceGroup({
167
+ primary, expectKey: name,
168
+ services: (): ServicesMap => {
169
+ const server: ServiceDefinition = {
170
+ image: { type: "registry", reference: opts.image ?? "temporalio/temporal:1.9.1" }, args,
171
+ ports: [port, httpPort, ...(opts.ui ? [8233] : [])],
172
+ readyCheck: { type: "http", port: httpPort, path: `/api/v1/namespaces/${encodeURIComponent(namespace)}`, timeoutSecs: 30 },
173
+ };
174
+ if (!opts.tls) return { server };
175
+ return { server, tls: {
176
+ image: { type: "registry", reference: "nginx:1.29.6-alpine" },
177
+ ports: [port], hostnames: [opts.tls.hostname], dependsOn: [serverName],
178
+ certificates: [{ hostnames: [opts.tls.hostname], certPath: "/tls/server.crt", keyPath: "/tls/server.key" }],
179
+ files: [{ path: "/etc/nginx/nginx.conf", content: `worker_processes 1;\nevents { worker_connections 1024; }\nstream { server { listen ${port} ssl; ssl_certificate /tls/server.crt; ssl_certificate_key /tls/server.key; ssl_protocols TLSv1.2 TLSv1.3; ssl_alpn h2; proxy_pass ${serverName}:${port}; } }\n` }],
180
+ readyCheck: { type: "tcp", port, timeoutSecs: 30 },
181
+ } };
182
+ },
183
+ helpers: (ctx) => temporalClient(httpUrl, namespace,
184
+ async (input, init) => readRaw(await ctx.fetch(String(input), init)) as unknown as Response),
185
+ });
186
+ return { group, address, httpUrl, namespace, ready: name,
187
+ client: temporalClient(httpUrl, namespace) };
188
+ }