@jterrazz/test 9.1.0 → 9.2.1

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/oxlint.js CHANGED
@@ -162,7 +162,7 @@ const RULE_DOCS = {
162
162
  },
163
163
  "a2-known-constructors": {
164
164
  channel: "statique",
165
- convention: "Trois constructeurs et seulement trois : `specification.api()`, `specification.jobs()`, `specification.cli(bin)` ; tout autre membre (`.app`, `.http`, `.stack`…) est une erreur.",
165
+ convention: "Quatre constructeurs et seulement quatre : `specification.api()`, `specification.jobs()`, `specification.cli(bin)`, `specification.website()` ; tout autre membre (`.app`, `.http`, `.stack`…) est une erreur.",
166
166
  family: "A",
167
167
  id: "A2",
168
168
  rationale: "Une surface fermée empêche l’invention de constructeurs parallèles et garde l’API mémorisable."
@@ -432,6 +432,22 @@ const RULE_DOCS = {
432
432
  family: "J",
433
433
  id: "J5",
434
434
  rationale: "Un titre est un fragment de prose, pas une phrase — la casse minuscule le garde fragmentaire ; minusculiser un symbole nommé le mal-orthographierait."
435
+ },
436
+ "w1-scenario-pure": {
437
+ channel: "statique",
438
+ 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é.",
439
+ facet: "website",
440
+ family: "W",
441
+ id: "W1",
442
+ rationale: "Séparer l’interaction de l’assertion garde la grammaire setup → action → résultat intacte et les scénarios rejouables."
443
+ },
444
+ "w2w-user-facing-elements": {
445
+ channel: "statique",
446
+ 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.",
447
+ facet: "website",
448
+ family: "W",
449
+ id: "W2",
450
+ rationale: "Tester ce que l’utilisateur voit (rôles, labels) rend les specs robustes aux refontes DOM ; un test-id contourne cette garantie."
435
451
  }
436
452
  };
437
453
  /**
@@ -736,16 +752,18 @@ const a10DuplicateBinding = {
736
752
  };
737
753
  //#endregion
738
754
  //#region src/lint/rules/a2-known-constructors.ts
739
- /** The three constructors, and only three (A2 — see docs/10-linting.md). */
755
+ /** The four constructors, and only four (A2 — see docs/10-linting.md). */
740
756
  const KNOWN_CONSTRUCTORS = /* @__PURE__ */ new Set([
741
757
  "api",
742
758
  "cli",
743
- "jobs"
759
+ "jobs",
760
+ "website"
744
761
  ]);
745
762
  /**
746
- * CONVENTIONS A2 — `specification.api()`, `specification.jobs()` and
747
- * `specification.cli()` are the only members. Any other access
748
- * (`specification.app`, `.http`, `.stack`, …) is flagged at the member site.
763
+ * CONVENTIONS A2 — `specification.api()`, `specification.jobs()`,
764
+ * `specification.cli()` and `specification.website()` are the only members.
765
+ * Any other access (`specification.app`, `.http`, `.stack`, …) is flagged at
766
+ * the member site.
749
767
  */
750
768
  const a2KnownConstructors = {
751
769
  create(context) {
@@ -762,7 +780,7 @@ const a2KnownConstructors = {
762
780
  },
763
781
  meta: {
764
782
  docs: RULE_DOCS["a2-known-constructors"],
765
- messages: { unknownConstructor: "specification.{{member}} does not exist — the only constructors are specification.api(), specification.jobs() and specification.cli() (A2 — see docs/10-linting.md)." },
783
+ 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)." },
766
784
  type: "problem"
767
785
  }
768
786
  };
