Skip to content

Commit 5f2df75

Browse files
committed
feat(plugin-axe): add setup wizard binding
1 parent b3fcaad commit 5f2df75

File tree

11 files changed

+329
-19
lines changed

11 files changed

+329
-19
lines changed

packages/create-cli/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,15 @@ Each plugin exposes its own configuration keys that can be passed as CLI argumen
7272
| **`--lighthouse.urls`** | `string \| string[]` | `http://localhost:4200` | Target URL(s) (comma-separated) |
7373
| **`--lighthouse.categories`** | `('performance'` \| `'a11y'` \| `'best-practices'` \| `'seo')[]` | all | Lighthouse categories |
7474

75+
#### Axe
76+
77+
| Option | Type | Default | Description |
78+
| ----------------------- | ------------------------------------------------------------ | ----------------------- | ------------------------------------------ |
79+
| **`--axe.urls`** | `string \| string[]` | `http://localhost:4200` | Target URL(s) (comma-separated) |
80+
| **`--axe.preset`** | `'wcag21aa'` \| `'wcag22aa'` \| `'best-practice'` \| `'all'` | `wcag21aa` | Accessibility preset |
81+
| **`--axe.setupScript`** | `boolean` | `false` | Create setup script for auth-protected app |
82+
| **`--axe.categories`** | `boolean` | `true` | Add Axe categories |
83+
7584
### Examples
7685

7786
Run interactively (default):

