@specific.dev/spectest 0.82.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 +78 -3
  16. package/dist/coverage.js +251 -11
  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 +19 -7
  22. package/dist/harness/coverage.js +52 -9
  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 +93 -13
  38. package/src/coverage.ts +295 -11
  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 +47 -9
  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
  import { createHmac } from "node:crypto";
52
52
  import { existsSync, readFileSync } from "node:fs";
53
53
  import { readdir, stat } from "node:fs/promises";
@@ -59,6 +59,8 @@ import { email, emailHelpers } from "./email.js";
59
59
  import { github } from "./github.js";
60
60
  import { google } from "./google.js";
61
61
  import { microsoft } from "./microsoft.js";
62
+ import { SUPABASE_WEBHOOKS_SQL } from "./supabase-webhooks.js";
63
+ import { postgresTls } from "./postgres-tls.js";
62
64
  import { mapSupabaseConfig, parseSupabaseConfigFile, } from "./supabase-config.js";
63
65
  // ──────────────────────────────────────────────────────────────────────────
64
66
  // Pinned upstream image tags (github.com/supabase/supabase docker-compose.yml,
@@ -78,6 +80,15 @@ const IMAGES = {
78
80
  // part). Tiny, and only pulled by a stack that configures a template.
79
81
  templates: "busybox:1.37",
80
82
  };
