import { SchematicsException, Rule, SchematicContext, Tree, chain } from '@angular-devkit/schematics';
import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks';
import * as path from 'node:path';
import ts from 'typescript';
import { Schema } from './schema';
const PLAYWRIGHT_VERSION = '^1.61.1';
const AXE_PLAYWRIGHT_VERSION = '^4.10.2';
const TYPES_NODE_VERSION = '^20.11.0';
export function e2e(options: Schema): Rule {
return chain([
addPlaywrightDependency,
addPackageScripts,
extendVerificationScript,
addPlaywrightSkipReporter,
addDeterministicHarnessFoundation,
options.registerRoute ? registerHarnessParentRoute(options) : (tree) => tree,
addPlaywrightConfig,
installPackages,
]);
}
function addPlaywrightDependency(tree: Tree): void {
const packageJson = readJson(tree, '/package.json');
const devDependencies = packageJson.devDependencies ?? {};
packageJson.devDependencies = {
...devDependencies,
'@axe-core/playwright': devDependencies['@axe-core/playwright'] ?? AXE_PLAYWRIGHT_VERSION,
'@playwright/test': devDependencies['@playwright/test'] ?? PLAYWRIGHT_VERSION,
'@types/node': devDependencies['@types/node'] ?? TYPES_NODE_VERSION,
};
writeJson(tree, '/package.json', packageJson);
}
function addPackageScripts(tree: Tree): void {
const packageJson = readJson(tree, '/package.json');
const scripts = packageJson.scripts ?? {};
packageJson.scripts = {
...scripts,
e2e: scripts.e2e ?? 'playwright test',
'e2e:ui': scripts['e2e:ui'] ?? 'playwright test --ui',
'e2e:report': scripts['e2e:report'] ?? 'playwright show-report',
};
writeJson(tree, '/package.json', packageJson);
}
/** Folds `npm run e2e` into a `verify` script that `ng add` may have already created without it. */
function extendVerificationScript(tree: Tree): void {
const packageJson = readJson(tree, '/package.json');
const scripts = packageJson.scripts ?? {};
if (!scripts.verify || scripts.verify.includes('npm run e2e')) {
return;
}
packageJson.scripts = {
...scripts,
verify: `${scripts.verify} && npm run e2e`,
};
writeJson(tree, '/package.json', packageJson);
}
function addPlaywrightConfig(tree: Tree): void {
if (tree.exists('/playwright.config.ts')) {
return;
}
tree.create(
'/playwright.config.ts',
`///
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './src',
testMatch: '**/*.e2e-spec.ts',
fullyParallel: true,
forbidOnly: !!process.env['CI'],
retries: process.env['CI'] ? 2 : 0,
workers: process.env['CI'] ? 1 : undefined,
reporter: [['html'], ['./semmet-playwright-reporter.mjs']],
use: {
baseURL: 'http://localhost:4200',
trace: 'on-first-retry',
},
webServer: {
command: 'npm run start',
url: 'http://localhost:4200',
reuseExistingServer: !process.env['CI'],
timeout: 120 * 1000,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
`
);
}
function addPlaywrightSkipReporter(tree: Tree): void {
const reporterPath = '/semmet-playwright-reporter.mjs';
if (tree.exists(reporterPath)) {
return;
}
tree.create(
reporterPath,
`export default class SemmetPlaywrightReporter {
skippedTests = [];
onTestEnd(test, result) {
if (result.status === 'skipped') {
this.skippedTests.push(test.titlePath().join(' > '));
}
}
onEnd() {
if (this.skippedTests.length === 0) {
return;
}
console.error('\\nSemmet Angular: skipped Playwright tests are not allowed.');
for (const title of this.skippedTests) {
console.error(\`- \${title}\`);
}
console.error('Mount every generated component in a deterministic test route or remove the unused spec.');
return { status: 'failed' };
}
}
`
);
}
function addDeterministicHarnessFoundation(tree: Tree): void {
const routesPath = '/src/app/semmet-e2e/semmet-e2e.routes.ts';
const pathsPath = '/src/app/semmet-e2e/semmet-e2e.paths.ts';
const accessibilitySpecPath = '/src/app/semmet-e2e/semmet-a11y.e2e-spec.ts';
const readmePath = '/src/app/semmet-e2e/README.md';
if (!tree.exists(routesPath)) {
tree.create(
routesPath,
`import { Routes } from '@angular/router';
/**
* Development-only routes used by generated Semmet Playwright specs.
*
* Mount each generated component at /__semmet-e2e/ and expose
* this array under the /__semmet-e2e parent route in a non-production config.
*/
export const SEMMET_E2E_ROUTES: Routes = [];
`
);
}
if (!tree.exists(pathsPath)) {
tree.create(
pathsPath,
`/** Routes audited automatically with axe-core by the generated Playwright suite. */
export const SEMMET_E2E_PATHS: readonly string[] = [];
`
);
}
if (!tree.exists(accessibilitySpecPath)) {
tree.create(
accessibilitySpecPath,
`import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
import { SEMMET_E2E_PATHS } from './semmet-e2e.paths';
for (const route of SEMMET_E2E_PATHS) {
test(\`\${route} has no automatically detectable accessibility violations\`, async ({ page }) => {
await page.goto(route);
const harness = page.locator('[data-semmet-e2e-host]');
await expect(
harness,
\`Semmet E2E harness was not mounted at \${route}. Register SEMMET_E2E_ROUTES under /__semmet-e2e and ensure the application shell renders a RouterOutlet.\`
).toHaveCount(1);
const results = await new AxeBuilder({ page })
.include('[data-semmet-e2e-host]')
.analyze();
expect(
results.violations,
results.violations
.map((violation) => \`\${violation.id}: \${violation.help} (\${violation.nodes.length} node(s))\`)
.join('\\n')
).toEqual([]);
});
}
`
);
}
if (!tree.exists(readmePath)) {
tree.create(
readmePath,
`# Semmet deterministic E2E harness
Generated component specs navigate to \`/__semmet-e2e/\`.
When this schematic is configured first, every component generated with \`--e2e\` receives a
small host component and is registered in \`SEMMET_E2E_ROUTES\`. Expose these routes only in
local/test builds. The generated Playwright suite also audits every registered route with axe-core.
The reporter fails the run when a spec is skipped, so a missing mount cannot produce a false-green CI.
Run \`ng generate semmet-angular:e2e --register-route\` to register the parent route in a recognized
\`app.routes.ts\`. The application shell must also render a \`RouterOutlet\`. Review the registration
before production deployment and keep the harness routes limited to local/test builds.
`
);
}
}
function registerHarnessParentRoute(options: Schema): Rule {
return (tree, context) => {
const project = resolveProject(tree, options.project);
const sourceRoot = trimSlashes(
String(project.sourceRoot ?? path.posix.join(String(project.root ?? ''), 'src')),
);
const routesPath = `/${path.posix.join(sourceRoot, 'app/app.routes.ts')}`;
if (!tree.exists(routesPath)) {
throw new SchematicsException(
`Could not find ${routesPath}. Re-run without --register-route and register SEMMET_E2E_ROUTES manually.`,
);
}
const sourceText = tree.readText(routesPath);
const sourceFile = ts.createSourceFile(routesPath, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
const routesArray = findRoutesArray(sourceFile);
if (!routesArray) {
throw new SchematicsException(
`Could not find a literal Routes array in ${routesPath}. Register SEMMET_E2E_ROUTES manually.`,
);
}
if (hasRoute(routesArray, '__semmet-e2e')) {
return;
}
const importPath = './semmet-e2e/semmet-e2e.routes';
const withImport = addNamedImport(
sourceText,
importPath,
'SEMMET_E2E_ROUTES',
);
const reparsed = ts.createSourceFile(routesPath, withImport, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
const updatedRoutesArray = findRoutesArray(reparsed);
if (!updatedRoutesArray) {
throw new SchematicsException(`Could not update the Routes array in ${routesPath}.`);
}
const entry = `{\n path: '__semmet-e2e',\n children: SEMMET_E2E_ROUTES,\n }`;
const hasExistingRoutes = updatedRoutesArray.elements.length > 0;
const insertionPoint = hasExistingRoutes
? updatedRoutesArray.elements[0].getStart(reparsed)
: updatedRoutesArray.elements.end;
const insertion = hasExistingRoutes ? `${entry},\n ` : `\n ${entry},\n`;
tree.overwrite(
routesPath,
`${withImport.slice(0, insertionPoint)}${insertion}${withImport.slice(insertionPoint)}`,
);
context.logger.warn(
'Registered /__semmet-e2e in app.routes.ts. Ensure the application shell renders RouterOutlet and exclude this route from production.',
);
};
}
function resolveProject(tree: Tree, requestedProject: string | undefined): Record {
const workspace = readJson(tree, '/angular.json');
const projects = (workspace.projects ?? {}) as Record>;
const projectName =
requestedProject ??
(typeof workspace.defaultProject === 'string' ? workspace.defaultProject : undefined) ??
Object.keys(projects)[0];
if (!projectName || !projects[projectName]) {
throw new SchematicsException('Could not resolve the Angular project for E2E route registration.');
}
return projects[projectName];
}
function findRoutesArray(sourceFile: ts.SourceFile): ts.ArrayLiteralExpression | undefined {
let fallback: ts.ArrayLiteralExpression | undefined;
for (const statement of sourceFile.statements) {
if (!ts.isVariableStatement(statement)) continue;
for (const declaration of statement.declarationList.declarations) {
if (!declaration.initializer || !ts.isArrayLiteralExpression(declaration.initializer)) continue;
fallback ??= declaration.initializer;
if (ts.isIdentifier(declaration.name) && declaration.name.text.toLowerCase().includes('routes')) {
return declaration.initializer;
}
}
}
return fallback;
}
function hasRoute(routes: ts.ArrayLiteralExpression, route: string): boolean {
return routes.elements.some((element) =>
ts.isObjectLiteralExpression(element) &&
element.properties.some((property) =>
ts.isPropertyAssignment(property) &&
property.name.getText() === 'path' &&
ts.isStringLiteralLike(property.initializer) &&
property.initializer.text === route,
),
);
}
function addNamedImport(source: string, moduleName: string, name: string): string {
const escapedModule = moduleName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const importPattern = new RegExp(`import\\s*\\{([^}]*)\\}\\s*from\\s*['"]${escapedModule}['"];?`);
const existing = source.match(importPattern);
if (existing) {
const names = existing[1].split(',').map((value) => value.trim()).filter(Boolean);
if (names.includes(name)) return source;
return source.replace(importPattern, `import { ${[...names, name].join(', ')} } from '${moduleName}';`);
}
const lastImport = [...source.matchAll(/^import .*;$/gm)].at(-1);
const insertionPoint = lastImport ? (lastImport.index ?? 0) + lastImport[0].length : 0;
return `${source.slice(0, insertionPoint)}${insertionPoint ? '\n' : ''}import { ${name} } from '${moduleName}';${source.slice(insertionPoint)}`;
}
function trimSlashes(value: string): string {
return value.replace(/^\/+|\/+$/g, '');
}
function installPackages(_tree: Tree, context: SchematicContext): void {
context.addTask(new NodePackageInstallTask());
}
function readJson(tree: Tree, path: string): Record {
const content = tree.readText(path);
return JSON.parse(content) as Record;
}
function writeJson(tree: Tree, path: string, value: Record): void {
tree.overwrite(path, `${JSON.stringify(value, null, 2)}\n`);
}