import { Rule, apply, applyTemplates, chain, filter, mergeWith, move, url } from '@angular-devkit/schematics'; import { strings } from '@angular-devkit/core'; import { createProjectSchematic } from '@schematics/angular/utility/project'; import { buildDefaultPath } from '@schematics/angular/utility/workspace'; import { parseName } from '@schematics/angular/utility/parse-name'; import { validateClassName, validateHtmlSelector } from '@schematics/angular/utility/validation'; import * as path from 'node:path'; import ts from 'typescript'; import { ComponentSchematicOptions } from './schema-types'; export interface E2eHarnessConfig { imports?: (name: string) => string[]; coreImports?: readonly string[]; template?: (name: string, selector: string) => string; classBody?: (name: string) => string; } interface AriaComponentSchematicConfig { includeSharedUnitTest?: boolean; includeSharedE2eTest?: boolean; e2eHarness?: E2eHarnessConfig; } const E2E_ROUTES_PATH = '/src/app/semmet-e2e/semmet-e2e.routes.ts'; const E2E_PATHS_PATH = '/src/app/semmet-e2e/semmet-e2e.paths.ts'; /** * Shared Rule factory for every Semmet Angular schematic. `url('./files')` resolves relative to * whichever named schematic is currently executing (per collection.json), not to this module's * location, so centralizing this logic here is safe and avoids repeating it in every schematic. */ export function createAriaComponentSchematic(config: AriaComponentSchematicConfig = {}): (options: ComponentSchematicOptions) => Rule { return createProjectSchematic((options, { project }) => { const includeSharedUnitTest = config.includeSharedUnitTest ?? true; const includeE2eTest = options.e2e ?? false; const includeSharedE2eTest = (config.includeSharedE2eTest ?? true) && includeE2eTest; options.path ??= buildDefaultPath(project); const parsedPath = parseName(options.path, options.name); const name = parsedPath.name; const selector = `${project.prefix ? `${project.prefix}-` : ''}${strings.dasherize(name)}`; validateHtmlSelector(selector); validateClassName(strings.classify(name)); const componentTemplateSource = apply(url('./files'), [ filter((path) => !path.endsWith('.e2e-spec.ts.template') || includeE2eTest), applyTemplates({ ...strings, name, selector }), move(parsedPath.path), ]); const rules = [mergeWith(componentTemplateSource)]; if (includeSharedUnitTest || includeSharedE2eTest) { const testTemplateSource = apply(url('../utils/component-test-files'), [ filter((path) => { if (path.endsWith('.spec.ts.template')) { return includeSharedUnitTest; } if (path.endsWith('.e2e-spec.ts.template')) { return includeSharedE2eTest; } return true; }), applyTemplates({ ...strings, name, selector }), move(parsedPath.path), ]); rules.push(mergeWith(testTemplateSource)); } if (includeE2eTest) { rules.push(addDeterministicE2eHarness(parsedPath.path, name, selector, config.e2eHarness)); } return chain(rules); }); } function addDeterministicE2eHarness( targetPath: string, name: string, selector: string, harnessConfig: E2eHarnessConfig | undefined, ): Rule { return (tree, context) => { if (!tree.exists(E2E_ROUTES_PATH)) { return tree; } const className = strings.classify(name); const imports = harnessConfig?.imports?.(name) ?? [className]; const coreImports = ['ChangeDetectionStrategy', 'Component', ...(harnessConfig?.coreImports ?? [])]; const harnessTemplate = harnessConfig?.template?.(name, selector) ?? `<${selector} />`; const classBody = harnessConfig?.classBody?.(name) ?? ''; const hostSource = apply(url('../utils/e2e-host-files'), [ applyTemplates({ ...strings, name, harnessImports: imports.join(', '), coreImports: [...new Set(coreImports)].join(', '), harnessTemplate: escapeTemplateLiteral(harnessTemplate), classBody, }), move(targetPath), ]); return chain([ mergeWith(hostSource), registerE2eHarnessRoute(targetPath, name), registerE2eAccessibilityPath(name), ])(tree, context); }; } function registerE2eHarnessRoute(targetPath: string, name: string): Rule { return (tree) => { const sourceText = tree.readText(E2E_ROUTES_PATH); const sourceFile = ts.createSourceFile(E2E_ROUTES_PATH, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); const routesArray = findHarnessRoutesArray(sourceFile); if (!routesArray) { throw new Error(`Could not find SEMMET_E2E_ROUTES in ${E2E_ROUTES_PATH}.`); } const routePath = strings.dasherize(name); if (hasHarnessRoute(routesArray, routePath)) { return; } const hostPath = path.posix.join(targetPath, routePath, `${routePath}.e2e-host`); let importPath = path.posix.relative(path.posix.dirname(E2E_ROUTES_PATH), hostPath); if (!importPath.startsWith('.')) { importPath = `./${importPath}`; } const entry = `{\n path: '${routePath}',\n loadComponent: () => import('${importPath}').then((host) => host.${strings.classify(name)}E2eHost),\n }`; const separator = routesArray.elements.length > 0 && !routesArray.elements.hasTrailingComma ? ',' : ''; const insertion = `${separator}\n ${entry},\n`; const insertionPoint = routesArray.elements.end; tree.overwrite( E2E_ROUTES_PATH, `${sourceText.slice(0, insertionPoint)}${insertion}${sourceText.slice(insertionPoint)}`, ); }; } function registerE2eAccessibilityPath(name: string): Rule { return (tree) => { if (!tree.exists(E2E_PATHS_PATH)) { return; } const route = `/__semmet-e2e/${strings.dasherize(name)}`; const sourceText = tree.readText(E2E_PATHS_PATH); const sourceFile = ts.createSourceFile(E2E_PATHS_PATH, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); const pathsArray = findNamedArray(sourceFile, 'SEMMET_E2E_PATHS'); if (!pathsArray) { throw new Error(`Could not find SEMMET_E2E_PATHS in ${E2E_PATHS_PATH}.`); } if ( pathsArray.elements.some( (element) => ts.isStringLiteralLike(element) && element.text === route, ) ) { return; } const separator = pathsArray.elements.length > 0 && !pathsArray.elements.hasTrailingComma ? ',' : ''; const insertion = `${separator}\n '${route}',\n`; tree.overwrite( E2E_PATHS_PATH, `${sourceText.slice(0, pathsArray.elements.end)}${insertion}${sourceText.slice(pathsArray.elements.end)}`, ); }; } function findHarnessRoutesArray(sourceFile: ts.SourceFile): ts.ArrayLiteralExpression | undefined { return findNamedArray(sourceFile, 'SEMMET_E2E_ROUTES'); } function findNamedArray(sourceFile: ts.SourceFile, variableName: string): ts.ArrayLiteralExpression | undefined { for (const statement of sourceFile.statements) { if (!ts.isVariableStatement(statement)) { continue; } for (const declaration of statement.declarationList.declarations) { if ( ts.isIdentifier(declaration.name) && declaration.name.text === variableName && declaration.initializer && ts.isArrayLiteralExpression(declaration.initializer) ) { return declaration.initializer; } } } return undefined; } function hasHarnessRoute(routes: ts.ArrayLiteralExpression, routePath: string): boolean { return routes.elements.some((element) => { if (!ts.isObjectLiteralExpression(element)) { return false; } return element.properties.some((property) => { if (!ts.isPropertyAssignment(property) || property.name.getText() !== 'path') { return false; } return ts.isStringLiteralLike(property.initializer) && property.initializer.text === routePath; }); }); } function escapeTemplateLiteral(value: string): string { return value.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${'); }