@@ -2404,6 +2422,7 @@ const INTEGRATION_DEPS = {
2404
2422
  hono: ["hono", "@hono/node-server"],
2405
2423
  msw: ["msw"],
2406
2424
  openai: ["openai"],
2425
+ playwright: ["playwright"],
2407
2426
  postgres: ["pg"],
2408
2427
  redis: ["redis"],
2409
2428
  sqlite: ["better-sqlite3"],
@@ -2474,6 +2493,7 @@ const i1LayerBoundaries = {
2474
2493
  if (layer === "core") {
2475
2494
  if (target.startsWith("core/") || target.startsWith("integrations/docker/") || target.startsWith("integrations/hono/") || withoutExtension(target) === "vitest/matchers") return null;
2476
2495
  if (target.startsWith("integrations/msw/") && inside === "core/specification/shared/builder.ts") return null;
2496
+ if (target.startsWith("integrations/playwright/") && inside === "core/specification/website/start-website.ts") return null;
2477
2497
  return "crossLayer";
2478
2498
  }
2479
2499
  if (layer === "integrations") return target.startsWith("core/") || target.startsWith(`integrations/${integrationFolder}/`) ? null : "crossLayer";
@@ -2882,6 +2902,52 @@ const plugin = {
2882
2902
  messages: { uppercase: "A {{runner}}() title must start lowercase — the test name is a prose fragment, not a sentence (J5 — see docs/10-linting.md)." },
2883
2903
  type: "problem"
2884
2904
  }
2905
+ },
2906
+ "w1-scenario-pure": {
2907
+ create(context) {
2908
+ return { CallExpression(node) {
2909
+ const callee = node.callee;
2910
+ if (!callee || memberPropertyName(callee) !== "visit") return;
2911
+ const scenario = node.arguments?.[1];
2912
+ if (scenario?.type !== "ArrowFunctionExpression" && scenario?.type !== "FunctionExpression") return;
2913
+ walk(scenario, (inner) => {
2914
+ if (inner.type !== "CallExpression") return;
2915
+ const innerCallee = inner.callee;
2916
+ if (innerCallee?.type === "Identifier" && innerCallee.name === "expect") context.report({
2917
+ messageId: "expectInScenario",
2918
+ node: inner
2919
+ });
2920
+ });
2921
+ } };
2922
+ },
2923
+ meta: {
2924
+ docs: RULE_DOCS["w1-scenario-pure"],
2925
+ 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)." },
2926
+ type: "problem"
2927
+ }
2928
+ },
2929
+ "w2w-user-facing-elements": {
2930
+ create(context) {
2931
+ return { CallExpression(node) {
2932
+ const callee = node.callee;
2933
+ if (!callee || memberPropertyName(callee) !== "visit") return;
2934
+ const scenario = node.arguments?.[1];
2935
+ if (scenario?.type !== "ArrowFunctionExpression" && scenario?.type !== "FunctionExpression") return;
2936
+ walk(scenario, (inner) => {
2937
+ if (inner.type !== "CallExpression") return;
2938
+ const innerCallee = inner.callee;
2939
+ if (innerCallee?.type === "Identifier" && innerCallee.name === "testId") context.report({
2940
+ messageId: "testIdElement",
2941
+ node: inner
2942
+ });
2943
+ });
2944
+ } };
2945
+ },
2946
+ meta: {
2947
+ docs: RULE_DOCS["w2w-user-facing-elements"],
2948
+ 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)." },
2949
+ type: "suggestion"
2950
+ }
2885
2951
  }
2886
2952
  }
2887
2953
  };
