@sdods/core 0.2.2 → 0.3.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/.tsbuildinfo +1 -1
- package/dist/analyze/detectors.js +236 -24
- package/dist/analyze/index.d.ts +0 -1
- package/dist/analyze/index.js +0 -1
- package/dist/analyze/propose.js +80 -38
- package/dist/analyze/scan.js +19 -1
- package/dist/api/client.js +7 -1
- package/dist/auth/capture.js +19 -6
- package/dist/auth/index.js +23 -2
- package/dist/config/resolve.d.ts +13 -0
- package/dist/config/resolve.js +1 -0
- package/dist/config/runner.js +2 -2
- package/dist/config/tags.d.ts +29 -1
- package/dist/config/tags.js +46 -0
- package/dist/data/provider.js +5 -1
- package/dist/data/user-pool.js +35 -3
- package/dist/fixtures/api-context.d.ts +14 -1
- package/dist/fixtures/api-context.js +13 -0
- package/dist/fixtures/scenario.js +1 -4
- package/dist/fixtures/test.js +42 -1
- package/dist/fixtures/types.d.ts +2 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/reporters/dashboard.d.ts +86 -0
- package/dist/reporters/dashboard.js +319 -61
- package/dist/shots/hooks.js +0 -10
- package/dist/steps/a11y.steps.d.ts +180 -0
- package/dist/steps/a11y.steps.js +598 -0
- package/dist/steps/api.steps.js +5 -1
- package/dist/steps/browser.steps.d.ts +27 -0
- package/dist/steps/browser.steps.js +653 -0
- package/dist/steps/clock.steps.d.ts +4 -0
- package/dist/steps/clock.steps.js +73 -0
- package/dist/steps/data.steps.js +50 -2
- package/dist/steps/db.steps.d.ts +5 -0
- package/dist/steps/db.steps.js +105 -0
- package/dist/steps/dom.steps.d.ts +2 -0
- package/dist/steps/dom.steps.js +583 -0
- package/dist/steps/glob.d.ts +16 -1
- package/dist/steps/glob.js +40 -1
- package/dist/steps/iframe.steps.d.ts +2 -0
- package/dist/steps/iframe.steps.js +93 -0
- package/dist/steps/index.d.ts +11 -1
- package/dist/steps/index.js +11 -1
- package/dist/steps/net.steps.d.ts +63 -0
- package/dist/steps/net.steps.js +728 -0
- package/dist/steps/perf.steps.d.ts +248 -0
- package/dist/steps/perf.steps.js +514 -0
- package/dist/steps/tabs.steps.d.ts +5 -0
- package/dist/steps/tabs.steps.js +109 -0
- package/dist/steps/webhook.steps.d.ts +46 -0
- package/dist/steps/webhook.steps.js +129 -0
- package/package.json +3 -4
- package/dist/analyze/modules.d.ts +0 -74
- package/dist/analyze/modules.js +0 -353
- package/dist/config/playwright.d.ts +0 -37
- package/dist/config/playwright.js +0 -262
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
import { writeFileSync } from 'node:fs';
|
|
2
|
+
import { expect } from '@playwright/test';
|
|
3
|
+
import { attachmentNames, scenarioFiles } from '@sdods/contracts';
|
|
4
|
+
import './params.js';
|
|
5
|
+
import { Then } from '../fixtures/test.js';
|
|
6
|
+
import { render } from '../api/template.js';
|
|
7
|
+
import { SdodsError } from '../errors.js';
|
|
8
|
+
import { Logger } from '../logger.js';
|
|
9
|
+
/**
|
|
10
|
+
* Accessibility steps: an axe-core audit plus the structural checks a rule engine cannot see.
|
|
11
|
+
*
|
|
12
|
+
* DESIGN — the browser gathers, node judges. Every DOM probe below returns plain serialisable
|
|
13
|
+
* records and nothing else; the pass/fail decision lives in an exported pure function. That split
|
|
14
|
+
* exists so the one rule that matters here is testable without a browser: a step that quantifies
|
|
15
|
+
* over a set MUST fail when the set is empty. "Every image has an alt" over a page with no images
|
|
16
|
+
* and "no violations" inside a region that does not exist are not passes, they are silence, and
|
|
17
|
+
* silence is what makes an accessibility suite worthless. `assertRegion` and `assertGathered` are
|
|
18
|
+
* the guards, and they throw rather than assert so the message can say what to do instead.
|
|
19
|
+
*
|
|
20
|
+
* WHY NO `heal.*` HERE. The rest of the UI library heals because it resolves ONE interactive
|
|
21
|
+
* element by role/name/text, and a near-miss substitute is still the control the author meant.
|
|
22
|
+
* These steps quantify over a POPULATION inside a region: healing a region selector to a
|
|
23
|
+
* different element would silently re-point the audit at a different set of nodes and report a
|
|
24
|
+
* green result for a surface nobody asked about. A missing region is a defect in the step, so it
|
|
25
|
+
* is reported as one.
|
|
26
|
+
*/
|
|
27
|
+
const log = new Logger('a11y');
|
|
28
|
+
const scopesOf = (apiContext, env) => [apiContext.vars.toObject(), env.vars];
|
|
29
|
+
/** axe impact levels, weakest first. `impact: null` is treated as `minor`. */
|
|
30
|
+
export const IMPACT_ORDER = ['minor', 'moderate', 'serious', 'critical'];
|
|
31
|
+
/**
|
|
32
|
+
* WCAG 2.0/2.1/2.2 level A and AA, and deliberately nothing else. axe's `best-practice` tag set
|
|
33
|
+
* changes between axe releases, so including it would let the same unchanged page pass one week
|
|
34
|
+
* and fail the next — a step whose verdict moves on its own teaches teams to ignore it.
|
|
35
|
+
*/
|
|
36
|
+
export const WCAG_AA_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'];
|
|
37
|
+
/* ── pure judges (exported so the vacuity guards are unit-testable) ─────── */
|
|
38
|
+
/** Rank of an axe impact level; an absent impact ranks as `minor` rather than vanishing. */
|
|
39
|
+
export function impactRank(impact) {
|
|
40
|
+
const i = IMPACT_ORDER.indexOf((impact ?? 'minor'));
|
|
41
|
+
return i === -1 ? 0 : i;
|
|
42
|
+
}
|
|
43
|
+
/** Rejects an impact level the feature file invented, instead of silently matching nothing. */
|
|
44
|
+
export function parseImpactFloor(level) {
|
|
45
|
+
const i = IMPACT_ORDER.indexOf(level);
|
|
46
|
+
if (i === -1)
|
|
47
|
+
throw new SdodsError('CONFIG_INVALID', `"${level}" is not an axe impact level.`, {
|
|
48
|
+
hint: `Use one of: ${IMPACT_ORDER.join(', ')}.`,
|
|
49
|
+
});
|
|
50
|
+
return i;
|
|
51
|
+
}
|
|
52
|
+
export function violationsAtOrAbove(violations, floor) {
|
|
53
|
+
return violations.filter((v) => impactRank(v.impact) >= floor);
|
|
54
|
+
}
|
|
55
|
+
/** Human-readable violation list: rule, impact, help text and the first few offending nodes. */
|
|
56
|
+
export function describeViolations(violations) {
|
|
57
|
+
return violations
|
|
58
|
+
.map((v) => {
|
|
59
|
+
const nodes = v.nodes ?? [];
|
|
60
|
+
const shown = nodes
|
|
61
|
+
.slice(0, 3)
|
|
62
|
+
.map((n) => (Array.isArray(n.target) ? n.target.join(' ') : String(n.target)))
|
|
63
|
+
.join(' | ');
|
|
64
|
+
const more = nodes.length > 3 ? ` (+${nodes.length - 3} more node(s))` : '';
|
|
65
|
+
return `${v.id} [${v.impact ?? 'minor'}] ${v.help ?? ''} → ${nodes.length} node(s): ${shown}${more}`;
|
|
66
|
+
})
|
|
67
|
+
.join('\n');
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* PROVES the audit actually executed. axe reporting zero violations is indistinguishable from
|
|
71
|
+
* axe never having run: a nonce'd CSP that blocks the injected source, an `about:blank` page, or
|
|
72
|
+
* an `include` that matched nothing all produce an empty, green-looking result. If no rule landed
|
|
73
|
+
* in passes, violations or incomplete, then nothing was examined and the verdict means nothing.
|
|
74
|
+
*/
|
|
75
|
+
export function assertAxeChecked(results, where) {
|
|
76
|
+
const checked = (results.passes?.length ?? 0) +
|
|
77
|
+
(results.violations?.length ?? 0) +
|
|
78
|
+
(results.incomplete?.length ?? 0);
|
|
79
|
+
if (checked === 0)
|
|
80
|
+
throw new SdodsError('RUN_FAILED', `axe examined nothing ${where}; the audit proves nothing.`, {
|
|
81
|
+
hint: 'The page may be blank, the scoped selector may match an empty element, or a Content-Security-Policy may have blocked axe from running. Open the attached sdods/a11y JSON to see what axe reported.',
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
/** Which of axe's four result buckets a rule landed in. `none` means the rule never ran. */
|
|
85
|
+
export function ruleBucket(results, ruleId) {
|
|
86
|
+
if (results.violations?.some((r) => r.id === ruleId))
|
|
87
|
+
return 'violations';
|
|
88
|
+
if (results.passes?.some((r) => r.id === ruleId))
|
|
89
|
+
return 'passes';
|
|
90
|
+
if (results.incomplete?.some((r) => r.id === ruleId))
|
|
91
|
+
return 'incomplete';
|
|
92
|
+
if (results.inapplicable?.some((r) => r.id === ruleId))
|
|
93
|
+
return 'inapplicable';
|
|
94
|
+
return 'none';
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* PROVES a rule-scoped audit was capable of failing. `none` means the rule id does not exist, so
|
|
98
|
+
* the run examined nothing; `inapplicable` means the rule found no matching element, so a green
|
|
99
|
+
* result is silence rather than evidence. Both are reported as defects in the step.
|
|
100
|
+
*/
|
|
101
|
+
export function assertRuleRan(results, ruleId, where) {
|
|
102
|
+
const bucket = ruleBucket(results, ruleId);
|
|
103
|
+
if (bucket === 'none')
|
|
104
|
+
throw new SdodsError('CONFIG_INVALID', `axe never ran the rule "${ruleId}".`, {
|
|
105
|
+
hint: 'Check the rule id against https://dequeuniversity.com/rules/axe — a misspelt id reports zero violations and looks like a pass.',
|
|
106
|
+
});
|
|
107
|
+
if (bucket === 'inapplicable')
|
|
108
|
+
throw new SdodsError('RUN_FAILED', `axe reported "${ruleId}" inapplicable ${where}: nothing there for it to check.`, {
|
|
109
|
+
hint: `A rule with no matching element cannot fail, so this assertion proves nothing. Point the step at a surface that exercises "${ruleId}", or use the whole-page audit instead.`,
|
|
110
|
+
});
|
|
111
|
+
return bucket;
|
|
112
|
+
}
|
|
113
|
+
/** PROVES the scoped region exists. A selector matching nothing must fail, never scan the void. */
|
|
114
|
+
export function assertRegion(gathered, selector) {
|
|
115
|
+
if (!gathered.regionFound)
|
|
116
|
+
throw new SdodsError('RUN_FAILED', `No element matches the selector "${selector}".`, {
|
|
117
|
+
hint: 'A region that is absent cannot be audited, and scanning nothing would report a green result. Fix the selector, or wait for the region to render before this step.',
|
|
118
|
+
});
|
|
119
|
+
return gathered.items;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* PROVES the step had something to check. This is the guard the whole library turns on: without
|
|
123
|
+
* it "every image carries an alt" is green on a page with no images, and "no focus indicator is
|
|
124
|
+
* missing" is green on a region with no focusable elements.
|
|
125
|
+
*/
|
|
126
|
+
export function assertGathered(items, what, where) {
|
|
127
|
+
if (items.length === 0)
|
|
128
|
+
throw new SdodsError('RUN_FAILED', `Found no ${what} ${where}; nothing was checked.`, {
|
|
129
|
+
hint: `An assertion over an empty set passes without proving anything, so it is reported as a failure. Point the step at a surface that has ${what}, or drop the step.`,
|
|
130
|
+
});
|
|
131
|
+
return items;
|
|
132
|
+
}
|
|
133
|
+
/** Adjacent heading pairs that jump more than one level, described in document order. */
|
|
134
|
+
export function headingSkips(headings) {
|
|
135
|
+
const out = [];
|
|
136
|
+
for (let i = 1; i < headings.length; i++) {
|
|
137
|
+
const prev = headings[i - 1];
|
|
138
|
+
const cur = headings[i];
|
|
139
|
+
if (cur.level - prev.level > 1)
|
|
140
|
+
out.push(`h${prev.level} "${prev.text}" → h${cur.level} "${cur.text}"`);
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* A MISSING alt and `alt=""` are different bugs: the empty one is a decision that the image is
|
|
146
|
+
* decorative, the absent one is an omission. axe's `image-alt` reports only the second, so this
|
|
147
|
+
* judge keeps a distinction the rule blurs.
|
|
148
|
+
*/
|
|
149
|
+
export function imagesWithoutAlt(images) {
|
|
150
|
+
return images.filter((i) => !i.hasAlt).map((i) => i.src || '(no src)');
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* An accessible name that still looks like an i18n catalogue key ("nav.workspace.settings") is a
|
|
154
|
+
* `t()` lookup that failed at render time. axe reports that only as a generic missing-name
|
|
155
|
+
* violation, with no hint that the translation catalogue is the cause.
|
|
156
|
+
*/
|
|
157
|
+
export const RAW_I18N_KEY = /^[a-z][A-Za-z0-9]*(\.[A-Za-z0-9_]+)+$/;
|
|
158
|
+
/** Controls that render no text — the ones whose only name is an aria-label or a title. */
|
|
159
|
+
export function iconOnlyControls(controls) {
|
|
160
|
+
return controls.filter((c) => !c.text.trim());
|
|
161
|
+
}
|
|
162
|
+
export function unnamedControls(controls) {
|
|
163
|
+
return controls
|
|
164
|
+
.filter((c) => !c.name.trim() || RAW_I18N_KEY.test(c.name.trim()))
|
|
165
|
+
.map((c) => `${c.tag}${c.name.trim() ? ` named "${c.name.trim()}" (raw i18n key)` : ' (no name)'}: ${c.html}`);
|
|
166
|
+
}
|
|
167
|
+
/** Focusable elements whose computed style is identical focused and unfocused. */
|
|
168
|
+
export function focusRingMissing(records) {
|
|
169
|
+
return records.filter((r) => r.rest === r.focused).map((r) => r.label);
|
|
170
|
+
}
|
|
171
|
+
/* ── browser gatherers (serialised by page.evaluate — no outer references) ─ */
|
|
172
|
+
/*
|
|
173
|
+
* TRAP, and the reason the bodies below repeat themselves instead of factoring out a helper:
|
|
174
|
+
* `page.evaluate` ships a function by calling `toString()` on it, so the body must survive
|
|
175
|
+
* transpilation intact. esbuild-based loaders (tsx, tsm, vite-node — how many projects run their
|
|
176
|
+
* Playwright config) compile a NAMED inner function to `__name((el) => …, "sig")`, and the helper
|
|
177
|
+
* `__name` does not exist in the browser. The outer arrow still serialises fine, so this fails at
|
|
178
|
+
* run time with `ReferenceError: __name is not defined` inside a step that type-checks perfectly.
|
|
179
|
+
* Anonymous inline callbacks are untouched; a `const fn = …` inside an evaluate body is not.
|
|
180
|
+
*/
|
|
181
|
+
/**
|
|
182
|
+
* Visible headings in document order, `h1`–`h6` and `role="heading"` alike, with `aria-level`
|
|
183
|
+
* winning over the tag. `checkVisibility()` is called WITHOUT the opacity check on purpose: a
|
|
184
|
+
* visually-hidden ("sr-only") h1 is a correct, common pattern and still forms the outline.
|
|
185
|
+
*/
|
|
186
|
+
export const gatherHeadings = (sel) => {
|
|
187
|
+
const root = sel ? document.querySelector(sel) : document.body;
|
|
188
|
+
if (!root)
|
|
189
|
+
return { regionFound: false, items: [] };
|
|
190
|
+
const out = [];
|
|
191
|
+
const nodes = root.querySelectorAll('h1,h2,h3,h4,h5,h6,[role="heading"]');
|
|
192
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
193
|
+
const el = nodes[i];
|
|
194
|
+
if (el.closest('[aria-hidden="true"]'))
|
|
195
|
+
continue;
|
|
196
|
+
const check = el.checkVisibility;
|
|
197
|
+
const visible = typeof check === 'function' ? check.call(el) : el.getClientRects().length > 0;
|
|
198
|
+
if (!visible)
|
|
199
|
+
continue;
|
|
200
|
+
const aria = el.getAttribute('aria-level');
|
|
201
|
+
const tagMatch = /^H([1-6])$/.exec(el.tagName);
|
|
202
|
+
const level = aria ? Number(aria) : tagMatch ? Number(tagMatch[1]) : 2;
|
|
203
|
+
if (!Number.isFinite(level) || level < 1)
|
|
204
|
+
continue;
|
|
205
|
+
out.push({ level, text: (el.textContent || '').trim().slice(0, 60) });
|
|
206
|
+
}
|
|
207
|
+
return { regionFound: true, items: out };
|
|
208
|
+
};
|
|
209
|
+
/** Every `<img>` in scope with whether it carries an alt ATTRIBUTE (empty counts as present). */
|
|
210
|
+
export const gatherImages = (sel) => {
|
|
211
|
+
const root = sel ? document.querySelector(sel) : document.body;
|
|
212
|
+
if (!root)
|
|
213
|
+
return { regionFound: false, items: [] };
|
|
214
|
+
const out = [];
|
|
215
|
+
const nodes = root.querySelectorAll('img');
|
|
216
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
217
|
+
const el = nodes[i];
|
|
218
|
+
out.push({ src: (el.getAttribute('src') || '').slice(0, 120), hasAlt: el.hasAttribute('alt') });
|
|
219
|
+
}
|
|
220
|
+
return { regionFound: true, items: out };
|
|
221
|
+
};
|
|
222
|
+
/**
|
|
223
|
+
* Visible controls in scope with their rendered text and their accessible name. The name is
|
|
224
|
+
* resolved the way a screen reader does — aria-label, then aria-labelledby, then title, then a
|
|
225
|
+
* contained `img[alt]`, then an `<svg><title>` — because reading only aria-label (as a naive
|
|
226
|
+
* probe does) flags every correctly-labelled icon button that uses one of the other four.
|
|
227
|
+
*
|
|
228
|
+
* TRAP: text is read with `innerText`, not `textContent`. An `<svg><title>` is part of
|
|
229
|
+
* `textContent` but is never painted, so a genuinely icon-only button named through its SVG title
|
|
230
|
+
* would look like a button that renders a label and drop out of the audit entirely.
|
|
231
|
+
*/
|
|
232
|
+
export const gatherControls = (sel) => {
|
|
233
|
+
const root = document.querySelector(sel);
|
|
234
|
+
if (!root)
|
|
235
|
+
return {
|
|
236
|
+
regionFound: false,
|
|
237
|
+
items: [],
|
|
238
|
+
};
|
|
239
|
+
const out = [];
|
|
240
|
+
const nodes = root.querySelectorAll('button,a[href],[role="button"],[role="link"]');
|
|
241
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
242
|
+
const el = nodes[i];
|
|
243
|
+
const check = el.checkVisibility;
|
|
244
|
+
const visible = typeof check === 'function'
|
|
245
|
+
? check.call(el, { checkVisibilityCSS: true, checkOpacity: true })
|
|
246
|
+
: el.getClientRects().length > 0;
|
|
247
|
+
if (!visible)
|
|
248
|
+
continue;
|
|
249
|
+
let name = (el.getAttribute('aria-label') || '').trim();
|
|
250
|
+
if (!name) {
|
|
251
|
+
const ids = (el.getAttribute('aria-labelledby') || '').split(/\s+/).filter(Boolean);
|
|
252
|
+
name = ids
|
|
253
|
+
.map((id) => (document.getElementById(id)?.textContent || '').trim())
|
|
254
|
+
.filter(Boolean)
|
|
255
|
+
.join(' ');
|
|
256
|
+
}
|
|
257
|
+
if (!name)
|
|
258
|
+
name = (el.getAttribute('title') || '').trim();
|
|
259
|
+
if (!name)
|
|
260
|
+
name = (el.querySelector('img[alt]')?.getAttribute('alt') || '').trim();
|
|
261
|
+
if (!name)
|
|
262
|
+
name = (el.querySelector('svg title')?.textContent || '').trim();
|
|
263
|
+
out.push({
|
|
264
|
+
tag: el.tagName.toLowerCase(),
|
|
265
|
+
text: (typeof el.innerText === 'string' ? el.innerText : el.textContent || '').trim(),
|
|
266
|
+
name,
|
|
267
|
+
html: el.outerHTML.slice(0, 120),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
return { regionFound: true, items: out };
|
|
271
|
+
};
|
|
272
|
+
/**
|
|
273
|
+
* Computed style of every visible focusable in scope, at rest and while focused.
|
|
274
|
+
*
|
|
275
|
+
* TRAP: modern apps style `:focus-visible` only, and the browser matches that pseudo-class from
|
|
276
|
+
* the user's last INTERACTION modality, not from the `focus()` call. Measured across Chromium,
|
|
277
|
+
* Firefox and WebKit: after any real mouse click, EVERY `:focus-visible`-styled control reports
|
|
278
|
+
* an unchanged style — a total false failure on a perfectly accessible page. The step therefore
|
|
279
|
+
* presses Tab first to restore keyboard modality; Tab is the only key that does so in all three
|
|
280
|
+
* engines (Shift restores it in Chromium only, ArrowRight in Chromium and WebKit only).
|
|
281
|
+
*
|
|
282
|
+
* That Tab press lands focus on an element, which is the second half of the trap: sampling that
|
|
283
|
+
* element's resting style while it is focused reports no change and accuses it wrongly. Hence the
|
|
284
|
+
* guard below. Programmatic focus keeps the keyboard modality alive, so moving focus to a sibling
|
|
285
|
+
* is safe; `blur()` is the fallback for a region holding a single control.
|
|
286
|
+
*
|
|
287
|
+
* `checkVisibility` replaces the `offsetParent !== null` idiom, which reports `null` for any
|
|
288
|
+
* `position: fixed` element and would silently drop every floating control from the audit.
|
|
289
|
+
*/
|
|
290
|
+
export const gatherFocusIndicators = (sel) => {
|
|
291
|
+
const root = document.querySelector(sel);
|
|
292
|
+
if (!root)
|
|
293
|
+
return { regionFound: false, items: [] };
|
|
294
|
+
const out = [];
|
|
295
|
+
const nodes = root.querySelectorAll('a[href],button,input,select,textarea,[tabindex]:not([tabindex="-1"])');
|
|
296
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
297
|
+
const el = nodes[i];
|
|
298
|
+
if (el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true')
|
|
299
|
+
continue;
|
|
300
|
+
const check = el.checkVisibility;
|
|
301
|
+
const visible = typeof check === 'function'
|
|
302
|
+
? check.call(el, { checkVisibilityCSS: true, checkOpacity: true })
|
|
303
|
+
: el.getClientRects().length > 0;
|
|
304
|
+
if (!visible)
|
|
305
|
+
continue;
|
|
306
|
+
// The Tab press that restored keyboard modality landed focus on SOME element, possibly this
|
|
307
|
+
// one. Sampling its resting style while it is already focused yields an identical signature
|
|
308
|
+
// and accuses a perfectly ringed control. Move focus to a sibling first — programmatic focus
|
|
309
|
+
// keeps keyboard modality alive; blur() is the last resort for a region with one control.
|
|
310
|
+
if (document.activeElement === el) {
|
|
311
|
+
for (let j = 0; j < nodes.length; j++) {
|
|
312
|
+
if (nodes[j] !== el) {
|
|
313
|
+
nodes[j].focus();
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (document.activeElement === el)
|
|
318
|
+
el.blur();
|
|
319
|
+
}
|
|
320
|
+
const a = getComputedStyle(el);
|
|
321
|
+
const rest = [
|
|
322
|
+
a.outlineStyle,
|
|
323
|
+
a.outlineWidth,
|
|
324
|
+
a.outlineColor,
|
|
325
|
+
a.outlineOffset,
|
|
326
|
+
a.boxShadow,
|
|
327
|
+
a.borderColor,
|
|
328
|
+
a.borderWidth,
|
|
329
|
+
a.backgroundColor,
|
|
330
|
+
a.color,
|
|
331
|
+
a.textDecorationLine,
|
|
332
|
+
a.filter,
|
|
333
|
+
].join('|');
|
|
334
|
+
el.focus();
|
|
335
|
+
const b = getComputedStyle(el);
|
|
336
|
+
const focused = [
|
|
337
|
+
b.outlineStyle,
|
|
338
|
+
b.outlineWidth,
|
|
339
|
+
b.outlineColor,
|
|
340
|
+
b.outlineOffset,
|
|
341
|
+
b.boxShadow,
|
|
342
|
+
b.borderColor,
|
|
343
|
+
b.borderWidth,
|
|
344
|
+
b.backgroundColor,
|
|
345
|
+
b.color,
|
|
346
|
+
b.textDecorationLine,
|
|
347
|
+
b.filter,
|
|
348
|
+
].join('|');
|
|
349
|
+
out.push({
|
|
350
|
+
label: (el.getAttribute('aria-label') || el.textContent || el.tagName).trim().slice(0, 60),
|
|
351
|
+
rest,
|
|
352
|
+
focused,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
return { regionFound: true, items: out };
|
|
356
|
+
};
|
|
357
|
+
/**
|
|
358
|
+
* Runs axe through `@axe-core/playwright`, which injects the source with `page.evaluate` rather
|
|
359
|
+
* than `addScriptTag`. That matters: an app serving a nonce'd CSP blocks an injected `<script>`
|
|
360
|
+
* silently, and the audit would report zero violations because it never ran.
|
|
361
|
+
*
|
|
362
|
+
* The import is lazy so a project that never writes an a11y step does not need the dependency —
|
|
363
|
+
* a top-level import would fail the load of this whole step file and take the rest of the core
|
|
364
|
+
* library down with it.
|
|
365
|
+
*/
|
|
366
|
+
async function runAxe(page, opts) {
|
|
367
|
+
let AxeBuilder;
|
|
368
|
+
try {
|
|
369
|
+
const mod = await import('@axe-core/playwright');
|
|
370
|
+
// Named export first: the package exports the class as both `AxeBuilder` and `default`, and a
|
|
371
|
+
// CJS interop path can hand `default` back as the namespace object rather than the class.
|
|
372
|
+
AxeBuilder = (mod.AxeBuilder ?? mod.default);
|
|
373
|
+
}
|
|
374
|
+
catch (cause) {
|
|
375
|
+
throw new SdodsError('NOT_SUPPORTED', 'Accessibility steps need @axe-core/playwright.', {
|
|
376
|
+
hint: 'Install it in the project that runs these steps: `npm i -D @axe-core/playwright`.',
|
|
377
|
+
cause,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
const builder = new AxeBuilder({ page });
|
|
381
|
+
if (opts.include)
|
|
382
|
+
builder.include(opts.include);
|
|
383
|
+
// withTags and withRules are mutually exclusive in axe-core; never set both.
|
|
384
|
+
if (opts.rules)
|
|
385
|
+
builder.withRules(opts.rules);
|
|
386
|
+
else
|
|
387
|
+
builder.withTags(opts.tags ?? WCAG_AA_TAGS);
|
|
388
|
+
try {
|
|
389
|
+
return await builder.analyze();
|
|
390
|
+
}
|
|
391
|
+
catch (cause) {
|
|
392
|
+
// axe raises "unknown rule `x` in options.runOnly" for a rule id that does not exist, and
|
|
393
|
+
// "No elements found for include in page Context" for a selector that matched nothing. Both
|
|
394
|
+
// are defects in the step rather than in the page under test, so both are re-raised as such.
|
|
395
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
396
|
+
if (/unknown rule/i.test(message))
|
|
397
|
+
throw new SdodsError('CONFIG_INVALID', `axe rejected the rule id: ${message}`, {
|
|
398
|
+
hint: 'Check the rule id against https://dequeuniversity.com/rules/axe.',
|
|
399
|
+
cause,
|
|
400
|
+
});
|
|
401
|
+
if (/no elements found for include/i.test(message))
|
|
402
|
+
throw new SdodsError('RUN_FAILED', `axe found nothing to scan: ${message}`, {
|
|
403
|
+
hint: `The selector "${opts.include ?? ''}" matched no element by the time axe ran. Wait for the region to render before this step.`,
|
|
404
|
+
cause,
|
|
405
|
+
});
|
|
406
|
+
throw new SdodsError('RUN_FAILED', `axe failed to analyse the page: ${message}`, { cause });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Writes the full axe result next to the scenario and attaches it under `sdods/a11y/<step>`.
|
|
411
|
+
* Always attached, pass or fail: on a failure it is what makes the verdict diagnosable, and on a
|
|
412
|
+
* pass it is the evidence that the audit examined something.
|
|
413
|
+
*/
|
|
414
|
+
function attachA11y(deps, results) {
|
|
415
|
+
try {
|
|
416
|
+
const file = deps.scenario.file(scenarioFiles.a11yJson(deps.stepIndex));
|
|
417
|
+
writeFileSync(file, JSON.stringify(results, null, 2));
|
|
418
|
+
void deps.testInfo.attach(attachmentNames.a11y(deps.stepIndex), {
|
|
419
|
+
path: file,
|
|
420
|
+
contentType: 'application/json',
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
catch (e) {
|
|
424
|
+
log.debug(`could not attach a11y result: ${e.message}`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
/** Region existence check for the axe steps, which need the SELECTOR (not a healed locator). */
|
|
428
|
+
async function requireRegion(page, selector) {
|
|
429
|
+
const count = await page.locator(selector).count();
|
|
430
|
+
if (count === 0)
|
|
431
|
+
throw new SdodsError('RUN_FAILED', `No element matches the selector "${selector}".`, {
|
|
432
|
+
hint: 'axe would scan an empty context and report zero violations, which reads as a pass. Fix the selector, or wait for the region to render before this step.',
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
/* ── axe audits ────────────────────────────────────────────────────────── */
|
|
436
|
+
/**
|
|
437
|
+
* PROVES the page carries no WCAG 2.x A/AA violation that axe can detect automatically.
|
|
438
|
+
* Roughly a third of WCAG is machine-checkable, so a pass is a floor, never a certificate.
|
|
439
|
+
*/
|
|
440
|
+
Then('the page should have no accessibility violations', async ({ page, scenario, $bddContext, $testInfo }) => {
|
|
441
|
+
const results = await runAxe(page, {});
|
|
442
|
+
attachA11y({ scenario, testInfo: $testInfo, stepIndex: $bddContext.stepIndex }, results);
|
|
443
|
+
assertAxeChecked(results, `on ${page.url()}`);
|
|
444
|
+
expect(results.violations.length, `${results.violations.length} WCAG A/AA violation(s) on ${page.url()}:\n${describeViolations(results.violations)}`).toBe(0);
|
|
445
|
+
});
|
|
446
|
+
/**
|
|
447
|
+
* PROVES one region of the page is clean, so a shared component can be audited without the rest
|
|
448
|
+
* of the page's debt masking or drowning it. Fails when the selector matches nothing: axe scans
|
|
449
|
+
* an empty context happily and reports zero violations, which is indistinguishable from a pass.
|
|
450
|
+
*/
|
|
451
|
+
Then('the page should have no accessibility violations within {string}', async ({ page, scenario, apiContext, env, $bddContext, $testInfo }, selector) => {
|
|
452
|
+
const sel = render(selector, ...scopesOf(apiContext, env));
|
|
453
|
+
await requireRegion(page, sel);
|
|
454
|
+
const results = await runAxe(page, { include: sel });
|
|
455
|
+
attachA11y({ scenario, testInfo: $testInfo, stepIndex: $bddContext.stepIndex }, results);
|
|
456
|
+
assertAxeChecked(results, `within "${sel}"`);
|
|
457
|
+
expect(results.violations.length, `${results.violations.length} WCAG A/AA violation(s) within "${sel}" on ${page.url()}:\n${describeViolations(results.violations)}`).toBe(0);
|
|
458
|
+
});
|
|
459
|
+
/**
|
|
460
|
+
* PROVES nothing at or above a chosen severity is present, so a team can adopt a11y gating with a
|
|
461
|
+
* critical-only bar and tighten it later. An invented level is rejected rather than matching
|
|
462
|
+
* nothing — "or worse than banana" would otherwise be the greenest step in the suite.
|
|
463
|
+
*/
|
|
464
|
+
Then('the page should have no accessibility violations of impact {string} or worse', async ({ page, scenario, apiContext, env, $bddContext, $testInfo }, level) => {
|
|
465
|
+
const floor = parseImpactFloor(render(level, ...scopesOf(apiContext, env)));
|
|
466
|
+
const results = await runAxe(page, {});
|
|
467
|
+
attachA11y({ scenario, testInfo: $testInfo, stepIndex: $bddContext.stepIndex }, results);
|
|
468
|
+
assertAxeChecked(results, `on ${page.url()}`);
|
|
469
|
+
const blocking = violationsAtOrAbove(results.violations, floor);
|
|
470
|
+
expect(blocking.length, `${blocking.length} violation(s) at ${IMPACT_ORDER[floor]}+ on ${page.url()} (of ${results.violations.length} total):\n${describeViolations(blocking)}`).toBe(0);
|
|
471
|
+
});
|
|
472
|
+
/** PROVES a region carries no violation at or above a severity — the scoped form of the above. */
|
|
473
|
+
Then('the page should have no accessibility violations of impact {string} or worse within {string}', async ({ page, scenario, apiContext, env, $bddContext, $testInfo }, level, selector) => {
|
|
474
|
+
const scopes = scopesOf(apiContext, env);
|
|
475
|
+
const floor = parseImpactFloor(render(level, ...scopes));
|
|
476
|
+
const sel = render(selector, ...scopes);
|
|
477
|
+
await requireRegion(page, sel);
|
|
478
|
+
const results = await runAxe(page, { include: sel });
|
|
479
|
+
attachA11y({ scenario, testInfo: $testInfo, stepIndex: $bddContext.stepIndex }, results);
|
|
480
|
+
assertAxeChecked(results, `within "${sel}"`);
|
|
481
|
+
const blocking = violationsAtOrAbove(results.violations, floor);
|
|
482
|
+
expect(blocking.length, `${blocking.length} violation(s) at ${IMPACT_ORDER[floor]}+ within "${sel}" on ${page.url()}:\n${describeViolations(blocking)}`).toBe(0);
|
|
483
|
+
});
|
|
484
|
+
/**
|
|
485
|
+
* PROVES one named axe rule holds on this page. Isolating a rule keeps a targeted regression
|
|
486
|
+
* ("labels came back") legible instead of buried in a forty-violation dump.
|
|
487
|
+
*
|
|
488
|
+
* TRAP: a misspelt rule id and a rule with nothing to check BOTH report zero violations. This
|
|
489
|
+
* step fails on either, because a rule that never ran cannot have passed.
|
|
490
|
+
*/
|
|
491
|
+
Then('the page should have no accessibility violations of rule {string}', async ({ page, scenario, apiContext, env, $bddContext, $testInfo }, ruleId) => {
|
|
492
|
+
const rule = render(ruleId, ...scopesOf(apiContext, env));
|
|
493
|
+
const results = await runAxe(page, { rules: [rule] });
|
|
494
|
+
attachA11y({ scenario, testInfo: $testInfo, stepIndex: $bddContext.stepIndex }, results);
|
|
495
|
+
const bucket = assertRuleRan(results, rule, `on ${page.url()}`);
|
|
496
|
+
const violations = results.violations.filter((v) => v.id === rule);
|
|
497
|
+
expect(violations.length, `${violations.length} "${rule}" violation(s) on ${page.url()} (axe bucket: ${bucket}):\n${describeViolations(violations)}`).toBe(0);
|
|
498
|
+
});
|
|
499
|
+
/**
|
|
500
|
+
* PROVES text on this page meets the WCAG AA contrast ratio. Kept separate from the full audit
|
|
501
|
+
* because a brand-palette or dark-mode change must fail ON CONTRAST rather than disappearing into
|
|
502
|
+
* a general violation list — contrast is the single most common finding, and the one most often
|
|
503
|
+
* introduced by a change that touched no markup at all.
|
|
504
|
+
*/
|
|
505
|
+
Then('the page should have no colour-contrast violations', async ({ page, scenario, $bddContext, $testInfo }) => {
|
|
506
|
+
const results = await runAxe(page, { rules: ['color-contrast'] });
|
|
507
|
+
attachA11y({ scenario, testInfo: $testInfo, stepIndex: $bddContext.stepIndex }, results);
|
|
508
|
+
const bucket = assertRuleRan(results, 'color-contrast', `on ${page.url()}`);
|
|
509
|
+
expect(results.violations.length, `${results.violations.length} colour-contrast violation(s) on ${page.url()} (axe bucket: ${bucket}; "incomplete" means axe could not read the background, e.g. text over an image):\n${describeViolations(results.violations)}`).toBe(0);
|
|
510
|
+
});
|
|
511
|
+
/** PROVES one region's text meets AA contrast — for auditing a themed component in isolation. */
|
|
512
|
+
Then('the page should have no colour-contrast violations within {string}', async ({ page, scenario, apiContext, env, $bddContext, $testInfo }, selector) => {
|
|
513
|
+
const sel = render(selector, ...scopesOf(apiContext, env));
|
|
514
|
+
await requireRegion(page, sel);
|
|
515
|
+
const results = await runAxe(page, { include: sel, rules: ['color-contrast'] });
|
|
516
|
+
attachA11y({ scenario, testInfo: $testInfo, stepIndex: $bddContext.stepIndex }, results);
|
|
517
|
+
const bucket = assertRuleRan(results, 'color-contrast', `within "${sel}"`);
|
|
518
|
+
expect(results.violations.length, `${results.violations.length} colour-contrast violation(s) within "${sel}" on ${page.url()} (axe bucket: ${bucket}):\n${describeViolations(results.violations)}`).toBe(0);
|
|
519
|
+
});
|
|
520
|
+
/* ── structure axe cannot judge ────────────────────────────────────────── */
|
|
521
|
+
/**
|
|
522
|
+
* PROVES the document has exactly one top-level heading. Zero leaves a screen-reader user with no
|
|
523
|
+
* landmark for "what is this page"; two or more make the outline ambiguous. axe's `page-has-h1`
|
|
524
|
+
* is a best-practice rule (excluded from the A/AA audit above by design), and neither it nor any
|
|
525
|
+
* WCAG rule catches the duplicate case.
|
|
526
|
+
*/
|
|
527
|
+
Then('the page should have exactly one level-1 heading', async ({ page }) => {
|
|
528
|
+
const gathered = await page.evaluate(gatherHeadings, null);
|
|
529
|
+
const level1 = gathered.items.filter((h) => h.level === 1);
|
|
530
|
+
expect(level1.length, `expected exactly one level-1 heading, found ${level1.length} of ${gathered.items.length} heading(s): ${JSON.stringify(level1.map((h) => h.text))}`).toBe(1);
|
|
531
|
+
});
|
|
532
|
+
/**
|
|
533
|
+
* PROVES the heading outline is navigable: no jump from h2 straight to h4, which is how a screen
|
|
534
|
+
* reader user loses a whole section. Headings are read as rendered, so a level chosen by CSS
|
|
535
|
+
* appearance rather than semantics shows up here.
|
|
536
|
+
*
|
|
537
|
+
* Fails when the page has NO heading at all — an empty outline is the strongest version of this
|
|
538
|
+
* defect, and a step that quantifies over no headings would report it as a pass.
|
|
539
|
+
*/
|
|
540
|
+
Then('the heading levels should not skip a level', async ({ page }) => {
|
|
541
|
+
const gathered = await page.evaluate(gatherHeadings, null);
|
|
542
|
+
const headings = assertGathered(gathered.items, 'headings', `on ${page.url()}`);
|
|
543
|
+
const skips = headingSkips(headings);
|
|
544
|
+
expect(skips.length, `skipped heading level(s) across ${headings.length} heading(s) on ${page.url()}: ${skips.join(', ')}`).toBe(0);
|
|
545
|
+
});
|
|
546
|
+
/**
|
|
547
|
+
* PROVES every image declares its alt text intent. Fails on a page with no images: the brief that
|
|
548
|
+
* this library exists to answer named exactly that case — "every image has alt" over an empty set
|
|
549
|
+
* is the archetype of an assertion that cannot fail.
|
|
550
|
+
*/
|
|
551
|
+
Then('every image on the page should carry an alt attribute', async ({ page }) => {
|
|
552
|
+
const gathered = await page.evaluate(gatherImages, null);
|
|
553
|
+
const images = assertGathered(gathered.items, 'img elements', `on ${page.url()}`);
|
|
554
|
+
const missing = imagesWithoutAlt(images);
|
|
555
|
+
expect(missing.length, `${missing.length} of ${images.length} img element(s) have no alt attribute: ${missing.join(', ')}`).toBe(0);
|
|
556
|
+
});
|
|
557
|
+
/** PROVES every image inside one region declares its alt intent — the scoped form of the above. */
|
|
558
|
+
Then('every image within {string} should carry an alt attribute', async ({ page, apiContext, env }, selector) => {
|
|
559
|
+
const sel = render(selector, ...scopesOf(apiContext, env));
|
|
560
|
+
const gathered = await page.evaluate(gatherImages, sel);
|
|
561
|
+
const images = assertGathered(assertRegion(gathered, sel), 'img elements', `within "${sel}"`);
|
|
562
|
+
const missing = imagesWithoutAlt(images);
|
|
563
|
+
expect(missing.length, `${missing.length} of ${images.length} img element(s) within "${sel}" have no alt attribute: ${missing.join(', ')}`).toBe(0);
|
|
564
|
+
});
|
|
565
|
+
/**
|
|
566
|
+
* PROVES every icon-only button or link in a region announces itself. An icon with no name is
|
|
567
|
+
* simply "button" to a screen reader.
|
|
568
|
+
*
|
|
569
|
+
* TRAP: a name that is still a raw i18n catalogue key ("nav.settings") is counted as missing. Those
|
|
570
|
+
* aria-labels resolve at render time, and a catalogue key that failed to resolve renders the key
|
|
571
|
+
* itself — axe sees a non-empty name and passes it.
|
|
572
|
+
*/
|
|
573
|
+
Then('every icon-only control within {string} should expose an accessible name', async ({ page, apiContext, env }, selector) => {
|
|
574
|
+
const sel = render(selector, ...scopesOf(apiContext, env));
|
|
575
|
+
const gathered = await page.evaluate(gatherControls, sel);
|
|
576
|
+
const controls = assertRegion(gathered, sel);
|
|
577
|
+
const iconOnly = assertGathered(iconOnlyControls(controls), 'icon-only controls', `within "${sel}" (${controls.length} control(s) there render text)`);
|
|
578
|
+
const bad = unnamedControls(iconOnly);
|
|
579
|
+
expect(bad.length, `${bad.length} of ${iconOnly.length} icon-only control(s) within "${sel}" have a missing or unresolved accessible name:\n${bad.join('\n')}`).toBe(0);
|
|
580
|
+
});
|
|
581
|
+
/**
|
|
582
|
+
* PROVES a keyboard user can see where they are inside a region (WCAG 2.2 AA 2.4.11/2.4.13). A
|
|
583
|
+
* focus ring removed by a CSS reset is invisible to axe, which reads the accessibility tree and
|
|
584
|
+
* never the painted style, so this is checked by comparing computed style at rest and focused.
|
|
585
|
+
*
|
|
586
|
+
* TRAP: `:focus-visible`-only styling (the Tailwind/shadcn default) matches on the last
|
|
587
|
+
* interaction MODALITY, not on the focus() call, so the walk is preceded by a real Tab press to
|
|
588
|
+
* enter keyboard modality and never blurs between elements.
|
|
589
|
+
*/
|
|
590
|
+
Then('every focusable element within {string} should show a visible focus indicator', async ({ page, apiContext, env }, selector) => {
|
|
591
|
+
const sel = render(selector, ...scopesOf(apiContext, env));
|
|
592
|
+
await page.keyboard.press('Tab');
|
|
593
|
+
const gathered = await page.evaluate(gatherFocusIndicators, sel);
|
|
594
|
+
const focusables = assertGathered(assertRegion(gathered, sel), 'focusable elements', `within "${sel}"`);
|
|
595
|
+
const bad = focusRingMissing(focusables);
|
|
596
|
+
expect(bad.length, `${bad.length} of ${focusables.length} focusable element(s) within "${sel}" show no computed style change when focused: ${bad.join(', ')}`).toBe(0);
|
|
597
|
+
});
|
|
598
|
+
//# sourceMappingURL=a11y.steps.js.map
|
package/dist/steps/api.steps.js
CHANGED
|
@@ -45,7 +45,11 @@ Given('I authenticate with basic credentials {string} and {string}', async ({ ap
|
|
|
45
45
|
};
|
|
46
46
|
});
|
|
47
47
|
Given('I use no authentication', async ({ apiContext }) => {
|
|
48
|
-
|
|
48
|
+
// `null`, not `undefined`: undefined means "unset, fall back to env.api.auth",
|
|
49
|
+
// which made this step a no-op on every environment that declares a
|
|
50
|
+
// credential — exactly the environments where asserting an unauthenticated
|
|
51
|
+
// refusal matters.
|
|
52
|
+
apiContext.auth = null;
|
|
49
53
|
});
|
|
50
54
|
/* ── assertions ───────────────────────────────────────────────────────── */
|
|
51
55
|
Then('the response status should be {int}', async ({ apiContext }, status) => {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Max channel below this is unambiguously a dark surface. */
|
|
2
|
+
export declare const DARK_MAX_CHANNEL = 90;
|
|
3
|
+
/** Min channel above this is unambiguously a light surface. */
|
|
4
|
+
export declare const LIGHT_MIN_CHANNEL = 160;
|
|
5
|
+
export interface BackgroundLayer {
|
|
6
|
+
tag: string;
|
|
7
|
+
colour: string;
|
|
8
|
+
}
|
|
9
|
+
export interface PaintedColour {
|
|
10
|
+
rgb: [number, number, number];
|
|
11
|
+
from: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The first layer in an element-to-`<html>` chain that actually paints.
|
|
15
|
+
*
|
|
16
|
+
* TRAP: `background-color: transparent` computes to `rgba(0, 0, 0, 0)`. Read naively that is pure
|
|
17
|
+
* black, so a page with a transparent body reads as "dark" and every dark-mode assertion passes
|
|
18
|
+
* over a surface that was never painted. Alpha zero means "not painted here" — keep walking.
|
|
19
|
+
*/
|
|
20
|
+
export declare function firstPaintedColour(chain: BackgroundLayer[]): PaintedColour | null;
|
|
21
|
+
/**
|
|
22
|
+
* A missing translation surfaces as the raw catalogue key rendered into the page — `nav.settings`
|
|
23
|
+
* where "Settings" belongs. Nothing throws, nothing logs, and the layout is unchanged, so only the
|
|
24
|
+
* shape of the text gives it away.
|
|
25
|
+
*/
|
|
26
|
+
export declare function looksLikeMessageKey(text: string): boolean;
|
|
27
|
+
//# sourceMappingURL=browser.steps.d.ts.map
|