@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/README.md +77 -24
- package/dist/index.d.ts +314 -3
- package/dist/index.js +429 -8
- package/dist/oxlint.cjs +73 -7
- package/dist/oxlint.d.cts +5 -0
- package/dist/oxlint.d.ts +5 -0
- package/dist/oxlint.js +73 -7
- package/dist/playwright.adapter.js +135 -0
- package/package.json +10 -2
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: "
|
|
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
|
|
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()
|
|
747
|
-
* `specification.cli()` are the only members.
|
|
748
|
-
* (`specification.app`, `.http`, `.stack`, …) is flagged at
|
|
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.
|
|
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
|
|
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
|
}
|