@lariocpt/flawless-react 0.0.0-stage → 0.3.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/.agents/skills/flawless-a11y/SKILL.md +113 -0
- package/.agents/skills/flawless-api/SKILL.md +159 -0
- package/.agents/skills/flawless-api-fixtures/SKILL.md +144 -0
- package/.agents/skills/flawless-e2e/SKILL.md +155 -0
- package/.agents/skills/flawless-expo/SKILL.md +283 -0
- package/.agents/skills/flawless-findings/SKILL.md +116 -0
- package/.agents/skills/flawless-load/SKILL.md +112 -0
- package/.agents/skills/flawless-nextjs/SKILL.md +198 -0
- package/.agents/skills/flawless-perf-harness/SKILL.md +158 -0
- package/.agents/skills/flawless-perf-static/SKILL.md +131 -0
- package/.agents/skills/flawless-react/SKILL.md +403 -0
- package/.agents/skills/flawless-react-rules/SKILL.md +96 -0
- package/.agents/skills/flawless-regen/SKILL.md +135 -0
- package/.agents/skills/flawless-unit-browser/SKILL.md +178 -0
- package/LICENSE +21 -0
- package/README.md +608 -2
- package/bin/flawless-react +4 -0
- package/package.json +67 -4
- package/scripts/postinstall.d.mts +46 -0
- package/scripts/postinstall.mjs +127 -0
- package/src/analyse/authShims.ts +220 -0
- package/src/analyse/bootability.ts +257 -0
- package/src/analyse/childBridge.ts +302 -0
- package/src/analyse/childrenIntoSlot.ts +66 -0
- package/src/analyse/compoundFamily.ts +100 -0
- package/src/analyse/dataExports.ts +200 -0
- package/src/analyse/extractChildAttrs.ts +112 -0
- package/src/analyse/extractDataDeps.ts +857 -0
- package/src/analyse/extractDisclosures.ts +186 -0
- package/src/analyse/extractFetchCalls.ts +81 -0
- package/src/analyse/extractFlows.ts +239 -0
- package/src/analyse/extractGuards.ts +270 -0
- package/src/analyse/extractHooks.ts +237 -0
- package/src/analyse/extractInteractions.ts +1421 -0
- package/src/analyse/extractLogging.ts +45 -0
- package/src/analyse/extractNativeA11y.ts +138 -0
- package/src/analyse/extractPredicates.ts +784 -0
- package/src/analyse/extractPropUsage.ts +162 -0
- package/src/analyse/extractProps.ts +1212 -0
- package/src/analyse/extractRenderMode.ts +165 -0
- package/src/analyse/extractRootTag.ts +117 -0
- package/src/analyse/extractState.ts +361 -0
- package/src/analyse/extractStaticText.ts +212 -0
- package/src/analyse/extractVariants.ts +186 -0
- package/src/analyse/harvestApiEndpoints.ts +284 -0
- package/src/analyse/hostProps.ts +185 -0
- package/src/analyse/hostTag.ts +72 -0
- package/src/analyse/htmlContentModel.ts +94 -0
- package/src/analyse/index.ts +744 -0
- package/src/analyse/mountPathCalls.ts +442 -0
- package/src/analyse/nativeContentModel.ts +129 -0
- package/src/analyse/nativeDialect.ts +83 -0
- package/src/analyse/nativeProps.ts +339 -0
- package/src/analyse/ownReturns.ts +31 -0
- package/src/analyse/parseFile.ts +919 -0
- package/src/analyse/pos.ts +8 -0
- package/src/analyse/providerWraps.ts +1977 -0
- package/src/analyse/pureModule.ts +88 -0
- package/src/analyse/reexportTable.ts +148 -0
- package/src/analyse/refs.ts +195 -0
- package/src/analyse/renderPath.ts +441 -0
- package/src/analyse/resolveDepReturns.ts +370 -0
- package/src/analyse/resolveIndexKeys.ts +98 -0
- package/src/analyse/resolveModule.ts +431 -0
- package/src/analyse/resolveTagRootTags.ts +103 -0
- package/src/analyse/serverActionReturns.ts +213 -0
- package/src/analyse/sourceCache.ts +263 -0
- package/src/analyse/spreadSiblings.ts +98 -0
- package/src/analyse/walkChildren.ts +98 -0
- package/src/analyse/zodSchema.ts +424 -0
- package/src/bin.ts +23 -0
- package/src/changed.ts +221 -0
- package/src/cli.ts +560 -0
- package/src/cliFlags.ts +281 -0
- package/src/core.ts +1229 -0
- package/src/docLine.ts +122 -0
- package/src/emitPaths.ts +51 -0
- package/src/expo/detect.ts +184 -0
- package/src/expo/refusal.ts +62 -0
- package/src/expo/routes.ts +145 -0
- package/src/index.ts +95 -0
- package/src/mcp/analysisTools.ts +720 -0
- package/src/mcp/infoTools.ts +135 -0
- package/src/mcp/protocol.ts +130 -0
- package/src/mcp/run.ts +269 -0
- package/src/mcp/runTools.ts +362 -0
- package/src/mcp/server.ts +279 -0
- package/src/mcp/spec.ts +86 -0
- package/src/mcp/support.ts +268 -0
- package/src/mcp/tools.ts +75 -0
- package/src/nextjs/apiRoutes.ts +116 -0
- package/src/nextjs/detect.ts +397 -0
- package/src/nextjs/emit.ts +106 -0
- package/src/nextjs/liveSpec.ts +79 -0
- package/src/nextjs/mswHandlers.ts +55 -0
- package/src/nextjs/providerWrapper.ts +603 -0
- package/src/nextjs/routes.ts +86 -0
- package/src/nextjs/swagger.ts +32 -0
- package/src/packageJson.ts +414 -0
- package/src/platform.ts +18 -0
- package/src/prompt.ts +132 -0
- package/src/reactCompiler.ts +165 -0
- package/src/reporters/findings.ts +156 -0
- package/src/reporters/report.ts +463 -0
- package/src/runner/runTests.ts +402 -0
- package/src/scaffold/adoptStyle.ts +181 -0
- package/src/scaffold/index.ts +253 -0
- package/src/scaffold/openapi.ts +183 -0
- package/src/scaffold/templates.ts +2379 -0
- package/src/scaffold-tests/index.ts +463 -0
- package/src/scaffold-tests/mockValues.ts +208 -0
- package/src/scaffold-tests/mocks.ts +794 -0
- package/src/scaffold-tests/nativeIndex.ts +52 -0
- package/src/scaffold-tests/nativeTemplates.ts +1528 -0
- package/src/scaffold-tests/postDispatch.ts +152 -0
- package/src/scaffold-tests/registry.ts +443 -0
- package/src/scaffold-tests/selectors.ts +493 -0
- package/src/scaffold-tests/selectorsNative.ts +273 -0
- package/src/scaffold-tests/stamp.ts +79 -0
- package/src/scaffold-tests/templates.ts +3578 -0
- package/src/skills/cli.ts +248 -0
- package/src/skills/compat.ts +166 -0
- package/src/skills/data.ts +192 -0
- package/src/skills/md.d.ts +7 -0
- package/src/skills/menu.ts +55 -0
- package/src/skills/spec.ts +227 -0
- package/src/suiteScripts.ts +110 -0
- package/src/tasks/a11yNative.ts +281 -0
- package/src/tasks/a11yScan.ts +67 -0
- package/src/tasks/biomeRunner.ts +301 -0
- package/src/tasks/biomeSweep.ts +166 -0
- package/src/tasks/e2eExpo.ts +436 -0
- package/src/tasks/e2eRoutes.ts +756 -0
- package/src/tasks/eslintReactRules.ts +67 -0
- package/src/tasks/index.ts +24 -0
- package/src/tasks/loadExpo.ts +273 -0
- package/src/tasks/loadPipes.ts +750 -0
- package/src/tasks/perfHarness.ts +145 -0
- package/src/tasks/staticPerfAnalysis.ts +510 -0
- package/src/tasks/types.ts +1 -0
- package/src/tasks/unitAndBrowser.ts +462 -0
- package/src/types.ts +1419 -0
- package/src/version.ts +12 -0
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
import { fillKind, isFillableTag } from "../analyse/htmlContentModel.ts";
|
|
2
|
+
import type { ClickHandler, FieldInfo, FormInfo, InteractionInfo, LabelSource } from "../types.ts";
|
|
3
|
+
|
|
4
|
+
const TAG_TO_ROLE: Record<string, string> = {
|
|
5
|
+
button: "button",
|
|
6
|
+
a: "link",
|
|
7
|
+
input: "textbox",
|
|
8
|
+
textarea: "textbox",
|
|
9
|
+
select: "combobox",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A Vitest/Testing-Library expression resolving to the target element, or
|
|
14
|
+
* undefined when nothing in the source identifies it.
|
|
15
|
+
*
|
|
16
|
+
* undefined is load-bearing. The old last resort was
|
|
17
|
+
* `container.querySelector("<TagName>")`, which for an uppercase component tag
|
|
18
|
+
* matches no element at all: userEvent.click(null) is a no-op, so the test
|
|
19
|
+
* PASSED while doing nothing. A skip that names the gap is worth more than a
|
|
20
|
+
* green test that never touched the DOM.
|
|
21
|
+
*/
|
|
22
|
+
export function emitVitestSelector(h: ClickHandler): string | undefined {
|
|
23
|
+
if (h.hints.testid) return `screen.getByTestId(${JSON.stringify(h.hints.testid)})`;
|
|
24
|
+
if (h.hints.ariaLabel) {
|
|
25
|
+
return `screen.getAllByLabelText(${JSON.stringify(h.hints.ariaLabel)})[0] as HTMLElement`;
|
|
26
|
+
}
|
|
27
|
+
const role = roleFor(h);
|
|
28
|
+
if (role) {
|
|
29
|
+
if (h.label.kind === "text") {
|
|
30
|
+
return `screen.getAllByRole(${JSON.stringify(role)}, { name: ${JSON.stringify(h.label.value)} })[0] as HTMLElement`;
|
|
31
|
+
}
|
|
32
|
+
if (h.label.kind === "text-partial") {
|
|
33
|
+
return `screen.getAllByRole(${JSON.stringify(role)}, { name: ${partialRegex(h.label.value)} })[0] as HTMLElement`;
|
|
34
|
+
}
|
|
35
|
+
if (h.label.kind === "prop") {
|
|
36
|
+
if (h.label.before || h.label.after) {
|
|
37
|
+
return `screen.getAllByRole(${JSON.stringify(role)}, { name: ${propNameRegex(h.label, "i")} })[0] as HTMLElement`;
|
|
38
|
+
}
|
|
39
|
+
return `screen.getAllByRole(${JSON.stringify(role)}, { name: String(DEFAULTS[${JSON.stringify(h.label.propName)}] ?? "") })[0] as HTMLElement`;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (h.hints.id) return `container.querySelector(${JSON.stringify(`#${h.hints.id}`)})!`;
|
|
43
|
+
if (h.label.kind === "text")
|
|
44
|
+
return `screen.getAllByText(${JSON.stringify(h.label.value)})[0] as HTMLElement`;
|
|
45
|
+
if (h.label.kind === "text-partial")
|
|
46
|
+
return `screen.getAllByText(${partialRegex(h.label.value)})[0] as HTMLElement`;
|
|
47
|
+
// A lowercase host tag is a real CSS selector; an uppercase component name is
|
|
48
|
+
// not, and querying it silently yields null. It still only NAMES the element
|
|
49
|
+
// when it is the only one of its kind in the render.
|
|
50
|
+
if (isHostTag(h.tag) && !h.ambiguousTag) {
|
|
51
|
+
return `container.querySelector(${JSON.stringify(h.tag)})!`;
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Why a handler could not be addressed, for the skip that replaces its test. */
|
|
57
|
+
export function describeUnlocatable(h: ClickHandler): string {
|
|
58
|
+
return h.ambiguousTag
|
|
59
|
+
? `more than one <${h.tag}> renders here and nothing distinguishes this one`
|
|
60
|
+
: `nothing in the source identifies the <${h.tag}> it is bound to`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Emit a Playwright Locator expression for the target element. */
|
|
64
|
+
export function emitPlaywrightSelector(h: ClickHandler): string | undefined {
|
|
65
|
+
if (h.hints.testid) return `page.getByTestId(${JSON.stringify(h.hints.testid)})`;
|
|
66
|
+
if (h.hints.ariaLabel) {
|
|
67
|
+
return `page.getByLabel(${JSON.stringify(h.hints.ariaLabel)})`;
|
|
68
|
+
}
|
|
69
|
+
const role = roleFor(h);
|
|
70
|
+
if (role) {
|
|
71
|
+
if (h.label.kind === "text") {
|
|
72
|
+
// exact: true for the same reason getByText needs it — Playwright's `name`
|
|
73
|
+
// is a case-insensitive SUBSTRING match by default, so { name: "Add" }
|
|
74
|
+
// also claims "Add member" and "Add building". Testing Library's
|
|
75
|
+
// equivalent defaults the other way, which is why the unit emitter says
|
|
76
|
+
// nothing here and this one has to.
|
|
77
|
+
return `page.getByRole(${JSON.stringify(role)}, { name: ${JSON.stringify(h.label.value)}, exact: true })`;
|
|
78
|
+
}
|
|
79
|
+
if (h.label.kind === "text-partial") {
|
|
80
|
+
return `page.getByRole(${JSON.stringify(role)}, { name: ${partialRegex(h.label.value)} })`;
|
|
81
|
+
}
|
|
82
|
+
if (h.label.kind === "prop") {
|
|
83
|
+
if (h.label.before || h.label.after) {
|
|
84
|
+
// A regex keeps the emitPlaywrightSelector case-sensitivity the
|
|
85
|
+
// exact: true string option had; PW ignores `exact` for regex names.
|
|
86
|
+
return `page.getByRole(${JSON.stringify(role)}, { name: ${propNameRegex(h.label)} })`;
|
|
87
|
+
}
|
|
88
|
+
return `page.getByRole(${JSON.stringify(role)}, { name: String(DEFAULTS[${JSON.stringify(h.label.propName)}] ?? ""), exact: true })`;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (h.hints.id) return `page.locator(${JSON.stringify(`#${h.hints.id}`)})`;
|
|
92
|
+
// exact: true, because Playwright's default text match is a case-insensitive
|
|
93
|
+
// SUBSTRING one. getByText("Try again") also matched the paragraph reading
|
|
94
|
+
// "We couldn't load this page. Please try again." and failed on strict mode.
|
|
95
|
+
// A `text` label is the element's whole literal copy, so exactness is what the
|
|
96
|
+
// source actually claims; `text-partial` is a fragment and keeps its regex.
|
|
97
|
+
if (h.label.kind === "text")
|
|
98
|
+
return `page.getByText(${JSON.stringify(h.label.value)}, { exact: true })`;
|
|
99
|
+
if (h.label.kind === "text-partial") return `page.getByText(${partialRegex(h.label.value)})`;
|
|
100
|
+
if (isHostTag(h.tag) && !h.ambiguousTag) return `page.locator(${JSON.stringify(h.tag)}).first()`;
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The browser-spec form of a click target: always resolved to one element.
|
|
106
|
+
*
|
|
107
|
+
* Playwright locators are strict. A control rendered inside `.map()` resolves to
|
|
108
|
+
* as many elements as there are rows, and "strict mode violation: getByText
|
|
109
|
+
* ('Remove') resolved to 2 elements" describes the list, not a defect. The perf
|
|
110
|
+
* scaffolder learned this; the browser scaffolder called the raw selector and
|
|
111
|
+
* inherited every one of those failures.
|
|
112
|
+
*/
|
|
113
|
+
export function firstOf(locator: string): string {
|
|
114
|
+
return locator.endsWith(".first()") ? locator : `${locator}.first()`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The first targetable click locator for the perf suite, or undefined to keep
|
|
119
|
+
* the scenario skipped.
|
|
120
|
+
*
|
|
121
|
+
* Always `.first()`: Playwright locators are strict, and a `.map()`-rendered
|
|
122
|
+
* control resolves to as many elements as there are rows — "strict mode
|
|
123
|
+
* violation: getByText('Remove') resolved to 2 elements" is a property of the
|
|
124
|
+
* list, not a defect in the component being profiled.
|
|
125
|
+
*/
|
|
126
|
+
export interface ClickTarget {
|
|
127
|
+
/** Locator expression, already resolved to a single element. */
|
|
128
|
+
locator: string;
|
|
129
|
+
/** Statement that opens the disclosure this target lives behind, if any. */
|
|
130
|
+
prelude?: string;
|
|
131
|
+
/** Why no target could be derived, when `locator` is absent. */
|
|
132
|
+
reason?: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function emitPlaywrightClickTarget(info: InteractionInfo | undefined): ClickTarget {
|
|
136
|
+
if (!info || info.clicks.length === 0) {
|
|
137
|
+
return { locator: "", reason: "the analyser found no click handler on this component" };
|
|
138
|
+
}
|
|
139
|
+
// A disabled control never becomes clickable, and Playwright's actionability
|
|
140
|
+
// wait runs to the test timeout before saying so.
|
|
141
|
+
const target = info.clicks.find((c) => c.event === "onClick" && c.hints.disabled !== "always");
|
|
142
|
+
if (!target) {
|
|
143
|
+
return { locator: "", reason: "every clickable target is disabled at these props" };
|
|
144
|
+
}
|
|
145
|
+
const locator = emitPlaywrightSelector(target);
|
|
146
|
+
if (!locator) return { locator: "", reason: describeUnlocatable(target) };
|
|
147
|
+
// A menu item inside a portal does not exist until its trigger opens it. The
|
|
148
|
+
// unit and browser emitters learned this; the profile one clicked into an
|
|
149
|
+
// empty page and reported the component as having no clickable target.
|
|
150
|
+
const trigger = target.gatedBy ? emitPlaywrightDisclosure(target.gatedBy) : undefined;
|
|
151
|
+
return {
|
|
152
|
+
locator: firstOf(locator),
|
|
153
|
+
// Guarded, because the trigger may not be there either. UserMenu returns
|
|
154
|
+
// null until a store hook supplies a session, so an unguarded
|
|
155
|
+
// `await trigger.click()` waited out the full 30s timeout for an element
|
|
156
|
+
// the component had decided not to render — and did it BEFORE the count
|
|
157
|
+
// check below could report the absence.
|
|
158
|
+
...(trigger
|
|
159
|
+
? {
|
|
160
|
+
prelude: `const __trigger = ${firstOf(trigger)};
|
|
161
|
+
if ((await __trigger.count()) > 0) await __trigger.click();`,
|
|
162
|
+
}
|
|
163
|
+
: {}),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Playwright form of a disclosure trigger. Shared by the browser and profile specs. */
|
|
168
|
+
export function emitPlaywrightDisclosure(label: LabelSource): string | undefined {
|
|
169
|
+
switch (label.kind) {
|
|
170
|
+
case "testid":
|
|
171
|
+
return `page.getByTestId(${JSON.stringify(label.value)})`;
|
|
172
|
+
case "aria":
|
|
173
|
+
return `page.getByLabel(${JSON.stringify(label.value)})`;
|
|
174
|
+
case "text":
|
|
175
|
+
return `page.getByText(${JSON.stringify(label.value)}, { exact: true })`;
|
|
176
|
+
default:
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Map an event attribute to the Testing-Library / user-event call for an element
|
|
183
|
+
* expression, or undefined when the event cannot be driven on that element.
|
|
184
|
+
*
|
|
185
|
+
* The undefined case is a change/input handler bound to something that holds no
|
|
186
|
+
* text. React's onChange bubbles, so `<form onChange={...}>` is idiomatic — and
|
|
187
|
+
* driving it literally emitted `page.locator("form").fill("x")`, which Playwright
|
|
188
|
+
* rejects outright. The fields inside that form are covered by the form test;
|
|
189
|
+
* describing the form itself as a text box was never true.
|
|
190
|
+
*/
|
|
191
|
+
export function emitVitestDispatch(
|
|
192
|
+
event: string,
|
|
193
|
+
elementExpr: string,
|
|
194
|
+
opts?: { typeValue?: string; tag?: string },
|
|
195
|
+
): string | undefined {
|
|
196
|
+
if ((event === "onChange" || event === "onInput") && opts?.tag && !isFillableTag(opts.tag)) {
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
switch (event) {
|
|
200
|
+
case "onClick":
|
|
201
|
+
return `await userEvent.click(${elementExpr})`;
|
|
202
|
+
case "onChange":
|
|
203
|
+
case "onInput":
|
|
204
|
+
return `await userEvent.type(${elementExpr}, ${JSON.stringify(opts?.typeValue ?? "x")})`;
|
|
205
|
+
case "onMouseDown":
|
|
206
|
+
case "onPointerDown":
|
|
207
|
+
return `await userEvent.pointer({ keys: "[MouseLeft>]", target: ${elementExpr} })`;
|
|
208
|
+
case "onKeyDown":
|
|
209
|
+
case "onKeyPress":
|
|
210
|
+
return `await userEvent.keyboard("{Enter}")`;
|
|
211
|
+
// Parenthesised: the locator expression ENDS in `as HTMLElement`, and an
|
|
212
|
+
// unparenthesised member access parses the dot against the TYPE —
|
|
213
|
+
// `el[0] as HTMLElement.focus()` is a syntax error, not a cast-then-call.
|
|
214
|
+
case "onFocus":
|
|
215
|
+
return `(${elementExpr}).focus()`;
|
|
216
|
+
case "onBlur":
|
|
217
|
+
return `(${elementExpr}).focus();(${elementExpr}).blur()`;
|
|
218
|
+
case "onSubmit":
|
|
219
|
+
return `await userEvent.click(${elementExpr})`;
|
|
220
|
+
default:
|
|
221
|
+
return `await userEvent.click(${elementExpr})`;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function emitPlaywrightDispatch(
|
|
226
|
+
event: string,
|
|
227
|
+
locatorExpr: string,
|
|
228
|
+
opts?: { typeValue?: string; tag?: string },
|
|
229
|
+
): string | undefined {
|
|
230
|
+
if ((event === "onChange" || event === "onInput") && opts?.tag && !isFillableTag(opts.tag)) {
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
switch (event) {
|
|
234
|
+
case "onClick":
|
|
235
|
+
case "onSubmit":
|
|
236
|
+
return `await ${locatorExpr}.click()`;
|
|
237
|
+
case "onChange":
|
|
238
|
+
case "onInput":
|
|
239
|
+
return `await ${locatorExpr}.fill(${JSON.stringify(opts?.typeValue ?? "x")})`;
|
|
240
|
+
case "onKeyDown":
|
|
241
|
+
case "onKeyPress":
|
|
242
|
+
return `await ${locatorExpr}.press("Enter")`;
|
|
243
|
+
case "onFocus":
|
|
244
|
+
return `await ${locatorExpr}.focus()`;
|
|
245
|
+
case "onBlur":
|
|
246
|
+
return `await ${locatorExpr}.evaluate((el) => (el as HTMLElement).blur())`;
|
|
247
|
+
default:
|
|
248
|
+
return `await ${locatorExpr}.click()`;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The DOM shape of "the button that submits this form", used when the source
|
|
254
|
+
* names a submit control but not its copy.
|
|
255
|
+
*
|
|
256
|
+
* `button:not([type])` is in there because HTML's default type for a button
|
|
257
|
+
* inside a form IS submit — the attribute's absence is the common case, not an
|
|
258
|
+
* edge one.
|
|
259
|
+
*/
|
|
260
|
+
const SUBMIT_WITHIN_FORM = "button[type=submit], button:not([type]), input[type=submit]";
|
|
261
|
+
|
|
262
|
+
/** Playwright locator for a form's submit control, or undefined when none was found. */
|
|
263
|
+
export function emitPlaywrightSubmit(form: FormInfo): string | undefined {
|
|
264
|
+
const control = form.submitControl;
|
|
265
|
+
if (!control) return undefined;
|
|
266
|
+
const byLabel = playwrightButtonByLabel(control.label);
|
|
267
|
+
if (byLabel) return firstOf(byLabel);
|
|
268
|
+
return `page.locator("form").first().locator(${JSON.stringify(SUBMIT_WITHIN_FORM)}).first()`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Testing-Library expression for a form's submit control. */
|
|
272
|
+
export function emitVitestSubmit(form: FormInfo): string | undefined {
|
|
273
|
+
const control = form.submitControl;
|
|
274
|
+
if (!control) return undefined;
|
|
275
|
+
switch (control.label.kind) {
|
|
276
|
+
case "text":
|
|
277
|
+
return `screen.getAllByRole("button", { name: ${JSON.stringify(control.label.value)} })[0] as HTMLElement`;
|
|
278
|
+
case "text-partial":
|
|
279
|
+
return `screen.getAllByRole("button", { name: ${partialRegex(control.label.value)} })[0] as HTMLElement`;
|
|
280
|
+
case "aria":
|
|
281
|
+
return `screen.getAllByLabelText(${JSON.stringify(control.label.value)})[0] as HTMLElement`;
|
|
282
|
+
case "testid":
|
|
283
|
+
return `screen.getByTestId(${JSON.stringify(control.label.value)})`;
|
|
284
|
+
default:
|
|
285
|
+
return `container.querySelector(${JSON.stringify(
|
|
286
|
+
SUBMIT_WITHIN_FORM.split(", ")
|
|
287
|
+
.map((s) => `form ${s}`)
|
|
288
|
+
.join(", "),
|
|
289
|
+
)}) as HTMLElement`;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function playwrightButtonByLabel(label: LabelSource): string | undefined {
|
|
294
|
+
switch (label.kind) {
|
|
295
|
+
case "text":
|
|
296
|
+
return `page.getByRole("button", { name: ${JSON.stringify(label.value)}, exact: true })`;
|
|
297
|
+
case "text-partial":
|
|
298
|
+
return `page.getByRole("button", { name: ${partialRegex(label.value)} })`;
|
|
299
|
+
case "aria":
|
|
300
|
+
return `page.getByLabel(${JSON.stringify(label.value)})`;
|
|
301
|
+
case "testid":
|
|
302
|
+
return `page.getByTestId(${JSON.stringify(label.value)})`;
|
|
303
|
+
default:
|
|
304
|
+
return undefined;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* A statement that puts a value into one form field, or undefined when the field
|
|
310
|
+
* is not something a user supplies.
|
|
311
|
+
*
|
|
312
|
+
* The old emitter typed into everything with a name, which produced
|
|
313
|
+
* `.fill()` against a checkbox ("Input of type checkbox cannot be filled"),
|
|
314
|
+
* against a hidden input, and against sixteen checkboxes sharing one name at
|
|
315
|
+
* once. The field's own `type` says which of those it is.
|
|
316
|
+
*/
|
|
317
|
+
export function emitPlaywrightFill(field: FieldInfo): string | undefined {
|
|
318
|
+
const kind = fillKind(field.tag, field.type);
|
|
319
|
+
if (kind === "skip") return undefined;
|
|
320
|
+
const locator = fieldLocator(field, "playwright");
|
|
321
|
+
if (!locator) return undefined;
|
|
322
|
+
const target = firstOf(locator);
|
|
323
|
+
if (kind === "toggle") return `await ${target}.check();`;
|
|
324
|
+
if (kind === "select") return `await ${target}.selectOption({ index: 1 });`;
|
|
325
|
+
return `await ${target}.fill(${JSON.stringify(sampleValue(field))});`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** The Testing-Library equivalent, including the same type-awareness. */
|
|
329
|
+
export function emitVitestFill(field: FieldInfo): string | undefined {
|
|
330
|
+
const kind = fillKind(field.tag, field.type);
|
|
331
|
+
if (kind === "skip") return undefined;
|
|
332
|
+
const locator = fieldLocator(field, "vitest");
|
|
333
|
+
if (!locator) return undefined;
|
|
334
|
+
if (kind === "toggle") return `await userEvent.click(${locator} as HTMLElement);`;
|
|
335
|
+
if (kind === "select") {
|
|
336
|
+
return `await userEvent.selectOptions(${locator} as HTMLElement, (${locator} as HTMLSelectElement).options[1]?.value ?? "");`;
|
|
337
|
+
}
|
|
338
|
+
return `await userEvent.type(${locator} as HTMLElement, ${JSON.stringify(sampleValue(field))});`;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The assertion that the value actually landed, or undefined when there is
|
|
343
|
+
* nothing to check.
|
|
344
|
+
*
|
|
345
|
+
* Without it a form test only proves that filling and clicking threw no error —
|
|
346
|
+
* true of a form whose inputs are all `readOnly`. `toHaveValue` is the cheapest
|
|
347
|
+
* statement that distinguishes the two.
|
|
348
|
+
*/
|
|
349
|
+
export function emitVitestFillAssertion(field: FieldInfo): string | undefined {
|
|
350
|
+
if (fillKind(field.tag, field.type) !== "text") return undefined;
|
|
351
|
+
const locator = fieldLocator(field, "vitest");
|
|
352
|
+
if (!locator) return undefined;
|
|
353
|
+
const value = sampleValue(field);
|
|
354
|
+
// A number input's value comes back as a number, not the string typed into it.
|
|
355
|
+
const expected = field.type === "number" ? value : JSON.stringify(value);
|
|
356
|
+
return `expect(${locator} as HTMLElement).toHaveValue(${expected});`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function sampleValue(field: FieldInfo): string {
|
|
360
|
+
switch (field.type) {
|
|
361
|
+
case "email":
|
|
362
|
+
return "user@example.com";
|
|
363
|
+
case "password":
|
|
364
|
+
return "pa55word!";
|
|
365
|
+
case "number":
|
|
366
|
+
return "42";
|
|
367
|
+
case "tel":
|
|
368
|
+
return "0123456789";
|
|
369
|
+
case "url":
|
|
370
|
+
return "https://example.com";
|
|
371
|
+
default:
|
|
372
|
+
return field.label ?? field.name ?? "value";
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function fieldLocator(field: FieldInfo, flavour: "playwright" | "vitest"): string | undefined {
|
|
377
|
+
const pw = flavour === "playwright";
|
|
378
|
+
if (field.label) {
|
|
379
|
+
return pw
|
|
380
|
+
? `page.getByLabel(${JSON.stringify(field.label)})`
|
|
381
|
+
: `screen.getAllByLabelText(${JSON.stringify(field.label)})[0]`;
|
|
382
|
+
}
|
|
383
|
+
if (field.placeholder) {
|
|
384
|
+
return pw
|
|
385
|
+
? `page.getByPlaceholder(${JSON.stringify(field.placeholder)})`
|
|
386
|
+
: `screen.getAllByPlaceholderText(${JSON.stringify(field.placeholder)})[0]`;
|
|
387
|
+
}
|
|
388
|
+
if (field.id) {
|
|
389
|
+
return pw
|
|
390
|
+
? `page.locator(${JSON.stringify(`#${field.id}`)})`
|
|
391
|
+
: `container.querySelector(${JSON.stringify(`#${field.id}`)})!`;
|
|
392
|
+
}
|
|
393
|
+
if (field.name) {
|
|
394
|
+
// Single-quoted inside the CSS so the emitted JS string needs no escaping —
|
|
395
|
+
// "[name=\"x\"]" is correct but unreadable in a spec someone has to review.
|
|
396
|
+
const css = `[name='${field.name.replace(/'/g, "\\'")}']`;
|
|
397
|
+
return pw
|
|
398
|
+
? `page.locator(${JSON.stringify(css)})`
|
|
399
|
+
: `container.querySelector(${JSON.stringify(css)})!`;
|
|
400
|
+
}
|
|
401
|
+
return undefined;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* The expression that finds a disclosure trigger so a spec can open it.
|
|
406
|
+
* undefined when the trigger has no handle worth clicking.
|
|
407
|
+
*/
|
|
408
|
+
export function emitTriggerSelector(label: LabelSource): string | undefined {
|
|
409
|
+
switch (label.kind) {
|
|
410
|
+
case "testid":
|
|
411
|
+
return `screen.getByTestId(${JSON.stringify(label.value)})`;
|
|
412
|
+
case "aria":
|
|
413
|
+
return `screen.getAllByLabelText(${JSON.stringify(label.value)})[0] as HTMLElement`;
|
|
414
|
+
case "text":
|
|
415
|
+
return `screen.getAllByText(${JSON.stringify(label.value)})[0] as HTMLElement`;
|
|
416
|
+
// The trigger's content is the fixture child itself — the spec renders
|
|
417
|
+
// <span>{CHILD_TEXT}</span> into it, and clicking that span bubbles to
|
|
418
|
+
// the trigger. Only valid where the spec declared CHILD_TEXT (callers
|
|
419
|
+
// check info.acceptsChildren).
|
|
420
|
+
case "children":
|
|
421
|
+
return `screen.getAllByText(CHILD_TEXT)[0] as HTMLElement`;
|
|
422
|
+
default:
|
|
423
|
+
return undefined;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* The awaiting form of a selector, for an element that only mounts after a
|
|
429
|
+
* trigger is clicked. Testing-Library's findBy* retries until it appears; getBy*
|
|
430
|
+
* asserts against the DOM as it stands the instant the click returns, which is
|
|
431
|
+
* before the menu has mounted its items.
|
|
432
|
+
*/
|
|
433
|
+
export function awaitSelector(selector: string): string {
|
|
434
|
+
if (/^screen\.getAllBy/.test(selector)) {
|
|
435
|
+
const inner = selector.replace(/^screen\.getAllBy/, "screen.findAllBy");
|
|
436
|
+
return `(await ${inner.replace(/\)\[0\] as HTMLElement$/, "))[0] as HTMLElement")}`;
|
|
437
|
+
}
|
|
438
|
+
if (/^screen\.getBy/.test(selector)) {
|
|
439
|
+
return `await ${selector.replace(/^screen\.getBy/, "screen.findBy")}`;
|
|
440
|
+
}
|
|
441
|
+
// A container.querySelector target is synchronous by nature; nothing to await.
|
|
442
|
+
return selector;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* A regex literal for a label we only know a fragment of. Both
|
|
447
|
+
* Testing-Library's `name` and Playwright's `name` accept a RegExp and match it
|
|
448
|
+
* against the WHOLE accessible name, so the fragment is left unanchored: it is a
|
|
449
|
+
* substring claim, which is all the source supports.
|
|
450
|
+
*/
|
|
451
|
+
function partialRegex(value: string): string {
|
|
452
|
+
return `/${value.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")}/`;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* A RegExp expression for an accessible name that is one prop interpolated into
|
|
457
|
+
* static copy: `+{step}` renders "+3", and a name query of just "3" never finds
|
|
458
|
+
* it. The static halves are escaped here; the prop value is escaped in the
|
|
459
|
+
* emitted code because it is only known at runtime. The `\s*` seams cover the
|
|
460
|
+
* accessible-name computation's and RTL's differing opinions about whether two
|
|
461
|
+
* adjacent text nodes join with a space.
|
|
462
|
+
*/
|
|
463
|
+
export function propNameRegex(label: Extract<LabelSource, { kind: "prop" }>, flags = ""): string {
|
|
464
|
+
const runtime = `String(DEFAULTS[${JSON.stringify(label.propName)}] ?? "").replace(/[.*+?^\${}()|[\\]\\\\]/g, "\\\\$&")`;
|
|
465
|
+
const parts = ['"^"'];
|
|
466
|
+
if (label.before)
|
|
467
|
+
parts.push(JSON.stringify(label.before.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")));
|
|
468
|
+
parts.push(`"\\\\s*"`, runtime, `"\\\\s*"`);
|
|
469
|
+
if (label.after) parts.push(JSON.stringify(label.after.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")));
|
|
470
|
+
parts.push(`"$"`);
|
|
471
|
+
return `new RegExp(${parts.join(" + ")}${flags ? `, ${JSON.stringify(flags)}` : ""})`;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** JSX lowercases host elements; anything capitalised is a component reference. */
|
|
475
|
+
function isHostTag(tag: string): boolean {
|
|
476
|
+
return /^[a-z][a-z0-9-]*$/.test(tag);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function roleFor(h: ClickHandler): string | undefined {
|
|
480
|
+
if (h.hints.role) return h.hints.role;
|
|
481
|
+
if (h.tag === "input") {
|
|
482
|
+
const t = h.hints.type;
|
|
483
|
+
if (t === "checkbox") return "checkbox";
|
|
484
|
+
if (t === "radio") return "radio";
|
|
485
|
+
if (t === "submit" || t === "button") return "button";
|
|
486
|
+
return "textbox";
|
|
487
|
+
}
|
|
488
|
+
// A component tag says nothing about the role it renders: <DropdownMenuItem>
|
|
489
|
+
// is role="menuitem", <SelectTrigger> is "combobox". Assuming "button" made
|
|
490
|
+
// every such query fail with a confident, wrong name. Callers fall back to
|
|
491
|
+
// matching the element's text, which is true regardless of role.
|
|
492
|
+
return TAG_TO_ROLE[h.tag];
|
|
493
|
+
}
|