automatica11y 0.4.1 → 0.6.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.
Files changed (42) hide show
  1. package/README.md +37 -130
  2. package/package.json +28 -2
  3. package/skills/automatica11y-runner/SKILL.md +16 -3
  4. package/skills/automatica11y-runner/references/fixtures.md +22 -0
  5. package/src/cli.js +1 -0
  6. package/src/commands/common.js +14 -11
  7. package/src/frameworks/angular-errors.js +27 -0
  8. package/src/frameworks/angular-selectors.js +36 -0
  9. package/src/frameworks/angular.js +156 -0
  10. package/src/frameworks/html.js +163 -0
  11. package/src/frameworks/index.js +12 -7
  12. package/src/frameworks/react.js +2 -1
  13. package/src/frameworks/vue.js +2 -1
  14. package/src/globals.d.ts +123 -6
  15. package/src/harness/bundle.js +2 -1
  16. package/src/harness/generate/angular-recipes.js +360 -0
  17. package/src/harness/generate/probe.js +16 -5
  18. package/src/harness/npm-install.js +43 -11
  19. package/src/harness/shadow.js +1 -1
  20. package/src/harness/storybook.js +18 -5
  21. package/src/plan/build-plan.js +1 -0
  22. package/src/plan/classify.js +39 -11
  23. package/src/plan/mapping.js +5 -5
  24. package/src/plan/resolve-npm.js +47 -17
  25. package/src/plan/subpath.js +17 -0
  26. package/src/report/comparison.js +4 -1
  27. package/src/report/index.js +1 -1
  28. package/src/report/parts.js +13 -1
  29. package/src/report/single.js +1 -1
  30. package/src/run/audit-npm.js +93 -26
  31. package/src/run/fail-check.js +10 -2
  32. package/src/run/generate-fixture.js +5 -4
  33. package/src/run/run-plan.js +8 -7
  34. package/src/run/summary.js +1 -1
  35. package/src/schema.js +9 -2
  36. package/src/tiers/computed/checks.js +3 -1
  37. package/src/tiers/conditions/kit.js +3 -3
  38. package/src/tiers/interactions/archetypes.js +6 -2
  39. package/src/tiers/interactions/focus-indicator.js +4 -2
  40. package/src/tiers/interactions/helpers.js +2 -1
  41. package/src/tiers/rules/ibm.js +2 -2
  42. package/src/tiers/rules/index.js +1 -1
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Candidate Angular fixtures, built from what discovery read off each exported class: its kind, selectors, inputs, outputs,
3
+ * `exportAs` names, and a service's methods. A selector says which element and attribute turn a class on, an input named
4
+ * after a selector attribute (`matMenuTriggerFor`) points at another class, and `exportAs` says how a template names it.
5
+ * Nothing here knows any one library. A candidate is a guess until the probe has seen it behave (see probe.js).
6
+ */
7
+ import { markupFor } from "../../frameworks/angular-selectors.js";
8
+ import { score } from "../../plan/mapping.js";
9
+ import { ARCHETYPE_PATTERNS } from "../storybook.js";
10
+ import { markingSource } from "./marking.js";
11
+ import { ATTEMPT_LIMIT, GENERATABLE } from "./shared.js";
12
+
13
+ const words = (text) => String(text).replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z])([A-Z][a-z])/g, "$1 $2").replace(/[-_[\]]/g, " ");
14
+
15
+ /** The shape of one record discovery wrote for a class. */
16
+ /** @typedef {{ name: string, angular: { kind: string, selectors?: unknown[][], inputs?: string[], outputs?: string[], exportAs?: string[], standalone?: boolean, moduleName?: string | null, methods?: string[] } }} Part */
17
+
18
+ const selectorText = (part) => (part.angular.selectors ?? []).flat().filter((x) => typeof x === "string").join(" ");
19
+
20
+ /** Does the class's name or selector say it belongs to this archetype? */
21
+ const fits = (archetype, part) => ARCHETYPE_PATTERNS[archetype].test(words(`${part.name} ${selectorText(part)}`));
22
+
23
+ /** The classes of a package that look like this archetype, plain Angular classes only, best fit first. */
24
+ function pool(archetype, exports) {
25
+ return exports
26
+ .filter((e) => e.angular && ["component", "directive", "service"].includes(e.angular.kind) && fits(archetype, e))
27
+ .sort((a, b) => score(archetype, b.name) - score(archetype, a.name) || a.name.localeCompare(b.name));
28
+ }
29
+
30
+ /** Can a template use this class? A standalone one is imported by name, and another is imported through the module that declares it. */
31
+ const importName = (part) => (part.angular.kind === "service" || part.angular.standalone ? part.name : part.angular.moduleName ?? null);
32
+
33
+ const usable = (...parts) => parts.every((part) => part && importName(part));
34
+
35
+ /**
36
+ * One element that turns a class on, as markup. `extra` holds attributes the recipe adds, and `bind` turns an attribute that is also
37
+ * an input into a binding (`[matMenuTriggerFor]="m"`). `values` gives a text value to an attribute or input of that name.
38
+ * @param {Part} part
39
+ * @param {{ prefer?: string, fallback?: string, extra?: string[], bind?: Record<string, string>, values?: Record<string, string>, inner?: string }} [options]
40
+ * @returns {string | null}
41
+ */
42
+ function element(part, { prefer, fallback = "div", extra = [], bind = {}, values = {}, inner = "" } = {}) {
43
+ // A selector that names its own element (`mat-tab`) can't be put on the preferred one, so it keeps its element.
44
+ const markup = markupFor(part.angular.selectors, { prefer, fallback }) ?? markupFor(part.angular.selectors, { fallback });
45
+ if (!markup) return null;
46
+ const attrs = markup.attrs.map(([name, value]) => {
47
+ if (name in bind) return `[${name}]="${bind[name]}"`;
48
+ if (name in values) return `${name}="${values[name]}"`;
49
+ return value ? `${name}="${value}"` : name;
50
+ });
51
+ for (const [name, value] of Object.entries(bind)) if (!markup.attrs.some(([attr]) => attr === name)) attrs.push(`[${name}]="${value}"`);
52
+ for (const [name, value] of Object.entries(values)) if (!markup.attrs.some(([attr]) => attr === name)) attrs.push(`${name}="${value}"`);
53
+ const open = [markup.tag, ...extra, ...attrs].join(" ");
54
+ return VOID.has(markup.tag) ? `<${open}>` : `<${open}>${inner}</${markup.tag}>`;
55
+ }
56
+
57
+ const VOID = new Set(["input", "img", "br", "hr", "area", "base", "col", "embed", "link", "meta", "source", "track", "wbr"]);
58
+
59
+ const exportRef = (part) => part.angular.exportAs?.[0] ?? null;
60
+
61
+ /** The classes that can point at another (`[matMenuTriggerFor]`), with the input that does it, matched to classes that have an `exportAs`. */
62
+ function linked(archetype, exports, targetPattern) {
63
+ const all = pool(archetype, exports);
64
+ // The class that is pointed at needn't have the archetype's own name (a listbox for a combobox), so a name pattern widens it.
65
+ const targets = [...new Set([...all, ...exports.filter((e) => e.angular && targetPattern.test(e.name))])].filter((e) => e.angular?.kind !== "service");
66
+ const pairs = [];
67
+ for (const pointer of all) {
68
+ const inputs = pointer.angular.inputs ?? [];
69
+ const attrs = new Set((pointer.angular.selectors ?? []).flat().filter((x) => typeof x === "string"));
70
+ const input = inputs.find((name) => attrs.has(name) && /For$|^for$|Trigger|Target|Menu|Panel|Content|Overlay/.test(name)) ?? inputs.find((name) => /For$/.test(name)) ?? inputs.find((name) => name === archetype || /^(menu|panel|popup|listbox|content|target|overlay|origin)$/.test(name)) ?? inputs.find((name) => attrs.has(name));
71
+ if (!input) continue;
72
+ // A library names its parts alike (`MatMenuTrigger`, `MatMenu`), so a target that starts the same way comes first and the rest are left out.
73
+ const prefix = pointer.name.match(/^[A-Z][a-z0-9]*/)?.[0] ?? "";
74
+ const near = targets.filter((target) => target.name.startsWith(prefix));
75
+ // The plainest name first: `Menu` before `MenuBar`.
76
+ for (const target of (near.length ? near : targets).slice().sort((a, b) => a.name.length - b.name.length)) {
77
+ if (target !== pointer && exportRef(target) && usable(pointer, target)) pairs.push({ pointer, target, input });
78
+ }
79
+ }
80
+ return pairs;
81
+ }
82
+
83
+ const nameMatches = (parts, pattern) => parts.filter((part) => pattern.test(part.name));
84
+
85
+ /**
86
+ * The first text-like input of a class, such as `label` or `title`, so a recipe can fill it in. A directive that carries its
87
+ * value on its own attribute (`brnTabsTrigger="one"`) counts too.
88
+ */
89
+ const textInput = (part, pattern = /^(label|title|header|heading|summary|text|value|name)$/) => {
90
+ const inputs = part.angular.inputs ?? [];
91
+ const attrs = new Set((part.angular.selectors ?? []).flat().filter((x) => typeof x === "string"));
92
+ return inputs.find((name) => pattern.test(name)) ?? inputs.find((name) => attrs.has(name) && part.angular.kind === "directive") ?? null;
93
+ };
94
+
95
+ /**
96
+ * The source of one fixture. The marking code runs outside Angular's zone, so its polling doesn't trigger change detection.
97
+ * @param {{ archetype: string, pkg: string, parts: Part[], template: string, fields?: string, before?: string, extraImports?: string[] }} input
98
+ */
99
+ function fixture({ archetype, pkg, parts, template, fields = "", before = "", extraImports = [] }) {
100
+ const names = [...new Set(parts.map(importName))].filter(Boolean);
101
+ const imports = parts.filter((part) => part.angular.kind !== "service").map(importName);
102
+ const core = ["Component", "NgZone", "inject", "signal", ...extraImports];
103
+ return `import { ${[...new Set(core)].join(", ")} } from "@angular/core";
104
+ import { ${names.join(", ")} } from ${JSON.stringify(pkg)};
105
+
106
+ ${markingSource(archetype)}${before}
107
+ class Fixture {
108
+ zone = inject(NgZone);
109
+ ${fields ? `${fields}\n` : ""} constructor() {
110
+ this.zone.runOutsideAngular(() => startMarking());
111
+ }
112
+ }
113
+ Component({ selector: "app-fixture", imports: [${[...new Set(imports)].join(", ")}], template: ${JSON.stringify(template)} })(Fixture);
114
+ export default Fixture;
115
+ `;
116
+ }
117
+
118
+ const candidate = (id, summary, parts, source) => ({ id, summary, source, used: [...new Set(parts.map((part) => part.name))] });
119
+
120
+ const ITEMS = "[{ label: 'One', value: 'one', title: 'One', header: 'One', text: 'One', id: 'one', key: 'one', content: 'First panel.' }, { label: 'Two', value: 'two', title: 'Two', header: 'Two', text: 'Two', id: 'two', key: 'two', content: 'Second panel.' }]";
121
+ const itemsInput = (part) => (part.angular.inputs ?? []).find((name) => /^(items|tabs|options|panels|model|data|links)$/.test(name)) ?? null;
122
+
123
+ // ---- tooltip ----
124
+
125
+ function tooltips(pkg, exports) {
126
+ const out = [];
127
+ for (const part of pool("tooltip", exports).filter((p) => p.angular.kind !== "service" && usable(p))) {
128
+ const attrs = new Set((part.angular.selectors ?? []).flat().filter((x) => typeof x === "string"));
129
+ const input = (part.angular.inputs ?? []).find((name) => attrs.has(name)) ?? (part.angular.inputs ?? []).find((name) => /^(tip|tooltip|content|text|label|message|description)$/i.test(name) || /tooltip$/i.test(name));
130
+ if (!input) continue;
131
+ const host = element(part, { prefer: "button", extra: ['type="button"', "data-a11y-trigger"], values: { [input]: "Saves your work." }, inner: "Save" });
132
+ if (host) out.push(candidate(`tooltip-${input}`, `a button with ${part.name} and its ${input} text`, [part], fixture({ archetype: "tooltip", pkg, parts: [part], template: host })));
133
+ }
134
+ return out;
135
+ }
136
+
137
+ // ---- live region ----
138
+
139
+ function messages(pkg, exports) {
140
+ const out = [];
141
+ const all = pool("live-region", exports);
142
+ for (const part of all.filter((p) => p.angular.kind !== "service" && usable(p))) {
143
+ const shownBy = (part.angular.inputs ?? []).find((name) => /^(open|opened|visible|show|shown|isOpen|display)$/.test(name));
144
+ const message = element(part, { extra: [], inner: "Saved." });
145
+ if (!message) continue;
146
+ out.push(candidate(`message-if-${part.name}`, `${part.name} added when the trigger is pressed`, [part], fixture({
147
+ archetype: "live-region", pkg, parts: [part], fields: " shown = signal(false);",
148
+ template: `<button type="button" data-a11y-trigger (click)="shown.set(true)">Show message</button>@if (shown()) {${message}}`,
149
+ })));
150
+ if (shownBy) {
151
+ const flagged = element(part, { bind: { [shownBy]: "shown()" }, inner: "Saved." });
152
+ out.push(candidate(`message-input-${part.name}`, `${part.name} shown through its ${shownBy} input`, [part], fixture({
153
+ archetype: "live-region", pkg, parts: [part], fields: " shown = signal(false);",
154
+ template: `<button type="button" data-a11y-trigger (click)="shown.set(true)">Show message</button>${flagged}`,
155
+ })));
156
+ }
157
+ }
158
+ for (const service of all.filter((p) => p.angular.kind === "service")) {
159
+ const method = (service.angular.methods ?? []).find((name) => /^(open|show|notify|add|success|info|error|warn|message|announce)/i.test(name));
160
+ if (!method) continue;
161
+ out.push(candidate(`message-service-${service.name}`, `${service.name}.${method}() called when the trigger is pressed`, [service], fixture({
162
+ archetype: "live-region", pkg, parts: [service], fields: ` service = inject(${service.name});`,
163
+ template: `<button type="button" data-a11y-trigger (click)="service.${method}('Saved.')">Show message</button>`,
164
+ })));
165
+ }
166
+ return out;
167
+ }
168
+
169
+ // ---- form field ----
170
+
171
+ function fields(pkg, exports) {
172
+ const out = [];
173
+ const all = pool("form-field", exports).filter((p) => p.angular.kind !== "service" && usable(p));
174
+ const wrappers = all.filter((p) => /Field|Form|Group|Wrapper|Control/.test(p.name) && !/Label|Input|Error|Hint/.test(p.name.replace(/(Form|Input)Field/, "")));
175
+ const labels = nameMatches(all, /Label/);
176
+ const inputs = all.filter((p) => /Input|Control|TextField|Textarea|Native|Field/.test(p.name) && !/Label|Error|Hint|Prefix|Suffix/.test(p.name));
177
+ for (const input of inputs.slice(0, 3)) {
178
+ const field = element(input, { prefer: "input", extra: ['id="name"', "data-a11y-trigger"] });
179
+ if (!field) continue;
180
+ const label = '<label for="name">Name</label>';
181
+ for (const wrapper of wrappers.filter((w) => w !== input).slice(0, 2)) {
182
+ const labelPart = labels.find((l) => l !== wrapper);
183
+ const labelText = labelPart && usable(labelPart) ? element(labelPart, { prefer: "label", extra: ['for="name"'], inner: "Name" }) : label;
184
+ const wrapped = element(wrapper, { inner: `${labelText ?? label}${field}` });
185
+ if (wrapped) out.push(candidate(`field-wrapper-${wrapper.name}`, `${wrapper.name} around a label and ${input.name}`, [wrapper, input, ...(labelPart ? [labelPart] : [])], fixture({ archetype: "form-field", pkg, parts: [wrapper, input, ...(labelPart && usable(labelPart) ? [labelPart] : [])], template: wrapped })));
186
+ }
187
+ out.push(candidate(`field-bare-${input.name}`, `${input.name} on a native input with its own label`, [input], fixture({ archetype: "form-field", pkg, parts: [input], template: `${label}${field}` })));
188
+ }
189
+ for (const part of all.filter((p) => p.angular.kind === "component" && (p.angular.inputs ?? []).some((name) => /^(label|placeholder|value)$/.test(name)))) {
190
+ const labelInput = (part.angular.inputs ?? []).find((name) => name === "label");
191
+ const self = element(part, { prefer: "div", extra: ["data-a11y-trigger"], values: labelInput ? { label: "Name" } : {} });
192
+ if (self) out.push(candidate(`field-component-${part.name}`, `${part.name} as the whole field`, [part], fixture({ archetype: "form-field", pkg, parts: [part], template: self })));
193
+ }
194
+ return out;
195
+ }
196
+
197
+ // ---- tabs and accordion: a group with child parts, or a data-driven component ----
198
+
199
+ function groups(archetype, pkg, exports, { groupPattern, childPattern, childTag }) {
200
+ const out = [];
201
+ const all = pool(archetype, exports).filter((p) => p.angular.kind !== "service" && usable(p));
202
+ const containers = all.filter((p) => groupPattern.test(p.name));
203
+ const plain = (p) => p.angular && p.angular.kind !== "service" && usable(p) && childPattern.test(p.name) && !groupPattern.test(p.name);
204
+ // A child needn't have the archetype's own name (`MatExpansionPanel`), so a part named like its container (`Mat…`) counts too.
205
+ const near = (container) => exports.filter((p) => plain(p) && p.name.startsWith(container.name.match(/^[A-Z][a-z0-9]*/)?.[0] ?? "\0"));
206
+ // The plainest child comes first: `MatTab` before `MatTabLabel`, which only works inside the other.
207
+ const byLength = (a, b) => a.name.length - b.name.length;
208
+ const stemOf = (name) => name.replace(/(Group|s)$/, "");
209
+ /** A part that sits beside the children (`AccordionHeader`, `TabList`): named like the child or like the container. */
210
+ const partOf = (pattern, child, container) => exports.find((p) => p.angular && p.angular.kind !== "service" && usable(p) && pattern.test(p.name) && (p.name.startsWith(child.name) || (container && p.name.startsWith(stemOf(container.name)))));
211
+ const isAccordion = archetype === "accordion";
212
+ const headerFor = (child, container) => (isAccordion ? partOf(/Header$/, child, container) : null);
213
+ const triggerFor = (child, container) => (isAccordion ? partOf(/Trigger$/, child, container) : null);
214
+ const contentFor = (child, container) => (archetype === "tabs" || isAccordion ? partOf(/Content$/, child, container) : null);
215
+ const listFor = (child, container) => (archetype === "tabs" ? partOf(/List$/, child, container) : null);
216
+ /** One child with its header, trigger, and content parts, when the library has them. */
217
+ const panel = (child, text, parts) => {
218
+ const labelInput = textInput(child);
219
+ const trigger = parts.trigger ? element(parts.trigger, { prefer: "button", extra: ['type="button"'], values: textInput(parts.trigger) ? { [textInput(parts.trigger)]: text } : {}, inner: text }) : null;
220
+ // A header that doesn't name its own element is a heading, which is what a disclosure trigger sits in.
221
+ const header = parts.header ? element(parts.header, { prefer: "h3", inner: trigger ?? text }) : trigger;
222
+ const body = `${text} content.`;
223
+ const content = parts.content && archetype === "accordion" ? element(parts.content, { prefer: "div", inner: body }) : null;
224
+ // A text input (`label`) names the child, so the text between the tags is its content. A value input only tells children apart.
225
+ const named = labelInput && /^(label|title|header|heading|summary|text|name)$/.test(labelInput);
226
+ const markup = element(child, { prefer: childTag, extra: [], values: labelInput ? { [labelInput]: text } : {}, inner: header ? `${header}${content ?? body}` : named ? body : text });
227
+ return markup && childTag === "button" && markup.startsWith("<button ") ? markup.replace("<button ", '<button type="button" ') : markup;
228
+ };
229
+ /** A tab's content panel sits outside the list, tied to its tab by the same value. */
230
+ const contents = (content, labels) => (content ? labels.map((text) => element(content, { prefer: "div", values: textInput(content) ? { [textInput(content)]: text } : {}, inner: `${text} content.` })).join("") : "");
231
+ for (const container of containers.slice(0, 2)) {
232
+ const children = [...new Set([...all.filter(plain), ...near(container)])].sort(byLength);
233
+ for (const child of children.slice(0, 2)) {
234
+ const parts = { header: headerFor(child, container), trigger: triggerFor(child, container), content: contentFor(child, container) };
235
+ const list = listFor(child, container);
236
+ const one = panel(child, "One", parts);
237
+ const two = panel(child, "Two", parts);
238
+ const valueInput = textInput(container);
239
+ const first = valueInput ? { [valueInput]: "One" } : {};
240
+ const used = [container, child, ...[parts.header, parts.trigger, parts.content, list].filter(Boolean)];
241
+ if (!one || !two) continue;
242
+ if (list) {
243
+ const wrappedList = element(list, { prefer: "div", inner: `${one}${two}` });
244
+ const nested = wrappedList && element(container, { values: first, inner: `${wrappedList}${contents(parts.content, ["One", "Two"])}` });
245
+ if (nested) out.push(candidate(`${archetype}-list-${container.name}-${list.name}-${child.name}`, `${container.name} holding ${list.name} with two ${child.name}`, used, fixture({ archetype, pkg, parts: used, template: nested })));
246
+ }
247
+ const wrapped = element(container, { values: first, inner: `${one}${two}` });
248
+ if (wrapped) out.push(candidate(`${archetype}-group-${container.name}-${child.name}`, `${container.name} holding two ${child.name}`, used, fixture({ archetype, pkg, parts: used, template: wrapped })));
249
+ }
250
+ }
251
+ if (archetype === "accordion") {
252
+ // A single disclosure panel, with no container around it.
253
+ for (const child of [...new Set([...all.filter(plain), ...containers.flatMap(near)])].sort(byLength).slice(0, 2)) {
254
+ const parts = { header: headerFor(child, null), trigger: triggerFor(child, null), content: contentFor(child, null) };
255
+ const alone = panel(child, "Details", parts);
256
+ const used = [child, ...[parts.header, parts.trigger, parts.content].filter(Boolean)];
257
+ if (alone) out.push(candidate(`${archetype}-single-${child.name}`, `${child.name} on its own`, used, fixture({ archetype, pkg, parts: used, template: alone })));
258
+ }
259
+ }
260
+ for (const part of all.filter((p) => itemsInput(p))) {
261
+ const input = itemsInput(part);
262
+ const host = element(part, { bind: { [input]: "items" } });
263
+ if (host) out.push(candidate(`${archetype}-items-${part.name}`, `${part.name} given its ${input} input`, [part], fixture({ archetype, pkg, parts: [part], fields: ` items = ${ITEMS};`, template: host })));
264
+ }
265
+ return out;
266
+ }
267
+
268
+ const tabs = (pkg, exports) => groups("tabs", pkg, exports, { groupPattern: /(Tabs|TabGroup|TabList|TabNav|TabNavBar|TabBar|TabView)$|^Tab(s|List|Group|Nav|Bar)$/, childPattern: /Tabs?(Link|Item|Label|Panel|Trigger)?$/, childTag: "button" });
269
+
270
+ function accordions(pkg, exports) {
271
+ const out = groups("accordion", pkg, exports, { groupPattern: /(Accordion|Expansion|Collapse|Collapsible)s?$/, childPattern: /(Panel|Item|Section|Tab|Disclosure)$/, childTag: "div" });
272
+ for (const { pointer, target, input } of linked("accordion", exports, /Panel|Content|Collaps/)) {
273
+ const ref = "panel";
274
+ const trigger = element(pointer, { prefer: "button", extra: ['type="button"', "data-a11y-trigger"], bind: { [input]: ref }, inner: "Details" });
275
+ const body = element(target, { extra: [`#${ref}="${exportRef(target)}"`], inner: "More about this." });
276
+ if (trigger && body) out.push(candidate(`accordion-ref-${pointer.name}-${target.name}`, `${pointer.name} pointing at ${target.name}`, [pointer, target], fixture({ archetype: "accordion", pkg, parts: [pointer, target], template: `${trigger}${body}` })));
277
+ }
278
+ return out;
279
+ }
280
+
281
+ // ---- menu and combobox: a pointer directive and a panel it points at, with item parts ----
282
+
283
+ function menus(pkg, exports) {
284
+ const out = [];
285
+ const items = exports.filter((p) => p.angular && /Menu.*Item|Item/.test(p.name) && fits("menu", p) && p.angular.kind !== "service" && usable(p));
286
+ for (const { pointer, target, input } of linked("menu", exports, /Menu|Panel/)) {
287
+ const trigger = element(pointer, { prefer: "button", extra: ['type="button"', "data-a11y-trigger"], bind: { [input]: "m" }, inner: "Actions" });
288
+ for (const item of [items[0] ?? null]) {
289
+ const entry = item ? element(item, { prefer: "button", extra: ['type="button"'], inner: "Copy" }) : '<button type="button">Copy</button>';
290
+ const panel = element(target, { extra: [`#m="${exportRef(target)}"`], inner: entry ?? "" });
291
+ if (trigger && panel) out.push(candidate(`menu-ref-${pointer.name}-${target.name}`, `${pointer.name} pointing at ${target.name}`, [pointer, target, ...(item ? [item] : [])], fixture({ archetype: "menu", pkg, parts: [pointer, target, ...(item ? [item] : [])], template: `${trigger}${panel}` })));
292
+ }
293
+ }
294
+ for (const part of pool("menu", exports).filter((p) => p.angular.kind !== "service" && usable(p) && itemsInput(p))) {
295
+ const input = itemsInput(part);
296
+ const host = element(part, { bind: { [input]: "items" } });
297
+ if (host) out.push(candidate(`menu-items-${part.name}`, `${part.name} given its ${input} input`, [part], fixture({ archetype: "menu", pkg, parts: [part], fields: ` items = ${ITEMS};`, template: host })));
298
+ }
299
+ return out;
300
+ }
301
+
302
+ function comboboxes(pkg, exports) {
303
+ const out = [];
304
+ const options = exports.filter((p) => p.angular && /Option/.test(p.name) && p.angular.kind !== "service" && usable(p));
305
+ for (const { pointer, target, input } of linked("combobox", exports, /Listbox|Options|Panel|Autocomplete|Overlay|Popup/)) {
306
+ const field = element(pointer, { prefer: "input", extra: ['type="text"', 'aria-label="Fruit"', "data-a11y-trigger"], bind: { [input]: "l" } });
307
+ const option = options[0] ? element(options[0], { prefer: "div", inner: "Apple" }) : '<div role="option">Apple</div>';
308
+ const panel = element(target, { extra: [`#l="${exportRef(target)}"`], inner: option ?? "" });
309
+ const used = [pointer, target, ...(options[0] ? [options[0]] : [])];
310
+ if (field && panel) out.push(candidate(`combobox-ref-${pointer.name}-${target.name}`, `${pointer.name} pointing at ${target.name}`, used, fixture({ archetype: "combobox", pkg, parts: used, template: `${field}${panel}` })));
311
+ }
312
+ for (const part of pool("combobox", exports).filter((p) => p.angular.kind === "component" && usable(p) && (p.angular.inputs ?? []).some((name) => /^(options|items|suggestions)$/.test(name)))) {
313
+ const input = (part.angular.inputs ?? []).find((name) => /^(options|items|suggestions)$/.test(name));
314
+ const host = element(part, { prefer: "div", bind: { [input]: "items" }, extra: ['aria-label="Fruit"'] });
315
+ if (host) out.push(candidate(`combobox-items-${part.name}`, `${part.name} given its ${input} input`, [part], fixture({ archetype: "combobox", pkg, parts: [part], fields: ` items = ${ITEMS};`, template: host })));
316
+ }
317
+ return out;
318
+ }
319
+
320
+ // ---- dialog ----
321
+
322
+ const CONTENT = `Component({ selector: "app-dialog-content", template: '<h2>Edit profile</h2><p>Update your details.</p><button type="button">Close</button>' })(DialogContent);\n`;
323
+
324
+ function dialogs(pkg, exports) {
325
+ const out = [];
326
+ const all = pool("dialog", exports);
327
+ for (const service of all.filter((p) => p.angular.kind === "service")) {
328
+ const method = (service.angular.methods ?? []).find((name) => /^open/i.test(name));
329
+ if (!method) continue;
330
+ out.push(candidate(`dialog-service-${service.name}`, `${service.name}.${method}() with a small content component`, [service], fixture({
331
+ archetype: "dialog", pkg, parts: [service], before: `class DialogContent {}\n${CONTENT}`, fields: ` dialog = inject(${service.name});\n content = DialogContent;`,
332
+ template: `<button type="button" data-a11y-trigger (click)="dialog.${method}(content)">Open dialog</button>`,
333
+ })));
334
+ }
335
+ for (const part of all.filter((p) => p.angular.kind === "component" && usable(p))) {
336
+ const shownBy = (part.angular.inputs ?? []).find((name) => /^(open|opened|visible|show|isOpen|display)$/.test(name));
337
+ if (!shownBy) continue;
338
+ const closers = (part.angular.outputs ?? []).filter((name) => /close|hide|Change$/.test(name)).map((name) => (/Change$/.test(name) ? `(${name})="shown.set($event === true)"` : `(${name})="shown.set(false)"`));
339
+ const host = element(part, { extra: closers, bind: { [shownBy]: "shown()" }, inner: '<h2>Edit profile</h2><p>Update your details.</p><button type="button" (click)="shown.set(false)">Close</button>' });
340
+ if (host) out.push(candidate(`dialog-input-${part.name}`, `${part.name} shown through its ${shownBy} input`, [part], fixture({ archetype: "dialog", pkg, parts: [part], fields: " shown = signal(false);", template: `<button type="button" data-a11y-trigger (click)="shown.set(true)">Open dialog</button>${host}` })));
341
+ }
342
+ return out;
343
+ }
344
+
345
+ const BUILDERS = { dialog: dialogs, menu: menus, tooltip: tooltips, tabs, accordion: accordions, combobox: comboboxes, "form-field": fields, "live-region": messages };
346
+
347
+ /**
348
+ * Candidates for an Angular package, from what discovery read off its classes.
349
+ * @param {{ archetype: string, pkg: string, exports: Array<{ name: string, angular?: AngularExportInfo }> }} input
350
+ * @returns {{ candidates: Array<{ id: string, summary: string, source: string, used: string[] }>, reason: string | null }}
351
+ */
352
+ export function generateAngular({ archetype, pkg, exports }) {
353
+ if (!GENERATABLE.has(archetype)) return { candidates: [], reason: `Nothing is generated for the ${archetype} archetype.` };
354
+ const found = BUILDERS[archetype](pkg, exports);
355
+ const seen = new Set();
356
+ const candidates = found.filter((c) => (seen.has(c.id) ? false : seen.add(c.id))).slice(0, ATTEMPT_LIMIT);
357
+ return candidates.length
358
+ ? { candidates, reason: null }
359
+ : { candidates: [], reason: `No Angular class looks like the ${archetype} archetype and shows a way to wire one (a selector, an input, or a service method), so a fixture has to be written.` };
360
+ }
@@ -1,3 +1,4 @@
1
+ import { explainAngularError } from "../../frameworks/angular-errors.js";
1
2
  import { installHelpers } from "../../tiers/interactions/helpers.js";