packages/create-cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
},
2727
"type": "module",
2828
"dependencies": {
29+
"@code-pushup/axe-plugin": "0.124.0",
2930
"@code-pushup/coverage-plugin": "0.124.0",
3031
"@code-pushup/eslint-plugin": "0.124.0",
3132
"@code-pushup/js-packages-plugin": "0.124.0",

packages/create-cli/src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#! /usr/bin/env node
22
import yargs from 'yargs';
33
import { hideBin } from 'yargs/helpers';
4+
import { axeSetupBinding } from '@code-pushup/axe-plugin';
45
import { coverageSetupBinding } from '@code-pushup/coverage-plugin';
56
import { eslintSetupBinding } from '@code-pushup/eslint-plugin';
67
import { jsPackagesSetupBinding } from '@code-pushup/js-packages-plugin';
@@ -15,13 +16,14 @@ import {
1516
} from './lib/setup/types.js';
1617
import { runSetupWizard } from './lib/setup/wizard.js';
1718

18-
// TODO: create, import and pass remaining plugin bindings (jsdocs, axe)
19+
// TODO: create, import and pass remaining plugin bindings (jsdocs)
1920
const bindings: PluginSetupBinding[] = [
2021
eslintSetupBinding,
2122
coverageSetupBinding,
2223
jsPackagesSetupBinding,
2324
typescriptSetupBinding,
2425
lighthouseSetupBinding,
26+
axeSetupBinding,
2527
];
2628

2729
const argv = await yargs(hideBin(process.argv))

packages/create-cli/src/lib/setup/codegen-categories.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ function addCategoryRefs(
8585
refsExpressions: MergedCategory['refsExpressions'],
8686
depth: number,
8787
): void {
88+
if (refsExpressions.length === 1 && refs.length === 0) {
89+
builder.addLine(`refs: ${refsExpressions[0]},`, depth);
90+
return;
91+
}
8892
builder.addLine('refs: [', depth);
8993
builder.addLines(
9094
refsExpressions.map(expr => `...${expr},`),

packages/create-cli/src/lib/setup/codegen.unit.test.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -490,9 +490,7 @@ describe('generateConfigSource', () => {
490490
{
491491
slug: 'performance',
492492
title: 'Performance',
493-
refs: [
494-
...lighthouseGroupRefs(lhPlugin, 'performance'),
495-
],
493+
refs: lighthouseGroupRefs(lhPlugin, 'performance'),
496494
},
497495
],
498496
} satisfies CoreConfig;

packages/plugin-axe/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ export {
1212
axeGroupRefs,
1313
} from './lib/utils.js';
1414
export { axeCategories } from './lib/categories.js';
15+
export { axeSetupBinding } from './lib/binding.js';
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { createRequire } from 'node:module';
2+
import type {
3+
CategoryCodegenConfig,
4+
PluginAnswer,
5+
PluginSetupBinding,
6+
PluginSetupTree,
7+
} from '@code-pushup/models';
8+
import {
9+
answerBoolean,
10+
answerNonEmptyArray,
11+
answerString,
12+
singleQuote,
13+
} from '@code-pushup/utils';
14+
import {
15+
AXE_DEFAULT_PRESET,
16+
AXE_PLUGIN_SLUG,
17+
AXE_PLUGIN_TITLE,
18+
AXE_PRESET_NAMES,
19+
} from './constants.js';
20+
21+
const { name: PACKAGE_NAME } = createRequire(import.meta.url)(
22+
'../../package.json',
23+
) as typeof import('../../package.json');
24+
25+
const DEFAULT_URL = 'http://localhost:4200';
26+
const PLUGIN_VAR = 'axe';
27+
const SETUP_SCRIPT_PATH = './axe-setup.ts';
28+
29+
const CATEGORIES: CategoryCodegenConfig[] = [
30+
{
31+
slug: 'a11y',
32+
title: 'Accessibility',
33+
description: 'Tests website **accessibility** in accordance with WCAG',
34+
refsExpression: `axeGroupRefs(${PLUGIN_VAR})`,
35+
},
36+
];
37+
38+
const PRESET_CHOICES = Object.entries(AXE_PRESET_NAMES).map(
39+
([value, name]) => ({ name, value }),
40+
);
41+
42+
const SETUP_SCRIPT_CONTENT = `import type { Page } from 'playwright-core';
43+
44+
export default async function (page: Page): Promise<void> {
45+
// ... add your custom logic here ...
46+
}
47+
`;
48+
49+
type AxeOptions = {
50+
urls: [string, ...string[]];
51+
preset: string;
52+
setupScript: boolean;
53+
categories: boolean;
54+
};
55+
56+
export const axeSetupBinding = {
57+
slug: AXE_PLUGIN_SLUG,
58+
title: AXE_PLUGIN_TITLE,
59+
packageName: PACKAGE_NAME,
60+
prompts: async () => [
61+
{
62+
key: 'axe.urls',
63+
message: 'Target URL(s) (comma-separated)',
64+
type: 'input',
65+
default: DEFAULT_URL,
66+
},
67+
{
68+
key: 'axe.preset',
69+
message: 'Accessibility preset',
70+
type: 'select',
71+
choices: [...PRESET_CHOICES],
72+
default: AXE_DEFAULT_PRESET,
73+
},
74+
{
75+
key: 'axe.setupScript',
76+
message: 'Create setup script for auth-protected app?',
77+
type: 'confirm',
78+
default: false,
79+
},
80+
{
81+
key: 'axe.categories',
82+
message: 'Add Axe categories?',
83+
type: 'confirm',
84+
default: true,
85+
},
86+
],
87+
generateConfig: async (
88+
answers: Record<string, PluginAnswer>,
89+
tree: PluginSetupTree,
90+
) => {
91+
const options = parseAnswers(answers);
92+
if (options.setupScript) {
93+
await tree.write(SETUP_SCRIPT_PATH, SETUP_SCRIPT_CONTENT);
94+
}
95+
const hasCategories = options.categories;
96+
const imports = [
97+
{
98+
moduleSpecifier: PACKAGE_NAME,
99+
defaultImport: 'axePlugin',
100+
...(hasCategories ? { namedImports: ['axeGroupRefs'] } : {}),
101+
},
102+
];
103+
const pluginCall = formatPluginCall(options);
104+
105+
if (!hasCategories) {
106+
return {
107+
imports,
108+
pluginInit: [`${pluginCall},`],
109+
};
110+
}
111+
return {
112+
imports,
113+
pluginDeclaration: {
114+
identifier: PLUGIN_VAR,
115+
expression: pluginCall,
116+
},
117+
pluginInit: [`${PLUGIN_VAR},`],
118+
categories: CATEGORIES,
119+
};
120+
},
121+
} satisfies PluginSetupBinding;
122+
123+
function parseAnswers(answers: Record<string, PluginAnswer>): AxeOptions {
124+
return {
125+
urls: answerNonEmptyArray(answers, 'axe.urls', DEFAULT_URL),
126+
preset: answerString(answers, 'axe.preset') || AXE_DEFAULT_PRESET,
127+
setupScript: answerBoolean(answers, 'axe.setupScript'),
128+
categories: answerBoolean(answers, 'axe.categories'),
129+
};
130+
}
131+
132+
function formatPluginCall({ urls, preset, setupScript }: AxeOptions): string {
133+
const formattedUrls = formatUrls(urls);
134+
const options = [
135+
preset !== AXE_DEFAULT_PRESET && `preset: ${singleQuote(preset)}`,
136+
setupScript && `setupScript: ${singleQuote(SETUP_SCRIPT_PATH)}`,
137+
].filter(Boolean);
138+
139+
if (options.length === 0) {
140+
return `axePlugin(${formattedUrls})`;
141+
}
142+
return `axePlugin(${formattedUrls}, { ${options.join(', ')} })`;
143+
}
144+
145+
function formatUrls([first, ...rest]: [string, ...string[]]): string {
146+
if (rest.length === 0) {
147+
return singleQuote(first);
148+
}
149+
return `[${[first, ...rest].map(singleQuote).join(', ')}]`;
150+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import type { PluginAnswer } from '@code-pushup/models';
2+
import { createMockTree } from '@code-pushup/test-utils';
3+
import { axeSetupBinding as binding } from './binding.js';
4+
5+
const defaultAnswers: Record<string, PluginAnswer> = {
6+
'axe.urls': 'http://localhost:4200',
7+
'axe.preset': 'wcag21aa',
8+
'axe.setupScript': false,
9+
'axe.categories': true,
10+
};
11+
12+
const noCategoryAnswers: Record<string, PluginAnswer> = {
13+
...defaultAnswers,
14+
'axe.categories': false,
15+
};
16+
17+
describe('axeSetupBinding', () => {
18+
describe('prompts', () => {
19+
it('should offer preset choices with wcag21aa as default', async () => {
20+
await expect(binding.prompts!()).resolves.toIncludeAllPartialMembers([
21+
{ key: 'axe.preset', type: 'select', default: 'wcag21aa' },
22+
]);
23+
});
24+
25+
it('should default setupScript to false', async () => {
26+
await expect(binding.prompts!()).resolves.toIncludeAllPartialMembers([
27+
{ key: 'axe.setupScript', type: 'confirm', default: false },
28+
]);
29+
});
30+
});
31+
32+
describe('generateConfig with categories selected', () => {
33+
it('should declare plugin as a variable for use in category refs', async () => {
34+
const { pluginDeclaration } = await binding.generateConfig(
35+
defaultAnswers,
36+
createMockTree(),
37+
);
38+
expect(pluginDeclaration).toStrictEqual({
39+
identifier: 'axe',
40+
expression: "axePlugin('http://localhost:4200')",
41+
});
42+
});
43+
44+
it('should import axeGroupRefs helper', async () => {
45+
const { imports } = await binding.generateConfig(
46+
defaultAnswers,
47+
createMockTree(),
48+
);
49+
expect(imports).toStrictEqual([
50+
expect.objectContaining({ namedImports: ['axeGroupRefs'] }),
51+
]);
52+
});
53+
54+
it('should produce accessibility category with refs expression', async () => {
55+
const { categories } = await binding.generateConfig(
56+
defaultAnswers,
57+
createMockTree(),
58+
);
59+
expect(categories).toStrictEqual([
60+
expect.objectContaining({
61+
slug: 'a11y',
62+
refsExpression: 'axeGroupRefs(axe)',
63+
}),
64+
]);
65+
});
66+
});
67+
68+
describe('generateConfig without categories selected', () => {
69+
it('should not declare plugin as a variable', async () => {
70+
const { pluginDeclaration } = await binding.generateConfig(
71+
noCategoryAnswers,
72+
createMockTree(),
73+
);
74+
expect(pluginDeclaration).toBeUndefined();
75+
});
76+
77+
it('should not import axeGroupRefs helper', async () => {
78+
const { imports } = await binding.generateConfig(
79+
noCategoryAnswers,
80+
createMockTree(),
81+
);
82+
expect(imports[0]).not.toHaveProperty('namedImports');
83+
});
84+
85+
it('should not produce categories', async () => {
86+
const { categories } = await binding.generateConfig(
87+
noCategoryAnswers,
88+
createMockTree(),
89+
);
90+
expect(categories).toBeUndefined();
91+
});
92+
});
93+
94+
describe('setup script', () => {
95+
it('should write setup script file when confirmed', async () => {
96+
const tree = createMockTree();
97+
await binding.generateConfig(
98+
{ ...defaultAnswers, 'axe.setupScript': true },
99+
tree,
100+
);
101+
expect(tree.written.get('./axe-setup.ts')).toContain(
102+
"import type { Page } from 'playwright-core'",
103+
);
104+
});
105+
106+
it('should include setupScript in plugin call when confirmed', async () => {
107+
const { pluginDeclaration } = await binding.generateConfig(
108+
{ ...defaultAnswers, 'axe.setupScript': true },
109+
createMockTree(),
110+
);
111+
expect(pluginDeclaration!.expression).toContain(
112+
"setupScript: './axe-setup.ts'",
113+
);
114+
});
115+
116+
it('should not write setup script file when declined', async () => {
117+
const tree = createMockTree();
118+
await binding.generateConfig(defaultAnswers, tree);
119+
expect(tree.written.size).toBe(0);
120+
});
121+
});
122+
123+
it('should include non-default preset in plugin call', async () => {
124+
const { pluginDeclaration } = await binding.generateConfig(
125+
{ ...defaultAnswers, 'axe.preset': 'wcag22aa' },
126+
createMockTree(),
127+
);
128+
expect(pluginDeclaration!.expression).toContain("preset: 'wcag22aa'");
129+
});
130+
131+
it('should format multiple URLs as array', async () => {
132+
const { pluginDeclaration } = await binding.generateConfig(
133+
{
134+
...defaultAnswers,
135+
'axe.urls': 'http://localhost:4200/login, http://localhost:4200/home',
136+
},
137+
createMockTree(),
138+
);
139+
expect(pluginDeclaration!.expression).toContain(
140+
"axePlugin(['http://localhost:4200/login', 'http://localhost:4200/home']",
141+
);
142+
});
143+
});

packages/plugin-coverage/src/lib/binding.unit.test.ts

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { vol } from 'memfs';
2-
import type { PluginAnswer, PluginSetupTree } from '@code-pushup/models';
3-
import { MEMFS_VOLUME } from '@code-pushup/test-utils';
2+
import type { PluginAnswer } from '@code-pushup/models';
3+
import { MEMFS_VOLUME, createMockTree } from '@code-pushup/test-utils';
44
import { readJsonFile } from '@code-pushup/utils';
55
import { coverageSetupBinding as binding } from './binding.js';
66

@@ -22,19 +22,6 @@ const defaultAnswers: Record<string, PluginAnswer> = {
2222
'coverage.categories': true,
2323
};
2424

25-
function createMockTree(
26-
files: Record<string, string> = {},
27-
): PluginSetupTree & { written: Map<string, string> } {
28-
const written = new Map<string, string>();
29-
return {
30-
written,
31-
read: async (filePath: string) => files[filePath] ?? null,
32-
write: async (filePath: string, content: string) => {
33-
written.set(filePath, content);
34-
},
35-
};
36-
}
37-
3825
describe('coverageSetupBinding', () => {
3926
beforeEach(() => {
4027
vol.fromJSON({ '.gitkeep': '' }, MEMFS_VOLUME);

0 commit comments

Comments
 (0)