@jterrazz/test 9.0.0 → 9.2.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/index.js CHANGED
@@ -7,6 +7,7 @@ import { Client } from "pg";
7
7
  import { readdir } from "node:fs/promises";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { tmpdir } from "node:os";
10
+ import { createServer } from "node:net";
10
11
  import Database from "better-sqlite3";
11
12
  import { mockDeep } from "vitest-mock-extended";
12
13
  import MockDatePackage from "mockdate";
@@ -1926,7 +1927,15 @@ async function toMatchRows(received, expected) {
1926
1927
  };
1927
1928
  }
1928
1929
  async function toBeEmpty(received) {
1929
- if (!(received instanceof TableAccessor)) throw new TypeError("toBeEmpty: unsupported subject — expected result.table(...).");
1930
+ if (received instanceof TextAccessor) {
1931
+ const content = received.comparableText;
1932
+ const pass = content === "";
1933
+ return {
1934
+ message: () => pass ? `expected ${received.streamName} not to be empty` : `Expected ${received.streamName} to be empty, but it contains:\n${content}`,
1935
+ pass
1936
+ };
1937
+ }
1938
+ if (!(received instanceof TableAccessor)) throw new TypeError("toBeEmpty: unsupported subject — expected result.table(...) or a text accessor.");
1930
1939
  const rows = await received.query(["*"]);
1931
1940
  const pass = rows.length === 0;
1932
1941
  return {
@@ -1988,6 +1997,128 @@ var HttpResult = class extends BaseResult {
1988
1997
  }
1989
1998
  };
1990
1999
  //#endregion
2000
+ //#region src/core/specification/website/result.ts
2001
+ /** Result from a raw `.fetch()` action (robots.txt, sitemaps, redirects). */
2002
+ var FetchResult = class extends BaseResult {
2003
+ exchange;
2004
+ constructor(options) {
2005
+ super(options);
2006
+ this.exchange = options.exchange;
2007
+ }
2008
+ /** The response body as a text accessor (`toMatch('robots.txt')`, `.grep()`). */
2009
+ get body() {
2010
+ return new TextAccessor(this.exchange.body, "body", this.testDir, { captures: this.captures });
2011
+ }
2012
+ /** Response headers as a flat, lower-cased key-value map. */
2013
+ get headers() {
2014
+ return this.exchange.headers;
2015
+ }
2016
+ /** The response body parsed as JSON. */
2017
+ get json() {
2018
+ return new JsonAccessor(this.exchange.body, this.testDir, void 0, this.captures);
2019
+ }
2020
+ /** The `location` header of a redirect response, or undefined. */
2021
+ get location() {
2022
+ return this.exchange.headers["location"];
2023
+ }
2024
+ /** The HTTP response status code — redirects surface as 3xx, never followed. */
2025
+ get status() {
2026
+ return this.exchange.status;
2027
+ }
2028
+ };
2029
+ /** Result from a rendered `.visit()` action — the page as a browser saw it. */
2030
+ var PageResult = class extends BaseResult {
2031
+ page;
2032
+ constructor(options) {
2033
+ super(options);
2034
+ this.page = options.page;
2035
+ }
2036
+ /** Hreflang alternates declared in the head, keyed by language code. */
2037
+ get alternates() {
2038
+ const alternates = {};
2039
+ for (const link of this.page.links) if (link.rel === "alternate" && link.hreflang) alternates[link.hreflang] = link.href;
2040
+ return alternates;
2041
+ }
2042
+ /** The canonical URL declared in the head, or null. */
2043
+ get canonical() {
2044
+ return this.page.links.find((link) => link.rel === "canonical")?.href ?? null;
2045
+ }
2046
+ /**
2047
+ * The browser console as a stream — one message per line, prefixed by
2048
+ * its type — same shape as `stdout` on a cli result. Assert emptiness
2049
+ * with `toBeEmpty()`, or snapshot with `toMatch('home.console.txt')`.
2050
+ */
2051
+ get console() {
2052
+ return new TextAccessor(this.page.consoleMessages.map((m) => `[${m.type}] ${m.text}`).join("\n"), "console", this.testDir, { captures: this.captures });
2053
+ }
2054
+ /**
2055
+ * Console messages of type `error` only — the stream you usually want
2056
+ * empty: `expect(result.errors).toBeEmpty()`. Same shape as `stderr` on
2057
+ * a cli result.
2058
+ */
2059
+ get errors() {
2060
+ return new TextAccessor(this.page.consoleMessages.filter((m) => m.type === "error").map((m) => m.text).join("\n"), "errors", this.testDir, { captures: this.captures });
2061
+ }
2062
+ /**
2063
+ * The document-head projection as a JSON accessor — title, canonical,
2064
+ * hreflang alternates, and named metas — for one golden per page:
2065
+ * `expect(result.head).toMatch('home.head.json')`.
2066
+ */
2067
+ get head() {
2068
+ const summary = {
2069
+ alternates: this.alternates,
2070
+ canonical: this.canonical,
2071
+ metas: namedMetas(this.page.metas),
2072
+ title: this.page.title
2073
+ };
2074
+ return new JsonAccessor(JSON.stringify(summary), this.testDir, void 0, this.captures);
2075
+ }
2076
+ /** The rendered DOM serialization as a text accessor. */
2077
+ get html() {
2078
+ return new TextAccessor(this.page.html, "html", this.testDir, { captures: this.captures });
2079
+ }
2080
+ /**
2081
+ * Every `application/ld+json` block of the page, parsed, as one JSON
2082
+ * array — `expect(result.jsonLd).toMatch('article.jsonld.json')`.
2083
+ */
2084
+ get jsonLd() {
2085
+ return new JsonAccessor(`[${this.page.jsonLdBlocks.join(",")}]`, this.testDir, void 0, this.captures);
2086
+ }
2087
+ /** `<link>` elements of the head, in DOM order. */
2088
+ get links() {
2089
+ return this.page.links;
2090
+ }
2091
+ /** The content of a named meta — `meta('description')`, `meta('og:image')`. */
2092
+ meta(name) {
2093
+ return this.page.metas.find((m) => m.name === name || m.property === name)?.content;
2094
+ }
2095
+ /** HTTP status of the main document response. */
2096
+ get status() {
2097
+ return this.page.status;
2098
+ }
2099
+ /** Rendered body text as a text accessor. */
2100
+ get content() {
2101
+ return new TextAccessor(this.page.text, "text", this.testDir, { captures: this.captures });
2102
+ }
2103
+ /** The document title as a text accessor. */
2104
+ get title() {
2105
+ return new TextAccessor(this.page.title, "title", this.testDir, { captures: this.captures });
2106
+ }
2107
+ /** Final URL after redirects. */
2108
+ get url() {
2109
+ return this.page.url;
2110
+ }
2111
+ };
2112
+ /** Collapse meta elements into a flat name → content map (property wins the key). */
2113
+ function namedMetas(metas) {
2114
+ const named = {};
2115
+ for (const meta of metas) {
2116
+ const key = meta.name ?? meta.property;
2117
+ if (key) named[key] = meta.content;
2118
+ }
2119
+ return named;
2120
+ }
2121
+ //#endregion
1991
2122
  //#region src/core/specification/shared/binding.ts
1992
2123
  /**
1993
2124
  * Compose-binding resolution and the case conversions it shares with the
@@ -2316,6 +2447,38 @@ var SpecificationBuilder = class {
2316
2447
  });
2317
2448
  }
2318
2449
  /**
2450
+ * Perform one raw HTTP GET against the website under test and resolve
2451
+ * with the exchange. Redirects are never followed — a 308 IS the result,
2452
+ * with its `location` readable on the result. The scalpel for wire-level
2453
+ * surfaces: robots.txt, sitemaps, llms.txt, redirect policies.
2454
+ *
2455
+ * @example
2456
+ * const result = await website.fetch('/robots.txt');
2457
+ * expect(result.status).toBe(200);
2458
+ * expect(result.body).toMatch('robots.txt');
2459
+ */
2460
+ fetch(path) {
2461
+ return this.executeSetup(null, () => this.runFetchAction(path));
2462
+ }
2463
+ /**
2464
+ * Render the page in the shared browser instance and resolve with the
2465
+ * captured document — rendered title, head elements, JSON-LD blocks,
2466
+ * body text, console errors. One browser per runner; each visit gets a
2467
+ * fresh, isolated context.
2468
+ *
2469
+ * @example
2470
+ * const result = await website.visit('/articles');
2471
+ * expect(result.head).toMatch('articles.head.json');
2472
+ *
2473
+ * const result = await website.visit('/', async (visitor) => {
2474
+ * await visitor.click(link('Articles'));
2475
+ * });
2476
+ * expect(result.url).toContain('/articles');
2477
+ */
2478
+ visit(path, scenario) {
2479
+ return this.executeSetup(null, () => this.runVisitAction(path, scenario));
2480
+ }
2481
+ /**
2319
2482
  * Execute the named job registered via the `jobs` option of
2320
2483
  * `specification.jobs()` and resolve with the result.
2321
2484
  *
@@ -2404,6 +2567,49 @@ var SpecificationBuilder = class {
2404
2567
  testDir: this.testDir
2405
2568
  });
2406
2569
  }
2570
+ async runFetchAction(path) {
2571
+ const baseUrl = this.requireBaseUrl("fetch");
2572
+ const headers = Object.keys(this.requestHeaders).length > 0 ? this.requestHeaders : void 0;
2573
+ const response = await fetch(`${baseUrl}${path}`, {
2574
+ headers,
2575
+ redirect: "manual"
2576
+ });
2577
+ const body = await response.text();
2578
+ const responseHeaders = {};
2579
+ response.headers.forEach((value, key) => {
2580
+ responseHeaders[key] = value;
2581
+ });
2582
+ return new FetchResult({
2583
+ config: this.config,
2584
+ exchange: {
2585
+ body,
2586
+ headers: responseHeaders,
2587
+ status: response.status
2588
+ },
2589
+ testDir: this.testDir
2590
+ });
2591
+ }
2592
+ async runVisitAction(path, scenario) {
2593
+ const baseUrl = this.requireBaseUrl("visit");
2594
+ if (!this.config.browser) throw new Error(".visit() requires a browser adapter (use specification.website())");
2595
+ const browser = await this.config.browser();
2596
+ const headers = Object.keys(this.requestHeaders).length > 0 ? this.requestHeaders : void 0;
2597
+ const page = await browser.open(`${baseUrl}${path}`, {
2598
+ baseUrl,
2599
+ external: this.config.external ?? "allow",
2600
+ headers,
2601
+ scenario
2602
+ });
2603
+ return new PageResult({
2604
+ config: this.config,
2605
+ page,
2606
+ testDir: this.testDir
2607
+ });
2608
+ }
2609
+ requireBaseUrl(method) {
2610
+ if (!this.config.baseUrl) throw new Error(`.${method}() requires a website under test (use specification.website())`);
2611
+ return this.config.baseUrl;
2612
+ }
2407
2613
  async runJobAction(name) {
2408
2614
  if (!this.config.jobs?.length) throw new Error("Job actions require jobs registered via the jobs option of specification.jobs()");
2409
2615
  const job = this.config.jobs.find((j) => j.name === name);
@@ -2512,6 +2718,17 @@ function createJobsFacet(config) {
2512
2718
  };
2513
2719
  }
2514
2720
  /**
2721
+ * Create the `website` facet bound to the given adapter configuration.
2722
+ */
2723
+ function createWebsiteFacet(config) {
2724
+ const start = () => new SpecificationBuilder(config, getCallerDir());
2725
+ return {
2726
+ fetch: (path) => start().fetch(path),
2727
+ headers: (headers) => start().headers(headers),
2728
+ visit: (path, scenario) => start().visit(path, scenario)
2729
+ };
2730
+ }
2731
+ /**
2515
2732
  * Create the `cli` facet bound to the given adapter configuration.
2516
2733
  */
2517
2734
  function createCliFacet(config) {
@@ -2900,7 +3117,7 @@ function isTransientConnectionError(error) {
2900
3117
  const cause = error.cause;
2901
3118
  return cause?.code !== void 0 && TRANSIENT_CONNECTION_CODES.has(cause.code) || error.message.includes("fetch failed");
2902
3119
  }
2903
- const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3120
+ const delay$1 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2904
3121
  /**
2905
3122
  * Server adapter that sends real HTTP requests via the Fetch API.
2906
3123
  * Used by the `e2e()` specification runner to hit a live server.
@@ -2941,7 +3158,7 @@ var FetchAdapter = class {
2941
3158
  return await fetch(url, init);
2942
3159
  } catch (error) {
2943
3160
  if (attempt >= MAX_ATTEMPTS || !isTransientConnectionError(error)) throw error;
2944
- await delay(BASE_BACKOFF_MS * 2 ** (attempt - 1));
3161
+ await delay$1(BASE_BACKOFF_MS * 2 ** (attempt - 1));
2945
3162
  }
2946
3163
  }
2947
3164
  };
@@ -3004,7 +3221,7 @@ async function startApi(options) {
3004
3221
  //#region src/core/specification/cli/exec.adapter.ts
3005
3222
  const DEFAULT_WATCH_TIMEOUT = 1e4;
3006
3223
  /** Grace period between SIGTERM and the SIGKILL escalation. */
3007
- const KILL_GRACE_MS = 2e3;
3224
+ const KILL_GRACE_MS$1 = 2e3;
3008
3225
  /**
3009
3226
  * Build a child-process env from the parent env plus user overrides.
3010
3227
  * `null` overrides delete keys (e.g. `INIT_CWD: null`).
@@ -3047,7 +3264,7 @@ function observeProcess(child, options) {
3047
3264
  child.kill("SIGTERM");
3048
3265
  killTimer = setTimeout(() => {
3049
3266
  if (!exited) child.kill("SIGKILL");
3050
- }, KILL_GRACE_MS);
3267
+ }, KILL_GRACE_MS$1);
3051
3268
  };
3052
3269
  const finish = (exitCode) => {
3053
3270
  if (resolved) return;
@@ -3206,6 +3423,164 @@ async function startJobs(options) {
3206
3423
  };
3207
3424
  }
3208
3425
  //#endregion
3426
+ //#region src/core/specification/website/serve.adapter.ts
3427
+ /** Grace period between SIGTERM and the SIGKILL escalation. */
3428
+ const KILL_GRACE_MS = 2e3;
3429
+ const DEFAULT_READY_TIMEOUT = 3e4;
3430
+ const READY_POLL_INTERVAL_MS = 250;
3431
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3432
+ /** Ask the OS for a free TCP port. */
3433
+ function findFreePort() {
3434
+ return new Promise((resolve, reject) => {
3435
+ const server = createServer();
3436
+ server.listen(0, () => {
3437
+ const address = server.address();
3438
+ if (address === null || typeof address === "string") {
3439
+ server.close();
3440
+ reject(/* @__PURE__ */ new Error("could not determine a free port"));
3441
+ return;
3442
+ }
3443
+ const { port } = address;
3444
+ server.close(() => resolve(port));
3445
+ });
3446
+ server.on("error", reject);
3447
+ });
3448
+ }
3449
+ /**
3450
+ * Starts the site under test as a child process and waits until it answers
3451
+ * HTTP. Any HTTP status counts as ready — a 404 on the ready path is still a
3452
+ * listening server; only connection failures keep the poll going.
3453
+ *
3454
+ * The child runs through the shell in its own process group (POSIX) so
3455
+ * `stop()` can terminate the whole tree — a `next start` grandchild must not
3456
+ * outlive the spec run (same escalation as the cli exec adapter: SIGTERM,
3457
+ * then SIGKILL after a 2 s grace).
3458
+ */
3459
+ var ServeAdapter = class {
3460
+ child = null;
3461
+ options;
3462
+ output = "";
3463
+ root;
3464
+ constructor(options, root) {
3465
+ this.options = options;
3466
+ this.root = root;
3467
+ }
3468
+ /** Start the server and resolve with its base URL once it answers HTTP. */
3469
+ async start() {
3470
+ const port = this.options.port ?? await findFreePort();
3471
+ const baseUrl = `http://127.0.0.1:${port}`;
3472
+ const readyUrl = `${baseUrl}${this.options.ready ?? "/"}`;
3473
+ const timeout = this.options.timeout ?? DEFAULT_READY_TIMEOUT;
3474
+ this.child = spawn(this.options.command, [], {
3475
+ cwd: this.root,
3476
+ detached: process.platform !== "win32",
3477
+ env: {
3478
+ ...process.env,
3479
+ PORT: String(port)
3480
+ },
3481
+ shell: true,
3482
+ stdio: [
3483
+ "ignore",
3484
+ "pipe",
3485
+ "pipe"
3486
+ ]
3487
+ });
3488
+ this.child.stdout?.on("data", (data) => {
3489
+ this.output += data.toString();
3490
+ });
3491
+ this.child.stderr?.on("data", (data) => {
3492
+ this.output += data.toString();
3493
+ });
3494
+ let exited = false;
3495
+ this.child.on("exit", () => {
3496
+ exited = true;
3497
+ });
3498
+ const deadline = Date.now() + timeout;
3499
+ for (;;) {
3500
+ if (exited) throw new Error(`specification.website(): server command exited before answering HTTP.\nCommand: ${this.options.command}\nOutput:\n${this.output}`);
3501
+ try {
3502
+ await fetch(readyUrl, { redirect: "manual" });
3503
+ return baseUrl;
3504
+ } catch {
3505
+ if (Date.now() >= deadline) {
3506
+ await this.stop();
3507
+ throw new Error(`specification.website(): server did not answer on ${readyUrl} within ${timeout}ms.\nCommand: ${this.options.command}\nOutput:\n${this.output}`);
3508
+ }
3509
+ await delay(READY_POLL_INTERVAL_MS);
3510
+ }
3511
+ }
3512
+ }
3513
+ /** Terminate the server process group (idempotent). */
3514
+ async stop() {
3515
+ const child = this.child;
3516
+ if (!child) return;
3517
+ this.child = null;
3518
+ await new Promise((resolve) => {
3519
+ let exited = false;
3520
+ child.on("exit", () => {
3521
+ exited = true;
3522
+ resolve();
3523
+ });
3524
+ if (child.exitCode !== null || child.signalCode !== null) {
3525
+ resolve();
3526
+ return;
3527
+ }
3528
+ this.kill(child, "SIGTERM");
3529
+ setTimeout(() => {
3530
+ if (!exited) {
3531
+ this.kill(child, "SIGKILL");
3532
+ resolve();
3533
+ }
3534
+ }, KILL_GRACE_MS).unref?.();
3535
+ });
3536
+ }
3537
+ /** Group-aware kill: signal the process group, fall back to the child. */
3538
+ kill(child, signal) {
3539
+ if (child.pid !== void 0 && process.platform !== "win32") try {
3540
+ process.kill(-child.pid, signal);
3541
+ return;
3542
+ } catch {}
3543
+ child.kill(signal);
3544
+ }
3545
+ };
3546
+ //#endregion
3547
+ //#region src/core/specification/website/start-website.ts
3548
+ async function startWebsite(options) {
3549
+ const callerDir = getCallerDir();
3550
+ await registerMatchers();
3551
+ let serve = null;
3552
+ let baseUrl;
3553
+ if (options.server) {
3554
+ const root = resolveRoot(options.root, callerDir);
3555
+ serve = new ServeAdapter(options.server, root);
3556
+ baseUrl = await serve.start();
3557
+ } else baseUrl = options.url.replace(/\/$/, "");
3558
+ let browser = null;
3559
+ const getBrowser = async () => {
3560
+ if (!browser) {
3561
+ const { PlaywrightAdapter } = await import("./playwright.adapter.js");
3562
+ browser = new PlaywrightAdapter();
3563
+ }
3564
+ return browser;
3565
+ };
3566
+ const config = {
3567
+ baseUrl,
3568
+ browser: getBrowser,
3569
+ external: options.external ?? (options.server ? "block" : "allow")
3570
+ };
3571
+ return {
3572
+ cleanup: async () => {
3573
+ if (browser) {
3574
+ await browser.close();
3575
+ browser = null;
3576
+ }
3577
+ if (serve) await serve.stop();
3578
+ },
3579
+ url: baseUrl,
3580
+ website: createWebsiteFacet(config)
3581
+ };
3582
+ }
3583
+ //#endregion
3209
3584
  //#region src/core/specification/shared/specification.ts
3210
3585
  /**
3211
3586
  * The three specification constructors (CONVENTIONS A2) — created in a
@@ -3250,9 +3625,55 @@ const specification = {
3250
3625
  * Test background jobs. Jobs run in-process by definition — no HTTP
3251
3626
  * server, no mode. `.trigger(name)` is the terminal action.
3252
3627
  */
3253
- jobs: startJobs
3628
+ jobs: startJobs,
3629
+ /**
3630
+ * Test a deployed or locally-served website. `server` starts the site
3631
+ * (a shell command receiving `PORT`); `url` targets a running one.
3632
+ * `.visit(path)` renders the page in a single shared browser instance;
3633
+ * `.fetch(path)` performs one raw HTTP exchange (redirects never
3634
+ * followed).
3635
+ */
3636
+ website: startWebsite
3254
3637
  };
3255
3638
  //#endregion
3639
+ //#region src/core/specification/website/elements.ts
3640
+ /**
3641
+ * The element vocabulary — user-facing descriptors for visit scenarios.
3642
+ * Reads like English (`click(link('Articles'))`), translates to
3643
+ * accessibility-first locators in the browser integration. CSS/XPath is
3644
+ * deliberately not expressible; `testId()` is the single escape hatch.
3645
+ */
3646
+ /** A button (or element with the button role), by accessible name. */
3647
+ const button = (name) => ({
3648
+ kind: "button",
3649
+ name
3650
+ });
3651
+ /** A form field, by label. */
3652
+ const field = (label) => ({
3653
+ kind: "field",
3654
+ name: label
3655
+ });
3656
+ /** A heading, by accessible name. */
3657
+ const heading = (name) => ({
3658
+ kind: "heading",
3659
+ name
3660
+ });
3661
+ /** A link, by accessible name. */
3662
+ const link = (name) => ({
3663
+ kind: "link",
3664
+ name
3665
+ });
3666
+ /** An element containing the given text. */
3667
+ const content = (value) => ({
3668
+ kind: "text",
3669
+ name: value
3670
+ });
3671
+ /** The escape hatch: an element by `data-testid`. Prefer user-facing elements. */
3672
+ const testId = (id) => ({
3673
+ kind: "testId",
3674
+ name: id
3675
+ });
3676
+ //#endregion
3256
3677
  //#region src/integrations/sqlite/sqlite.ts
3257
3678
  var SqliteHandle = class {
3258
3679
  type = "sqlite";
@@ -3825,4 +4246,4 @@ registerComposeServiceFactory("postgres", (service) => postgres({
3825
4246
  }));
3826
4247
  registerComposeServiceFactory("redis", (service) => redis({ composeService: service.name }));
3827
4248
  //#endregion
3828
- export { BaseResult, CliResult, ContainerAccessor, DirectoryAccessor, FilesystemAccessor, HttpResult, JsonAccessor, Matcher, Orchestrator, ResponseAccessor, TableAccessor, TextAccessor, anthropic, defineContract, findContainersByLabel, http, inspectContainer, match, mockOf, mockOfDate, openai, postgres, redis, removeContainers, specification, sqlite, text };
4249
+ export { BaseResult, CliResult, ContainerAccessor, DirectoryAccessor, FetchResult, FilesystemAccessor, HttpResult, JsonAccessor, Matcher, Orchestrator, PageResult, ResponseAccessor, TableAccessor, TextAccessor, anthropic, button, content, defineContract, field, findContainersByLabel, heading, http, inspectContainer, link, match, mockOf, mockOfDate, openai, postgres, redis, removeContainers, specification, sqlite, testId, text };