2
3
  import { openPage } from "../url.js";
3
4
  import { ROOT_ROLES } from "./marking.js";
@@ -25,9 +26,9 @@ export async function probeFixture(browser, url, archetype) {
25
26
  opened = await openPage(browser, url, {
26
27
  waitUntil: "load",
27
28
  beforeGoto: async (page) => {
28
- page.on("pageerror", (error) => errors.push(error.message.split("\n")[0]));
29
+ page.on("pageerror", (error) => errors.push(explainAngularError(error.message.split("\n")[0])));
29
30
  page.on("console", (message) => {
30
- if (message.type() === "error" && !/favicon|Failed to load resource/i.test(message.text())) errors.push(message.text().split("\n")[0]);
31
+ if (message.type() === "error" && !/favicon|Failed to load resource/i.test(message.text())) errors.push(explainAngularError(message.text().split("\n")[0]));
31
32
  });
32
33
  await page.addInitScript(installHelpers);
33
34
  },
@@ -60,8 +61,8 @@ export async function probeFixture(browser, url, archetype) {
60
61
  } catch (error) {
61
62
  return { ok: false, reason: `the trigger couldn't be activated: ${error instanceof Error ? error.message.split("\n")[0] : String(error)}` };
62
63
  }
63
- try {
64
- await page.waitForFunction(
64
+ /** Wait for the surface. A tooltip gets a second chance below, so this one is short for it. */
65
+ const waitForSurface = (timeout) => page.waitForFunction(
65
66
  ({ live, needsRoot }) => {
66
67
  const state = window.__a11y.live();
67
68
  if (live) return state.present && state.visible && Boolean(state.text || state.named);
@@ -71,8 +72,18 @@ export async function probeFixture(browser, url, archetype) {
71
72
  return (state.present && state.visible) || trigger?.getAttribute("aria-expanded") === "true";
72
73
  },
73
74
  { live: archetype === "live-region", needsRoot: Boolean(ROOT_ROLES[archetype]) },
74
- { timeout: 4000 },
75
+ { timeout },
75
76
  );
77
+ try {
78
+ try {
79
+ await waitForSurface(archetype === "tooltip" ? 1500 : 4000);
80
+ } catch (error) {
81
+ if (archetype !== "tooltip") throw error;
82
+ // Some tooltips open only for focus that came from the keyboard, so focus the trigger again the way a person would.
83
+ await page.evaluate(() => /** @type {HTMLElement | null} */ (document.activeElement)?.blur());
84
+ await page.keyboard.press("Tab");
85
+ await waitForSurface(3000);
86
+ }
76
87
  } catch {
77
88
  return { ok: false, reason: `activating the trigger showed no ${archetype === "live-region" ? "message" : "element marked as the root"}${ROOT_ROLES[archetype] ? ` (looked for an element with role ${ROOT_ROLES[archetype].join(", ")})` : ""}, which can also mean the library doesn't set that role` };
78
89
  }
@@ -35,7 +35,7 @@ export function installedVersion(dir, name) {
35
35
  * Install a package into its own directory, never next to another target's install.
36
36
  * npm adds the peer dependencies. A React package also needs react-dom, and a Vue package needs vue, so add them when the peers left them out.
37
37
  * Install scripts stay off, because the code is untrusted until it runs in the browser sandbox.
38
- * @param {{ dir: string, name: string, version: string, flavor: "react" | "vue" | "wc" | "unknown", run?: typeof runNpm }} options
38
+ * @param {{ dir: string, name: string, version: string, flavor: "react" | "vue" | "angular" | "html" | "wc" | "unknown", run?: typeof runNpm }} options
39
39
  */
40
40
  export async function installPackage({ dir, name, version, flavor, run = runNpm }) {
41
41
  mkdirSync(dir, { recursive: true });
@@ -66,23 +66,55 @@ export async function installPackage({ dir, name, version, flavor, run = runNpm
66
66
  if (flavor === "react") {
67
67
  const react = installedVersion(dir, "react");
68
68
  if (!react) {
69
- await npm(["install", "react", "react-dom", ...NPM_FLAGS, "--legacy-peer-deps"]);
69
+ await npm(["install", "react", "react-dom", ...pinnedRuntime(dir, ["react", "react-dom"]), ...NPM_FLAGS, "--legacy-peer-deps"]);
70
70
  warnings.push(`${name} didn't bring in react, so the latest react and react-dom were added.`);
71
71
  } else if (!installedVersion(dir, "react-dom")) {
72
72
  // Name react too. A loose install prunes a package that only arrived as a peer, and react did.
73
- await npm(["install", `react@${react}`, `react-dom@${react}`, ...NPM_FLAGS, "--legacy-peer-deps"]);
73
+ await npm(["install", `react@${react}`, `react-dom@${react}`, ...pinnedRuntime(dir, ["react", "react-dom"]), ...NPM_FLAGS, "--legacy-peer-deps"]);
74
74
  }
75
75
  }
76
76
  if (flavor === "vue" && !installedVersion(dir, "vue")) {
77
- await npm(["install", "vue", ...NPM_FLAGS, "--legacy-peer-deps"]);
77
+ await npm(["install", "vue", ...pinnedRuntime(dir, ["vue"]), ...NPM_FLAGS, "--legacy-peer-deps"]);
78
78
  warnings.push(`${name} didn't bring in vue, so the latest vue was added.`);
79
79
  }
80
- return { dir, warnings, react: installedVersion(dir, "react"), reactDom: installedVersion(dir, "react-dom"), vue: installedVersion(dir, "vue"), version: installedVersion(dir, name) };
80
+ if (flavor === "angular") {
81
+ // The Angular packages have to be the same version as core. A library's peers usually bring in core and common, not the compiler or the platform.
82
+ const core = installedVersion(dir, "@angular/core");
83
+ const missing = ANGULAR_RUNTIME.filter((pkg) => !installedVersion(dir, pkg));
84
+ if (!core) {
85
+ await npm(["install", ...ANGULAR_RUNTIME, ...pinnedRuntime(dir, ANGULAR_RUNTIME), ...NPM_FLAGS, "--legacy-peer-deps"]);
86
+ warnings.push(`${name} didn't bring in @angular/core, so the latest Angular packages were added.`);
87
+ } else if (missing.length) {
88
+ // Name everything already installed too. A loose install prunes a package that only arrived as a peer.
89
+ const adding = missing.map((pkg) => (pkg.startsWith("@angular/") ? `${pkg}@${core}` : pkg));
90
+ await npm(["install", ...adding, ...pinnedRuntime(dir, adding), ...NPM_FLAGS, "--legacy-peer-deps"]);
91
+ }
92
+ }
93
+ return { dir, warnings, react: installedVersion(dir, "react"), reactDom: installedVersion(dir, "react-dom"), vue: installedVersion(dir, "vue"), angular: installedVersion(dir, "@angular/core"), version: installedVersion(dir, name) };
81
94
  }
82
95
 
83
- /** The installed framework runtimes (React, React DOM, Vue), named again so a loose install can't prune them. */
84
- function pinnedRuntime(dir) {
85
- return ["react", "react-dom", "vue"].flatMap((name) => (installedVersion(dir, name) ? [`${name}@${installedVersion(dir, name)}`] : []));
96
+ /** What an Angular fixture needs beside the library: core, the runtime compiler, the platform, and the pieces core uses. */
97
+ const ANGULAR_RUNTIME = ["@angular/core", "@angular/common", "@angular/compiler", "@angular/platform-browser", "rxjs"];
98
+
99
+ /**
100
+ * Every package installed at the top level, as exact specs. A loose install (--legacy-peer-deps) prunes a package that only arrived
101
+ * as someone's peer, which is how React, and then Angular's CDK, went missing. Naming everything that's already there keeps it all.
102
+ * A spec for a package in `except` is left out, because the caller is installing that one at a different version.
103
+ * @param {string} dir
104
+ * @param {string[]} [except] Package names, or specs such as `name@range`.
105
+ * @returns {string[]}
106
+ */
107
+ function pinnedRuntime(dir, except = []) {
108
+ const skip = new Set(except.map((spec) => spec.replace(/^(@?[^@]+)@.*$/, "$1")));
109
+ const root = join(dir, "node_modules");
110
+ /** @type {string[]} */
111
+ const names = [];
112
+ for (const entry of existsSync(root) ? readdirSync(root) : []) {
113
+ if (entry.startsWith(".")) continue;
114
+ if (entry.startsWith("@")) for (const inner of readdirSync(join(root, entry))) names.push(`${entry}/${inner}`);
115
+ else names.push(entry);
116
+ }
117
+ return names.filter((name) => !skip.has(name)).flatMap((name) => (installedVersion(dir, name) ? [`${name}@${installedVersion(dir, name)}`] : []));
86
118
  }
87
119
 
88
120
  const PACKAGE_SPEC = /^(@[a-z0-9~][\w.~-]*\/)?[a-z0-9~][\w.~-]*(@[\w.^~<>=*|-]+)?$/i;
@@ -98,7 +130,7 @@ export async function installExtraPackages({ dir, specs, run = runNpm }) {
98
130
  if (wanted.length === 0) return { installed: [], warnings: [] };
99
131
  for (const spec of wanted) if (!PACKAGE_SPEC.test(spec)) throw new Error(`The mapping's install list has "${spec}", which isn't a package name with an optional version.`);
100
132
  try {
101
- await run(["install", ...wanted, ...pinnedRuntime(dir), "--legacy-peer-deps", ...NPM_FLAGS], dir);
133
+ await run(["install", ...wanted, ...pinnedRuntime(dir, wanted), "--legacy-peer-deps", ...NPM_FLAGS], dir);
102
134
  } catch (error) {
103
135
  throw new Error(`npm couldn't install ${wanted.join(", ")}: ${firstLine(error.message)}`);
104
136
  }
@@ -145,8 +177,8 @@ export async function installOptionalPeers({ dir, unresolved, run = runNpm }) {
145
177
  const wanted = unresolved.filter((name) => declared.has(name) && !installedVersion(dir, name));
146
178
  if (wanted.length === 0) return { installed: [], warnings: [] };
147
179
  // A loose install prunes packages that only arrived as peers, so name the framework again to keep it.
148
- const keep = pinnedRuntime(dir);
149
- const specs = [...wanted.map((name) => `${name}@${declared.get(name)}`), ...keep];
180
+ const adding = wanted.map((name) => `${name}@${declared.get(name)}`);
181
+ const specs = [...adding, ...pinnedRuntime(dir, adding)];
150
182
  try {
151
183
  await run(["install", ...specs, "--legacy-peer-deps", ...NPM_FLAGS], dir);
152
184
  } catch (error) {
@@ -18,7 +18,7 @@ export function recordClosedShadowRoots() {
18
18
  * @returns {Promise<Record<string, number>>}
19
19
  */
20
20
  export async function closedShadowHosts(page) {
21
- const hosts = await page.evaluate(() => /** @type {any} */ (window).__a11yClosedShadowHosts ?? []).catch(() => []);
21
+ const hosts = await page.evaluate(() => window.__a11yClosedShadowHosts ?? []).catch(() => []);
22
22
  /** @type {Record<string, number>} */
23
23
  const counts = {};
24
24
  for (const host of hosts) counts[host] = (counts[host] ?? 0) + 1;
@@ -16,20 +16,32 @@ export const ARCHETYPE_PATTERNS = {
16
16
  chart: /\bcharts?\b|\bgraphs?\b|\bplots?\b/i,
17
17
  };
18
18
 
19
+ /** @param {unknown} value @returns {value is Record<string, unknown>} */
20
+ function isRecord(value) {
21
+ return typeof value === "object" && value !== null && !Array.isArray(value);
22
+ }
23
+
24
+ /** @param {unknown} value @returns {value is unknown[]} */
25
+ function isArray(value) {
26
+ return Array.isArray(value);
27
+ }
28
+
19
29
  /**
20
30
  * Pull the stories out of a Storybook index. Handles `index.json` (`entries`, with docs pages) and the older `stories.json`.
21
- * @param {any} index
31
+ * @param {unknown} index
22
32
  * @returns {Array<{ id: string, title: string, name: string, tags: string[] }>}
23
33
  */
24
34
  export function listStories(index) {
25
- const table = index.entries ?? index.stories ?? {};
35
+ const data = isRecord(index) ? index : {};
36
+ const table = (isRecord(data.entries) ? data.entries : null) ?? (isRecord(data.stories) ? data.stories : {});
26
37
  return Object.values(table)
27
- .filter((entry) => entry && typeof entry === "object" && (entry.type === undefined || entry.type === "story"))
38
+ .filter(isRecord)
39
+ .filter((entry) => entry.type === undefined || entry.type === "story")
28
40
  .map((entry) => ({
29
41
  id: String(entry.id),
30
42
  title: String(entry.title ?? entry.kind ?? ""),
31
43
  name: String(entry.name ?? entry.story ?? ""),
32
- tags: Array.isArray(entry.tags) ? entry.tags.map(String) : [],
44
+ tags: isArray(entry.tags) ? entry.tags.map(String) : [],
33
45
  }))
34
46
  .sort((a, b) => a.id.localeCompare(b.id));
35
47
  }
@@ -88,7 +100,8 @@ export function storyUrl(base, id) {
88
100
  /**
89
101
  * Read a Storybook's index from disk or over HTTP.
90
102
  * @param {{ path?: string | null, url?: string | null, index: string }} resolved
91
- * @param {Function} [doFetch]
103
+ * @param {(url: URL, options?: { signal?: AbortSignal }) => Promise<{ ok: boolean, status: number, json(): Promise<unknown> }>} [doFetch]
104
+ * @returns {Promise<unknown>}
92
105
  */
93
106
  export async function readIndex(resolved, doFetch = globalThis.fetch) {
94
107
  if (resolved.path) return JSON.parse(readFileSync(join(resolved.path, resolved.index), "utf8"));
@@ -36,6 +36,7 @@ export async function buildPlan({ command, targets, options, browserVersion = nu
36
36
  kind: target.kind,
37
37
  evidenceLevel: target.evidenceLevel,
38
38
  resolved: target.resolved,
39
+ ...(target.companions?.length ? { companions: target.companions } : {}),
39
40
  mapping: null,
40
41
  };
41
42
  });