@@ -0,0 +1,135 @@
1
+ import { mkdtempSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ //#region src/integrations/playwright/playwright.adapter.ts
5
+ /** Translate a user-facing element descriptor into a playwright locator. */
6
+ function locate(page, element) {
7
+ switch (element.kind) {
8
+ case "button": return page.getByRole("button", { name: element.name });
9
+ case "field": return page.getByLabel(element.name);
10
+ case "heading": return page.getByRole("heading", { name: element.name });
11
+ case "link": return page.getByRole("link", { name: element.name });
12
+ case "testId": return page.getByTestId(element.name);
13
+ case "text": return page.getByText(element.name);
14
+ }
15
+ }
16
+ /** The visitor implementation — every action auto-waits via playwright actionability. */
17
+ function createVisitor(page, baseUrl) {
18
+ return {
19
+ check: (element) => locate(page, element).first().check(),
20
+ click: (element) => locate(page, element).first().click(),
21
+ fill: (element, value) => locate(page, element).first().fill(value),
22
+ goto: async (path) => {
23
+ await page.goto(`${baseUrl}${path}`, { waitUntil: "load" });
24
+ },
25
+ hover: (element) => locate(page, element).first().hover(),
26
+ press: (key) => page.keyboard.press(key),
27
+ see: (element) => locate(page, element).first().waitFor({ state: "visible" }),
28
+ select: async (element, option) => {
29
+ await locate(page, element).first().selectOption(option);
30
+ }
31
+ };
32
+ }
33
+ /**
34
+ * Browser adapter backed by playwright chromium.
35
+ *
36
+ * ONE browser process per adapter (= per runner = per vitest worker),
37
+ * launched lazily on the first `open()`. Each visit gets a fresh
38
+ * `BrowserContext` — isolation without paying a browser launch per spec.
39
+ *
40
+ * Playwright is an optional peer dependency: it is only imported here, and
41
+ * this module is only loaded when a spec calls `.visit()`.
42
+ */
43
+ var PlaywrightAdapter = class {
44
+ browser = null;
45
+ async close() {
46
+ if (this.browser) {
47
+ await this.browser.close();
48
+ this.browser = null;
49
+ }
50
+ }
51
+ async open(url, options) {
52
+ const context = await (await this.launch()).newContext({ extraHTTPHeaders: options.headers });
53
+ if (options.external === "block") {
54
+ const origin = new URL(options.baseUrl).origin;
55
+ await context.route("**/*", (route) => {
56
+ if (new URL(route.request().url()).origin === origin) route.continue();
57
+ else route.abort();
58
+ });
59
+ }
60
+ try {
61
+ const page = await context.newPage();
62
+ const consoleMessages = [];
63
+ page.on("console", (message) => {
64
+ consoleMessages.push({
65
+ text: message.text(),
66
+ type: message.type()
67
+ });
68
+ });
69
+ const response = await page.goto(url, { waitUntil: "load" });
70
+ if (options.scenario) try {
71
+ await options.scenario(createVisitor(page, options.baseUrl));
72
+ } catch (error) {
73
+ const evidence = await this.captureEvidence(page);
74
+ throw new Error(`visit scenario failed: ${error instanceof Error ? error.message : String(error)}${evidence ? `\nEvidence: ${evidence}` : ""}`, { cause: error });
75
+ }
76
+ const extraction = await page.evaluate(() => {
77
+ const links = [...document.querySelectorAll("link")].map((link) => ({
78
+ href: link.href,
79
+ hreflang: link.hreflang || void 0,
80
+ rel: link.rel,
81
+ type: link.type || void 0
82
+ }));
83
+ const metas = [...document.querySelectorAll("meta")].map((meta) => ({
84
+ content: meta.content,
85
+ name: meta.name || void 0,
86
+ property: meta.getAttribute("property") ?? void 0
87
+ }));
88
+ const jsonLdBlocks = [...document.querySelectorAll("script[type=\"application/ld+json\"]")].map((script) => script.textContent ?? "");
89
+ return {
90
+ html: document.documentElement.outerHTML,
91
+ jsonLdBlocks,
92
+ links,
93
+ metas,
94
+ text: document.body?.innerText ?? "",
95
+ title: document.title
96
+ };
97
+ });
98
+ return {
99
+ consoleMessages,
100
+ status: response?.status() ?? 0,
101
+ url: page.url(),
102
+ ...extraction
103
+ };
104
+ } finally {
105
+ await context.close();
106
+ }
107
+ }
108
+ /** Screenshot the failing state into a temp file; never masks the original error. */
109
+ async captureEvidence(page) {
110
+ try {
111
+ const path = resolve(mkdtempSync(resolve(tmpdir(), "spec-website-")), "failure.png");
112
+ await page.screenshot({
113
+ fullPage: true,
114
+ path
115
+ });
116
+ return path;
117
+ } catch {
118
+ return null;
119
+ }
120
+ }
121
+ /** Launch the shared chromium instance (once), with an actionable error when playwright is absent. */
122
+ async launch() {
123
+ if (this.browser) return this.browser;
124
+ let chromium;
125
+ try {
126
+ ({chromium} = await import("playwright"));
127
+ } catch {
128
+ throw new Error(".visit() requires playwright (optional peer dependency): npm install -D playwright && npx playwright install chromium");
129
+ }
130
+ this.browser = await chromium.launch();
131
+ return this.browser;
132
+ }
133
+ };
134
+ //#endregion
135
+ export { PlaywrightAdapter };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jterrazz/test",
3
- "version": "9.1.0",
3
+ "version": "9.2.1",
4
4
  "description": "Declarative testing framework for HTTP APIs, CLIs, and background jobs — one entry point, real infrastructure, golden-file assertions, plus an oxlint convention plugin.",
5
5
  "keywords": [
6
6
  "api-testing",
@@ -49,7 +49,8 @@
49
49
  "docs": "npm run build && node dist/catalog.js && typescript docs",
50
50
  "lint": "typescript check && node dist/checker.js specs",
51
51
  "lint:fix": "typescript fix",
52
- "test": "vitest --run"
52
+ "test": "vitest --run",
53
+ "pretest": "playwright install chromium"
53
54
  },
54
55
  "dependencies": {
55
56
  "better-sqlite3": "^12.11.1",
@@ -69,12 +70,19 @@
69
70
  "@types/pg": "^8.20.0",
70
71
  "hono": "^4.12.30",
71
72
  "oxlint": "^1.74.0",
73
+ "playwright": "^1.49.0",
72
74
  "tsdown": "^0.22.7",
73
75
  "vitest": "^4.1.10"
74
76
  },
75
77
  "peerDependencies": {
78
+ "playwright": "^1.49.0",
76
79
  "vitest": "^4.1.10"
77
80
  },
81
+ "peerDependenciesMeta": {
82
+ "playwright": {
83
+ "optional": true
84
+ }
85
+ },
78
86
  "engines": {
79
87
  "node": ">=20"
80
88
  }