@jterrazz/test 9.1.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 };
package/dist/oxlint.cjs CHANGED
@@ -166,7 +166,7 @@ const RULE_DOCS = {
166
166
  },
167
167
  "a2-known-constructors": {
168
168
  channel: "statique",
169
- convention: "Trois constructeurs et seulement trois : `specification.api()`, `specification.jobs()`, `specification.cli(bin)` ; tout autre membre (`.app`, `.http`, `.stack`…) est une erreur.",
169
+ convention: "Quatre constructeurs et seulement quatre : `specification.api()`, `specification.jobs()`, `specification.cli(bin)`, `specification.website()` ; tout autre membre (`.app`, `.http`, `.stack`…) est une erreur.",
170
170
  family: "A",
171
171
  id: "A2",
172
172
  rationale: "Une surface fermée empêche l’invention de constructeurs parallèles et garde l’API mémorisable."
@@ -436,6 +436,22 @@ const RULE_DOCS = {
436
436
  family: "J",
437
437
  id: "J5",
438
438
  rationale: "Un titre est un fragment de prose, pas une phrase — la casse minuscule le garde fragmentaire ; minusculiser un symbole nommé le mal-orthographierait."
439
+ },
440
+ "w1-scenario-pure": {
441
+ channel: "statique",
442
+ convention: "Un scénario de visite est le When : le visiteur agit, la capture reflète l’état final ; aucun `expect()` dans le callback — les assertions vivent dans le Then, sur le résultat retourné.",
443
+ facet: "website",
444
+ family: "W",
445
+ id: "W1",
446
+ rationale: "Séparer l’interaction de l’assertion garde la grammaire setup → action → résultat intacte et les scénarios rejouables."
447
+ },
448
+ "w2w-user-facing-elements": {
449
+ channel: "statique",
450
+ convention: "Les éléments d’un scénario sont user-facing (`button`, `link`, `field`, `heading`, `content`) ; `testId()` est l’unique échappatoire et déclenche un avertissement.",
451
+ facet: "website",
452
+ family: "W",
453
+ id: "W2",
454
+ rationale: "Tester ce que l’utilisateur voit (rôles, labels) rend les specs robustes aux refontes DOM ; un test-id contourne cette garantie."
439
455
  }
440
456
  };
441
457
  /**
@@ -740,16 +756,18 @@ const a10DuplicateBinding = {
740
756
  };
741
757
  //#endregion
742
758
  //#region src/lint/rules/a2-known-constructors.ts
743
- /** The three constructors, and only three (A2 — see docs/10-linting.md). */
759
+ /** The four constructors, and only four (A2 — see docs/10-linting.md). */
744
760
  const KNOWN_CONSTRUCTORS = /* @__PURE__ */ new Set([
745
761
  "api",
746
762
  "cli",
747
- "jobs"
763
+ "jobs",
764
+ "website"
748
765
  ]);
749
766
  /**
750
- * CONVENTIONS A2 — `specification.api()`, `specification.jobs()` and
751
- * `specification.cli()` are the only members. Any other access
752
- * (`specification.app`, `.http`, `.stack`, …) is flagged at the member site.
767
+ * CONVENTIONS A2 — `specification.api()`, `specification.jobs()`,
768
+ * `specification.cli()` and `specification.website()` are the only members.
769
+ * Any other access (`specification.app`, `.http`, `.stack`, …) is flagged at
770
+ * the member site.
753
771
  */
