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`); }