83
+ /** Override tables without mutating the parsed project config. */
84
+ function mergeConfig(base, overrides) {
85
+ const table = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
86
+ const result = Object.assign(Object.create(null), table(base) ? base : {});
87
+ for (const [key, value] of Object.entries(overrides)) {
88
+ result[key] = table(value) ? mergeConfig(result[key], value) : value;
89
+ }
90
+ return result;
91
+ }
81
92
  /**
82
93
  * Social sign-in providers this stack can serve, by the name GoTrue (and
83
94
  * `[auth.external.<name>]`) uses. Each factory builds one container answering
@@ -214,6 +225,30 @@ function apiKey(role, secret) {
214
225
  function pgLiteral(s) {
215
226
  return `'${s.replace(/'/g, "''")}'`;
216
227
  }
228
+ function pgIdentifier(s) {
229
+ if (!s || s.includes("\0"))
230
+ throw new Error("supabase(): an SQL identifier must be non-empty and contain no NUL");
231
+ return `"${s.replace(/"/g, '""')}"`;
232
+ }
233
+ function extensionSql(extensions) {
234
+ return Object.entries(extensions ?? {}).map(([name, { schema, version }]) => {
235
+ const install = `CREATE EXTENSION IF NOT EXISTS ${pgIdentifier(name)}` +
236
+ (schema ? ` SCHEMA ${pgIdentifier(schema)}` : "") +
237
+ (version ? ` VERSION ${pgLiteral(version)}` : "") + ";";
238
+ // IF NOT EXISTS does not validate an already-installed extension.
239
+ const matches = [
240
+ version ? `e.extversion = ${pgLiteral(version)}` : "true",
241
+ schema ? `n.nspname = ${pgLiteral(schema)}` : "true",
242
+ ].join(" AND ");
243
+ const check = `BEGIN
244
+ IF EXISTS (SELECT 1 FROM pg_extension e JOIN pg_namespace n ON n.oid = e.extnamespace
245
+ WHERE e.extname = ${pgLiteral(name)} AND NOT (${matches})) THEN
246
+ RAISE EXCEPTION USING MESSAGE = ${pgLiteral(`supabase: extension ${name} does not match the requested schema/version`)};
247
+ END IF;
248
+ END`;
249
+ return `${install}\nDO ${pgLiteral(check)};`;
250
+ }).join("\n");
251
+ }
217
252
  function buildBootstrapSql(dbPassword, jwtSecret, withRealtime) {
218
253
  const pw = pgLiteral(dbPassword);
219
254
  const lines = [
@@ -1213,11 +1248,14 @@ async function reloadPostgrestSchema(ctx, db, rest) {
1213
1248
  /**
1214
1249
  * A ready-to-use self-hosted Supabase stack. Mount `.group` at the key
1215
1250
  * matching the `name` option (default `"supabase"`) and wire the app with
1216
- * `.appEnv` / `.ready`:
1251
+ * its connection values and `.ready`:
1217
1252
  *
1218
1253
  * ```ts
1219
1254
  * const sb = supabase({ dbPassword: "postgres" });
1220
- * services: { supabase: sb.group, app: { image, env: sb.appEnv, dependsOn: [sb.ready] } }
1255
+ * services: { supabase: sb.group, app: {
1256
+ * image, env: { SUPABASE_URL: sb.url, SUPABASE_ANON_KEY: sb.anonKey },
1257
+ * dependsOn: [sb.ready],
1258
+ * } }
1221
1259
  * ```
1222
1260
  *
1223
1261
  * See the file header for the full picture. Tests get an instrumented DB client
@@ -1272,11 +1310,27 @@ export function supabase(opts = {}) {
1272
1310
  // services map is returned. `config.json` wins where a newer CLI wrote one.
1273
1311
  const configPath = inDir("config.json") ?? inDir("config.toml");
1274
1312
  let mapped = null;
1275
- if (configPath !== null) {
1276
- const shown = configPath.startsWith(projectRootPath())
1313
+ if (configPath !== null || opts.configOverrides !== undefined) {
1314
+ const shown = configPath === null ? "configOverrides" : configPath.startsWith(projectRootPath())
1277
1315
  ? configPath.slice(projectRootPath().length + 1)
1278
1316
  : configPath;
1279
- mapped = mapSupabaseConfig(parseSupabaseConfigFile(configPath));
1317
+ mapped = mapSupabaseConfig(mergeConfig(configPath === null ? {} : parseSupabaseConfigFile(configPath), opts.configOverrides ?? {}));
1318
+ for (const [target, { from, key }] of Object.entries(mapped.deferredAuth)) {
1319
+ const enabled = target.replace(/_(URI|SECRETS)$/, "_ENABLED");
1320
+ if (!withAuth || mapped.auth[enabled] !== "true")
1321
+ continue;
1322
+ const value = opts.env?.[from];
1323
+ if (value === undefined || value === "") {
1324
+ throw new Error(`supabase(): ${key} reads env(${from}); supply a non-empty test value with env: { ${from}: "…" }.`);
1325
+ }
1326
+ mapped.auth[target] = value;
1327
+ }
1328
+ for (const key of mapped.encryptedValues) {
1329
+ const hook = /^auth\.hook\.([^.]+)\.(uri|secrets)$/.exec(key);
1330
+ if (withAuth && hook && mapped.auth[`GOTRUE_HOOK_${hook[1].toUpperCase()}_ENABLED`] === "true") {
1331
+ throw new Error(`supabase(): ${key} is encrypted; override it with env(NAME) and supply the test value through env.`);
1332
+ }
1333
+ }
1280
1334
  // What the file asked for and did not get. Reported, never fatal: the
1281
1335
  // config belongs to the project and describes more than this environment,
1282
1336
  // so a key that is meaningless in a hermetic VM is not grounds to refuse
@@ -1302,6 +1356,7 @@ export function supabase(opts = {}) {
1302
1356
  // and nothing says why.
1303
1357
  const referenced = new Set([
1304
1358
  ...Object.values(mapped.deferredSecrets),
1359
+ ...Object.values(mapped.deferredAuth).map(({ from }) => from),
1305
1360
  ...Object.values(mapped.externalProviders).flatMap((p) => [p.clientIdFrom, p.secretFrom].filter((n) => n !== undefined)),
1306
1361
  ]);
1307
1362
  const unread = Object.keys(opts.env ?? {}).filter((k) => !referenced.has(k));
@@ -1361,17 +1416,20 @@ export function supabase(opts = {}) {
1361
1416
  }
1362
1417
  const anonKey = apiKey("anon", jwtSecret);
1363
1418
  const serviceRoleKey = apiKey("service_role", jwtSecret);
1364
- // Derived surface, embedded in `appEnv` / `url` before the group expands.
1419
+ // Derived connection values, available before the group expands.
1365
1420
  // `expectKey: name` on the group guarantees these names and the expanded
1366
1421
  // service keys agree (`g.key("db")` at expansion === `${name}-db` here).
1367
1422
  const dbKey = `${name}-db`;
1368
1423
  const url = opts.hostname ? `https://${opts.hostname}` : `http://${name}:8000`;
1369
1424
  // App-facing URL: the conventional `postgres` role (owns migrated tables).
1370
- const dbUrl = `postgresql://postgres:${dbPassword}@${dbKey}:5432/postgres`;
1425
+ const dbTls = opts.db?.tls !== false ? postgresTls() : undefined;
1426
+ const dbUrl = `postgresql://postgres:${encodeURIComponent(dbPassword)}@${dbKey}:5432/postgres` +
1427
+ (dbTls ? "?sslmode=verify-full" : "");
1371
1428
  // The test-side `ctx.svc.<name>.sql` helper connects as the superuser
1372
1429
  // `supabase_admin` so it can inspect everything — public tables plus the
1373
1430
  // `auth`/`storage` schemas — regardless of ownership.
1374
- const adminDbUrl = `postgresql://supabase_admin:${dbPassword}@${dbKey}:5432/postgres`;
1431
+ const adminDbUrl = `postgresql://supabase_admin:${encodeURIComponent(dbPassword)}@${dbKey}:5432/postgres` +
1432
+ (dbTls ? "?sslmode=verify-full" : "");
1375
1433
  // ── social sign-in ────────────────────────────────────────────────────
1376
1434
  // Every `[auth.external.<name>]` the config enables: served in-environment
1377
1435
  // where there is an emulator for it, reported where there isn't. The app
@@ -1415,7 +1473,7 @@ export function supabase(opts = {}) {
1415
1473
  const parts = {};
1416
1474
  // ── db ──────────────────────────────────────────────────────────
1417
1475
  const dbDef = {
1418
- image: { type: "registry", reference: IMAGES.db },
1476
+ image: { type: "registry", reference: opts.db?.image ?? IMAGES.db },
1419
1477
  // `args` (not `command`): keep the image's docker-entrypoint.sh so it
1420
1478
  // runs initdb + its baked init-scripts before starting.
1421
1479
  args: [
@@ -1424,7 +1482,9 @@ export function supabase(opts = {}) {
1424
1482
  "config_file=/etc/postgresql/postgresql.conf",
1425
1483
  "-c",
1426
1484
  "log_min_messages=fatal",
1485
+ ...(dbTls?.args ?? []),
1427
1486
  ],
1487
+ ...(dbTls ? { certificates: dbTls.certificates } : {}),
1428
1488
  env: {
1429
1489
  POSTGRES_HOST: "/var/run/postgresql",
1430
1490
  PGPORT: "5432",
@@ -1451,6 +1511,10 @@ export function supabase(opts = {}) {
1451
1511
  // database — superuser-only. `postgres` is deliberately NOT a
1452
1512
  // superuser in modern supabase/postgres; `supabase_admin` is.
1453
1513
  "supabase_admin");
1514
+ await applySqlFile(ctx, SUPABASE_WEBHOOKS_SQL, "database webhooks", "supabase_admin");
1515
+ const extensions = extensionSql(opts.db?.extensions);
1516
+ if (extensions)
1517
+ await applySqlFile(ctx, extensions, "configured extensions", "postgres");
1454
1518
  },
1455
1519
  };
1456
1520
  parts.db = dbDef;
@@ -1834,12 +1898,6 @@ export function supabase(opts = {}) {
1834
1898
  ...(withMail ? { mail: emailHelpers(mailKey) } : {}),
1835
1899
  }),
1836
1900
  });
1837
- const appEnv = {
1838
- SUPABASE_URL: url,
1839
- SUPABASE_ANON_KEY: anonKey,
1840
- SUPABASE_SERVICE_ROLE_KEY: serviceRoleKey,
1841
- DATABASE_URL: dbUrl,
1842
- };
1843
1901
  return {
1844
1902
  group,
1845
1903
  url,
@@ -1847,7 +1905,6 @@ export function supabase(opts = {}) {
1847
1905
  serviceRoleKey,
1848
1906
  dbUrl,
1849
1907
  ready: name,
1850
- appEnv,
1851
1908
  ...(withMail ? { smtp: { host: mailKey, port: 1025 } } : {}),
1852
1909
  };
1853
1910
  }
@@ -0,0 +1,89 @@
1
+ import { type ServicesMap } from "../index.js";
2
+ export type TemporalSearchAttributeType = "Text" | "Keyword" | "Int" | "Double" | "Bool" | "Datetime" | "KeywordList";
3
+ export type TemporalTaskType = "workflow" | "activity";
4
+ export interface TemporalPoller {
5
+ identity: string;
6
+ lastAccessTime?: string;
7
+ }
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: {
20
+ name: string;
21
+ state: string;
22
+ id: string;
23
+ };
24
+ config?: {
25
+ workflowExecutionRetentionTtl?: string;
26
+ };
27
+ }
28
+ export interface TemporalOptions {
29
+ name?: string;
30
+ /** Pinned CLI image, including the real Temporal server and SQLite. */
31
+ image?: string;
32
+ namespace?: string;
33
+ namespaces?: string[];
34
+ searchAttributes?: Record<string, TemporalSearchAttributeType>;
35
+ port?: number;
36
+ httpPort?: number;
37
+ /** Off by default; no UI process or assets are needed by workers. */
38
+ ui?: boolean;
39
+ /** Optional native gRPC TLS endpoint; adds a small TLS terminator. */
40
+ tls?: {
41
+ hostname: string;
42
+ };
43
+ }
44
+ /** Stateless HTTP client, also usable inside a fake that provisions real namespaces.
45
+ * No gRPC channel survives a VM restore, and no CLI process is spawned per request.
46
+ */
47
+ export declare function temporalClient(httpUrl: string, namespace?: string, fetcher?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>): {
48
+ httpUrl: string;
49
+ namespace: string;
50
+ describeNamespace(name?: string): Promise<TemporalNamespace>;
51
+ registerNamespace(name: string, options?: {
52
+ retentionDays?: number;
53
+ timeoutMs?: number;
54
+ }): Promise<TemporalNamespace>;
55
+ waitForWorkers(taskQueue: string, options?: TemporalWorkerOptions): Promise<{
56
+ [k: string]: TemporalPoller[];
57
+ }>;
58
+ };
59
+ export type TemporalClient = ReturnType<typeof temporalClient>;
60
+ export declare function temporal(opts?: TemporalOptions): {
61
+ group: import("../index.js").ServiceGroup<ServicesMap, {
62
+ httpUrl: string;
63
+ namespace: string;
64
+ describeNamespace(name?: string): Promise<TemporalNamespace>;
65
+ registerNamespace(name: string, options?: {
66
+ retentionDays?: number;
67
+ timeoutMs?: number;
68
+ }): Promise<TemporalNamespace>;
69
+ waitForWorkers(taskQueue: string, options?: TemporalWorkerOptions): Promise<{
70
+ [k: string]: TemporalPoller[];
71
+ }>;
72
+ }, "tls" | "server">;
73
+ address: string;
74
+ httpUrl: string;
75
+ namespace: string;
76
+ ready: string;
77
+ client: {
78
+ httpUrl: string;
79
+ namespace: string;
80
+ describeNamespace(name?: string): Promise<TemporalNamespace>;
81
+ registerNamespace(name: string, options?: {
82
+ retentionDays?: number;
83
+ timeoutMs?: number;
84
+ }): Promise<TemporalNamespace>;
85
+ waitForWorkers(taskQueue: string, options?: TemporalWorkerOptions): Promise<{
86
+ [k: string]: TemporalPoller[];
87
+ }>;
88
+ };
89
+ };
@@ -0,0 +1,185 @@
1
+ import { serviceGroup } from "../index.js";
2
+ import { rawFetch } from "../harness/raw-fetch.js";
3
+ import { readRaw } from "../inspect.js";
4
+ class TemporalApiError extends Error {
5
+ status;
6
+ code;
7
+ constructor(status, code, message) {
8
+ super(message);
9
+ this.status = status;
10
+ this.code = code;
11
+ }
12
+ }
13
+ class TemporalTransportError extends Error {
14
+ }
15
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
16
+ const positive = (name, n) => {
17
+ if (!Number.isFinite(n) || n <= 0)
18
+ throw new Error(`temporal(): ${name} must be positive`);
19
+ return n;
20
+ };
21
+ const namespaceName = (name) => {
22
+ if (!name || name.includes("\0"))
23
+ throw new Error("temporal(): namespace must be non-empty and contain no NUL");
24
+ return name;
25
+ };
26
+ /** Stateless HTTP client, also usable inside a fake that provisions real namespaces.
27
+ * No gRPC channel survives a VM restore, and no CLI process is spawned per request.
28
+ */
29
+ export function temporalClient(httpUrl, namespace = "default", fetcher = rawFetch) {
30
+ const nsPath = (ns) => `/api/v1/namespaces/${encodeURIComponent(namespaceName(ns))}`;
31
+ async function request(path, init, timeoutMs = 5000) {
32
+ let response;
33
+ let body;
34
+ try {
35
+ response = await fetcher(`${httpUrl}${path}`, { ...init, signal: AbortSignal.timeout(Math.max(1, Math.ceil(timeoutMs))) });
36
+ body = await response.text();
37
+ }
38
+ catch (cause) {
39
+ throw new TemporalTransportError(`Temporal ${path}: ${String(cause)}`, { cause });
40
+ }
41
+ let value;
42
+ try {
43
+ value = body ? JSON.parse(body) : {};
44
+ }
45
+ catch {
46
+ if (response.ok)
47
+ throw new Error(`Temporal ${path}: invalid JSON (HTTP ${response.status}): ${body.slice(0, 500)}`);
48
+ value = {}; // Gateways may return plain text or HTML for a transient error.
49
+ }
50
+ if (!response.ok)
51
+ throw new TemporalApiError(response.status, value?.code, `Temporal ${path}: HTTP ${response.status}: ${value?.message ?? body.slice(0, 500)}`);
52
+ return value;
53
+ }
54
+ async function until(description, timeoutMs, probe) {
55
+ const deadline = Date.now() + positive("timeoutMs", timeoutMs);
56
+ let last = "condition not met";
57
+ let interval = 50;
58
+ do {
59
+ try {
60
+ const value = await probe(Math.min(5000, Math.max(1, deadline - Date.now())));
61
+ if (value !== null)
62
+ return value;
63
+ }
64
+ catch (error) {
65
+ if (!(error instanceof TemporalTransportError) &&
66
+ !(error instanceof TemporalApiError && [404, 429, 502, 503, 504].includes(error.status)))
67
+ throw error;
68
+ last = error;
69
+ }
70
+ const remaining = deadline - Date.now();
71
+ if (remaining <= 0)
72
+ break;
73
+ await delay(Math.min(interval, remaining));
74
+ interval = Math.min(500, interval * 2);
75
+ } while (Date.now() < deadline);
76
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for ${description}. Last result: ${String(last)}`);
77
+ }
78
+ return {
79
+ httpUrl, namespace,
80
+ describeNamespace(name = namespace) { return request(nsPath(name)); },
81
+ async registerNamespace(name, options = {}) {
82
+ namespaceName(name);
83
+ const timeoutMs = positive("timeoutMs", options.timeoutMs ?? 10000);
84
+ const deadline = Date.now() + timeoutMs;
85
+ const retentionSeconds = positive("retentionDays", options.retentionDays ?? 1) * 86400;
86
+ await until(`Temporal namespace ${JSON.stringify(name)} creation`, timeoutMs, async (remaining) => {
87
+ try {
88
+ await request("/api/v1/namespaces", { method: "POST", headers: { "content-type": "application/json" },
89
+ body: JSON.stringify({ namespace: name, workflowExecutionRetentionPeriod: `${retentionSeconds}s` }) }, remaining);
90
+ }
91
+ catch (error) {
92
+ // A retry after a lost response is safe: names are unique, and an
93
+ // existing namespace is never updated by this operation.
94
+ if (!(error instanceof TemporalApiError && error.code === 6))
95
+ throw error;
96
+ }
97
+ return true;
98
+ });
99
+ return until(`Temporal namespace ${JSON.stringify(name)} to be registered`, Math.max(1, deadline - Date.now()), async (remaining) => {
100
+ const result = await request(nsPath(name), undefined, remaining);
101
+ return result.namespaceInfo?.state === "NAMESPACE_STATE_REGISTERED" ? result : null;
102
+ });
103
+ },
104
+ async waitForWorkers(taskQueue, options = {}) {
105
+ if (!taskQueue)
106
+ throw new Error("temporal(): taskQueue must be non-empty");
107
+ const ns = options.namespace ?? namespace;
108
+ const types = [...new Set(options.types ?? ["workflow", "activity"])];
109
+ if (!types.length || types.some(t => t !== "workflow" && t !== "activity"))
110
+ throw new Error("temporal(): types must contain workflow and/or activity");
111
+ const minPollers = positive("minPollers", options.minPollers ?? 1);
112
+ if (!Number.isInteger(minPollers))
113
+ throw new Error("temporal(): minPollers must be an integer");
114
+ const maxAgeMs = positive("maxAgeMs", options.maxAgeMs ?? 120000);
115
+ let observed = {};
116
+ try {
117
+ return await until(`Temporal workers on ${ns}/${taskQueue} (${types.join(", ")})`, options.timeoutMs ?? 30000, async (remaining) => {
118
+ const entries = await Promise.all(types.map(async (type) => {
119
+ const result = await request(`${nsPath(ns)}/task-queues/${encodeURIComponent(taskQueue)}?taskQueueType=${type === "workflow" ? 1 : 2}`, undefined, remaining);
120
+ const pollers = (result.pollers ?? []).filter(p => p.identity && p.lastAccessTime && Date.now() - Date.parse(p.lastAccessTime) <= maxAgeMs);
121
+ return [type, pollers];
122
+ }));
123
+ observed = Object.fromEntries(entries.map(([t, ps]) => [t, ps.map(p => p.identity)]));
124
+ return entries.every(([, ps]) => new Set(ps.map(p => p.identity)).size >= minPollers &&
125
+ (options.identities ?? []).every(id => ps.some(p => p.identity === id))) ? Object.fromEntries(entries) : null;
126
+ });
127
+ }
128
+ catch (error) {
129
+ if (error instanceof Error)
130
+ error.message += ` Observed pollers: ${JSON.stringify(observed)}`;
131
+ throw error;
132
+ }
133
+ },
134
+ };
135
+ }
136
+ export function temporal(opts = {}) {
137
+ const name = opts.name ?? "temporal";
138
+ const namespace = namespaceName(opts.namespace ?? "default");
139
+ const port = opts.port ?? 7233;
140
+ const httpPort = opts.httpPort ?? 7243;
141
+ for (const p of [port, httpPort])
142
+ if (!Number.isInteger(p) || p < 1 || p > 65535)
143
+ throw new Error("temporal(): ports must be integers between 1 and 65535");
144
+ if (port === httpPort || (opts.ui && [port, httpPort].includes(8233)))
145
+ throw new Error("temporal(): listener ports must be distinct");
146
+ if (opts.tls && !/^(?:[a-z0-9-]+\.)+[a-z0-9-]+$/.test(opts.tls.hostname))
147
+ throw new Error("temporal(): TLS hostname must be a multi-label DNS name");
148
+ const serverName = opts.tls ? `${name}-server` : name;
149
+ const address = `${opts.tls?.hostname ?? name}:${port}`;
150
+ const httpUrl = `http://${serverName}:${httpPort}`;
151
+ const namespaces = [...new Set([namespace, ...(opts.namespaces ?? [])])].map(namespaceName);
152
+ const args = ["server", "start-dev", "--ip", "0.0.0.0", "--port", String(port), "--http-port", String(httpPort), "--log-format", "json"];
153
+ args.push(...(opts.ui ? ["--ui-port", "8233", "--ui-disable-news-fetch"] : ["--headless"]));
154
+ for (const ns of namespaces)
155
+ if (ns !== "default")
156
+ args.push("--namespace", ns);
157
+ for (const [key, type] of Object.entries(opts.searchAttributes ?? {})) {
158
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || !["Text", "Keyword", "Int", "Double", "Bool", "Datetime", "KeywordList"].includes(type))
159
+ throw new Error(`temporal(): invalid search attribute ${key}=${type}`);
160
+ args.push("--search-attribute", `${key}=${type}`);
161
+ }
162
+ const primary = opts.tls ? "tls" : "server";
163
+ const group = serviceGroup({
164
+ primary, expectKey: name,
165
+ services: () => {
166
+ const server = {
167
+ image: { type: "registry", reference: opts.image ?? "temporalio/temporal:1.9.1" }, args,
168
+ ports: [port, httpPort, ...(opts.ui ? [8233] : [])],
169
+ readyCheck: { type: "http", port: httpPort, path: `/api/v1/namespaces/${encodeURIComponent(namespace)}`, timeoutSecs: 30 },
170
+ };
171
+ if (!opts.tls)
172
+ return { server };
173
+ return { server, tls: {
174
+ image: { type: "registry", reference: "nginx:1.29.6-alpine" },
175
+ ports: [port], hostnames: [opts.tls.hostname], dependsOn: [serverName],
176
+ certificates: [{ hostnames: [opts.tls.hostname], certPath: "/tls/server.crt", keyPath: "/tls/server.key" }],
177
+ 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` }],
178
+ readyCheck: { type: "tcp", port, timeoutSecs: 30 },
179
+ } };
180
+ },
181
+ helpers: (ctx) => temporalClient(httpUrl, namespace, async (input, init) => readRaw(await ctx.fetch(String(input), init))),
182
+ });
183
+ return { group, address, httpUrl, namespace, ready: name,
184
+ client: temporalClient(httpUrl, namespace) };
185
+ }
@@ -29,6 +29,29 @@ export interface CoverageLoadContext {
29
29
  */
30
30
  collectBrowserScripts(): void;
31
31
  }
32
+ /**
33
+ * What one adapter's `inventory` runs against, once per environment, at
34
+ * the bring-up capture. The inventory is the set of **units** the
35
+ * environment resolved for this service — dependencies, and fingerprints
36
+ * of the manifests that declare them (see {@link CoverageAdapter.inventory}).
37
+ */
38
+ export interface CoverageInventoryContext {
39
+ /** The services-map key. */
40
+ service: string;
41
+ /** Run a shell command (`sh -c`) inside the container. Rejects on a
42
+ * non-zero exit with the command's output in the message. */
43
+ exec(command: string): Promise<{
44
+ stdout: string;
45
+ stderr: string;
46
+ }>;
47
+ /** Every file of the uploaded project, project-relative, `/`-separated,
48
+ * `node_modules`, `.git` and the like skipped. */
49
+ projectFiles(): Promise<string[]>;
50
+ /** One project file's text (project-relative path); `null` when absent. */
51
+ readProjectFile(path: string): Promise<string | null>;
52
+ /** Aborts when the inventory budget runs out. */
53
+ signal: AbortSignal;
54
+ }
32
55
  /** What one adapter's `capture` runs against. */
33
56
  export interface CoverageCaptureContext {
34
57
  /** The services-map key. */
@@ -73,7 +96,31 @@ export interface CoverageAdapter {
73
96
  load?(ctx: CoverageLoadContext): void | Promise<void>;
74
97
  /** Make the report appear in the directory. A throw fails the test. */
75
98
  capture?(ctx: CoverageCaptureContext): void | Promise<void>;
99
+ /**
100
+ * Project paths (gitignore-style patterns, project-relative) whose
101
+ * changes this adapter's **inventory** accounts for — a package
102
+ * manifest, a lockfile. The subset selector sets such a changed file
103
+ * aside instead of treating it as a file no coverage names (which is a
104
+ * full run), and judges the change by the inventory instead: a unit
105
+ * that is gone selects the tests that ran it; a gone unit no test ran
106
+ * is a full run. Part of the wire config (`coverage.explains`).
107
+ */
108
+ explains?: readonly string[];
109
+ /**
110
+ * The **units** the environment resolved for this service, once per
111
+ * environment at the bring-up capture: one opaque id per line of the
112
+ * `inventory.units` report. Dependencies as `<scheme>:<name>@<version>`
113
+ * (`npm:picocolors@1.1.1`), and a fingerprint per manifest as
114
+ * `manifest:<project path>#<hash>` over the manifest minus its metadata
115
+ * keys, so a version bump does not move it and a `scripts` change does.
116
+ * The control plane compares inventories across runs by string
117
+ * equality and knows no scheme; what a unit is, is the adapter's.
118
+ */
119
+ inventory?(ctx: CoverageInventoryContext): Promise<readonly string[]>;
76
120
  }
121
+ /** The report name the harness writes an adapter's inventory to, in the
122
+ * bring-up capture only. */
123
+ export declare const INVENTORY_REPORT = "inventory.units";
77
124
  /** `ServiceConfig.coverage`: the program writes its own reports (`true`),
78
125
  * or a list of adapters gets them out, and/or how the reports the
79
126
  * program itself writes count (`reports`, default `"cumulative"`). */
@@ -141,12 +188,21 @@ export declare const NODE_COVERAGE_DUMP_PREFIX = "node-";
141
188
  * counters: every dump is the delta since the previous take. No
142
189
  * signal is used: signals are claimed by frameworks (SIGUSR2 stops a
143
190
  * Temporal worker, restarts nodemon), a socket is nobody's.
191
+ * - Reports **dependencies as units**: a ran script under `node_modules`
192
+ * becomes `npm:<name>@<version>` (from the nearest package.json, or the
193
+ * path's text) in the dump's `units`; a bundled script's map sources
194
+ * under `node_modules` become `sourceUnits` beside the map in its
195
+ * record. The control plane compares unit ids by equality and never
196
+ * learns what `node_modules` is.
144
197
  * - Writes a **dump** `node-<pid>-<thread>-<ms>-<n>.json`: the app's own
145
198
  * scripts (`file://`, outside `node_modules` and `.cache`, not the
146
199
  * hook) in which some range count is above zero, and of each only the
147
- * functions that ran, ranges and counts as V8 reported them. Nothing
148
- * when nothing ran. Still a V8 coverage document (`result`), plus
149
- * `spectestScript` on each script naming its record.
200
+ * functions that ran, each one flat array: `[start, length, count]`
201
+ * for its extent (V8's first range), then `[offset from start,
202
+ * length, count]` per block range in V8's order. Name and block flag
203
+ * are in the record, joined on the extent. Nothing when nothing ran.
204
+ * `{ spectestVersion: 2, result: [{ url, spectestScript, functions:
205
+ * [[s, l, c, o, l, c, …], …] }] }`.
150
206
  * - Writes, **once per script per branch**, a **script record**
151
207
  * `scripts/<id>.json` (`id` = sha256 of the URL): the length of every
152
208
  * line of the source V8 compiled (`Debugger.getScriptSource`, so a
@@ -182,6 +238,25 @@ export declare const NODE_COVERAGE_HOOK: string;
182
238
  * only its own files: what an earlier capture on this branch shipped is
183
239
  * removed first (module memory, forks with the environment).
184
240
  */
241
+ /** Manifests and lockfiles whose changes the node inventory accounts for. */
242
+ export declare const NODE_EXPLAINS: readonly string[];
243
+ /** package.json keys that describe the package rather than shape what
244
+ * runs: a change confined to them does not move the manifest's unit. */
245
+ export declare const NPM_MANIFEST_METADATA_KEYS: readonly string[];
246
+ /** `manifest:<path>#<hash>` over a package.json minus its metadata keys,
247
+ * keys sorted, so formatting and a version bump leave it unchanged and a
248
+ * `scripts`, `exports`, `bin` or dependency change moves it. A file that
249
+ * is not JSON hashes as text. */
250
+ export declare function npmManifestUnit(projectPath: string, text: string): string;
251
+ /**
252
+ * The node script the inventory runs inside the container: every package
253
+ * installed in a `node_modules` on the path from the working directory up
254
+ * to `/` — hoisted (`node_modules/<name>`, `@scope/<name>`), nested, and
255
+ * pnpm's store (`node_modules/.pnpm/<pkg>/node_modules/<name>`, the real
256
+ * directories only; the symlinks beside them point back into the store).
257
+ * Prints one `npm:<name>@<version>` per line.
258
+ */
259
+ export declare const NODE_INVENTORY_SCRIPT = "\"use strict\";\nconst fs = require(\"node:fs\"), path = require(\"node:path\");\nconst out = new Set();\nfunction isRealDir(p) { try { return fs.lstatSync(p).isDirectory(); } catch (e) { return false; } }\nfunction readPkg(d) {\n try {\n const j = JSON.parse(fs.readFileSync(path.join(d, \"package.json\"), \"utf8\"));\n if (j && typeof j.name === \"string\" && j.name) out.add(\"npm:\" + j.name + (typeof j.version === \"string\" && j.version ? \"@\" + j.version : \"\"));\n } catch (e) {}\n}\nfunction visit(d, depth) {\n readPkg(d);\n if (depth < 3) { const nested = path.join(d, \"node_modules\"); if (isRealDir(nested)) walkNm(nested, depth + 1); }\n}\nfunction walkNm(nm, depth) {\n let names; try { names = fs.readdirSync(nm); } catch (e) { return; }\n for (const n of names) {\n if (n === \".bin\" || n === \".cache\" || n === \".modules.yaml\") continue;\n if (n === \".pnpm\") {\n let ps; try { ps = fs.readdirSync(path.join(nm, \".pnpm\")); } catch (e) { continue; }\n for (const p of ps) { const inner = path.join(nm, \".pnpm\", p, \"node_modules\"); if (isRealDir(inner)) walkNm(inner, depth + 1); }\n continue;\n }\n if (n.startsWith(\"@\")) {\n let ss; try { ss = fs.readdirSync(path.join(nm, n)); } catch (e) { continue; }\n for (const s of ss) { const d = path.join(nm, n, s); if (isRealDir(d)) visit(d, depth); }\n } else { const d = path.join(nm, n); if (isRealDir(d)) visit(d, depth); }\n }\n}\nlet d = process.cwd();\nfor (;;) {\n const nm = path.join(d, \"node_modules\");\n if (isRealDir(nm)) walkNm(nm, 0);\n const up = path.dirname(d);\n if (up === d) break;\n d = up;\n}\nprocess.stdout.write(Array.from(out).sort().join(\"\\n\") + (out.size ? \"\\n\" : \"\"));\n";
185
260
  export declare function node(): CoverageAdapter;
186
261
  /**
187
262
  * Ask every node process that holds a hook socket in `dir` for a dump.