754
772
  const a2KnownConstructors = {
755
773
  create(context) {
@@ -766,7 +784,7 @@ const a2KnownConstructors = {
766
784
  },
767
785
  meta: {
768
786
  docs: RULE_DOCS["a2-known-constructors"],
769
- messages: { unknownConstructor: "specification.{{member}} does not exist — the only constructors are specification.api(), specification.jobs() and specification.cli() (A2 — see docs/10-linting.md)." },
787
+ messages: { unknownConstructor: "specification.{{member}} does not exist — the only constructors are specification.api(), specification.jobs(), specification.cli() and specification.website() (A2 — see docs/10-linting.md)." },
770
788
  type: "problem"
771
789
  }
772
790
  };
@@ -2408,6 +2426,7 @@ const INTEGRATION_DEPS = {
2408
2426
  hono: ["hono", "@hono/node-server"],
2409
2427
  msw: ["msw"],
2410
2428
  openai: ["openai"],
2429
+ playwright: ["playwright"],
2411
2430
  postgres: ["pg"],
2412
2431
  redis: ["redis"],
2413
2432
  sqlite: ["better-sqlite3"],
@@ -2478,6 +2497,7 @@ const i1LayerBoundaries = {
2478
2497
  if (layer === "core") {
2479
2498
  if (target.startsWith("core/") || target.startsWith("integrations/docker/") || target.startsWith("integrations/hono/") || withoutExtension(target) === "vitest/matchers") return null;
2480
2499
  if (target.startsWith("integrations/msw/") && inside === "core/specification/shared/builder.ts") return null;
2500
+ if (target.startsWith("integrations/playwright/") && inside === "core/specification/website/start-website.ts") return null;
2481
2501
  return "crossLayer";
2482
2502
  }
2483
2503
  if (layer === "integrations") return target.startsWith("core/") || target.startsWith(`integrations/${integrationFolder}/`) ? null : "crossLayer";
@@ -2886,6 +2906,52 @@ const plugin = {
2886
2906
  messages: { uppercase: "A {{runner}}() title must start lowercase — the test name is a prose fragment, not a sentence (J5 — see docs/10-linting.md)." },
2887
2907
  type: "problem"
2888
2908
  }
2909
+ },
2910
+ "w1-scenario-pure": {
2911
+ create(context) {
2912
+ return { CallExpression(node) {
2913
+ const callee = node.callee;
2914
+ if (!callee || memberPropertyName(callee) !== "visit") return;
2915
+ const scenario = node.arguments?.[1];
2916
+ if (scenario?.type !== "ArrowFunctionExpression" && scenario?.type !== "FunctionExpression") return;
2917
+ walk(scenario, (inner) => {
2918
+ if (inner.type !== "CallExpression") return;
2919
+ const innerCallee = inner.callee;
2920
+ if (innerCallee?.type === "Identifier" && innerCallee.name === "expect") context.report({
2921
+ messageId: "expectInScenario",
2922
+ node: inner
2923
+ });
2924
+ });
2925
+ } };
2926
+ },
2927
+ meta: {
2928
+ docs: RULE_DOCS["w1-scenario-pure"],
2929
+ messages: { expectInScenario: "No expect() inside a visit scenario — the scenario is the When; assert the final state on the returned result in the Then (W1 — see docs/10-linting.md)." },
2930
+ type: "problem"
2931
+ }
2932
+ },
2933
+ "w2w-user-facing-elements": {
2934
+ create(context) {
2935
+ return { CallExpression(node) {
2936
+ const callee = node.callee;
2937
+ if (!callee || memberPropertyName(callee) !== "visit") return;
2938
+ const scenario = node.arguments?.[1];
2939
+ if (scenario?.type !== "ArrowFunctionExpression" && scenario?.type !== "FunctionExpression") return;
2940
+ walk(scenario, (inner) => {
2941
+ if (inner.type !== "CallExpression") return;
2942
+ const innerCallee = inner.callee;
2943
+ if (innerCallee?.type === "Identifier" && innerCallee.name === "testId") context.report({
2944
+ messageId: "testIdElement",
2945
+ node: inner
2946
+ });
2947
+ });
2948
+ } };
2949
+ },
2950
+ meta: {
2951
+ docs: RULE_DOCS["w2w-user-facing-elements"],
2952
+ messages: { testIdElement: "Prefer a user-facing element (button, link, field, heading, content) over testId() — the escape hatch hides what the user actually sees (W2 — see docs/10-linting.md)." },
2953
+ type: "suggestion"
2954
+ }
2889
2955
  }
2890
2956
  }
2891
2957
  };
package/dist/oxlint.d.cts CHANGED
@@ -61,6 +61,11 @@ type RuleDoc = {
61
61
  channel: 'checker' | 'process' | 'runtime' | 'statique';
62
62
  /** The French normative sentence (the constitution's per-rule text, moved here). */
63
63
  convention: string;
64
+ /**
65
+ * The specification facet the rule guards — segments the catalogue like
66
+ * the constructors segment the API. `'shared'` for cross-facet rules.
67
+ */
68
+ facet?: 'api' | 'cli' | 'jobs' | 'shared' | 'website';
64
69
  /** Convention family letter, e.g. `'A'`. */
65
70
  family: string;
66
71
  /** Convention code, e.g. `'A1'`. */
package/dist/oxlint.d.ts CHANGED
@@ -61,6 +61,11 @@ type RuleDoc = {
61
61
  channel: 'checker' | 'process' | 'runtime' | 'statique';
62
62
  /** The French normative sentence (the constitution's per-rule text, moved here). */
63
63
  convention: string;
64
+ /**
65
+ * The specification facet the rule guards — segments the catalogue like
66
+ * the constructors segment the API. `'shared'` for cross-facet rules.
67
+ */
68
+ facet?: 'api' | 'cli' | 'jobs' | 'shared' | 'website';
64
69
  /** Convention family letter, e.g. `'A'`. */
65
70
  family: string;
66
71
  /** Convention code, e.g. `'A1'`. */