S2 EsLint implementation
All checks were successful
Build, test and publish / verify (push) Successful in 1m40s

This commit is contained in:
2026-10-29 22:05:11 +01:00
parent 09cb26107e
commit 8f3026e6cd
22 changed files with 2282 additions and 31 deletions

View File

@@ -0,0 +1,14 @@
node_modules/
dist/
.angular/
coverage/
test-results/
unit-results/
playwright-report/
package-lock.json
out-tsc/
bazel-out/
tmp/
typings/
__screenshots__/
.history/

View File

@@ -1,4 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
"recommendations": ["angular.ng-template", "dbaeumer.vscode-eslint"]
}

View File

@@ -1,9 +1,10 @@
FROM node:24.15.0-bookworm AS build
WORKDIR /workspace
COPY package.json package-lock.json ./
RUN npm ci
COPY tools/install-hooks.mjs tools/install-hooks.mjs
RUN CI=true npm ci
COPY . .
RUN npm test && npm run build
RUN npm run lint && npm test && npm run build
FROM nginx:1.28-alpine AS runtime
ENV BACKEND_UPSTREAM=backend:8080

View File

@@ -84,3 +84,9 @@ PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/path/to/chrome npm run e2e
Browser test screenshots and failure traces are written to the ignored
`test-results` directory.
## Lint and commit formatting
See the [root README](../README.md#frontend-lint-and-formatting) for `npm run lint`,
`npm run lint:fix`, editor support, CI enforcement and the measured Prettier-only
commit hook, including installation and local opt-out.

View File

@@ -1,4 +1,5 @@
import { test, expect, Page } from '@playwright/test';
import type { School } from '../src/app/core/api-models';
const backend = process.env['PLAYWRIGHT_BASE_URL'] || 'http://127.0.0.1:8080';
let schoolToDelete: number | undefined;
@@ -32,7 +33,6 @@ test('manages a school, shared memberships and shows through the real REST API',
page,
}) => {
const schoolName = `E2E Studio ${Date.now()}`;
let schoolId: number | undefined;
const browserErrors: string[] = [];
page.on('pageerror', (error) => browserErrors.push(error.message));
await page.goto('/');
@@ -43,8 +43,9 @@ test('manages a school, shared memberships and shows through the real REST API',
response.url().endsWith('/api/dance-schools') && response.request().method() === 'POST',
);
await save(page);
schoolId = (await (await created).json()).id;
schoolToDelete = schoolId;
const school: unknown = await (await created).json();
expect(school).toEqual(expect.objectContaining({ id: expect.any(Number) }));
schoolToDelete = (school as School).id;
await page.getByRole('link', { name: new RegExp(schoolName) }).click();
await expect(page.getByRole('heading', { name: schoolName, exact: true })).toBeVisible();

View File

@@ -0,0 +1,47 @@
import js from '@eslint/js';
import { defineConfig, globalIgnores } from 'eslint/config';
import tseslint from 'typescript-eslint';
import angular from 'angular-eslint';
import prettier from 'eslint-config-prettier';
import globals from 'globals';
export default defineConfig(
globalIgnores([
'**/node_modules/**',
'**/dist/**',
'**/out-tsc/**',
'**/bazel-out/**',
'**/tmp/**',
'**/typings/**',
'**/__screenshots__/**',
'**/.history/**',
'**/.angular/**',
'**/coverage/**',
'**/test-results/**',
'**/unit-results/**',
'**/playwright-report/**',
'package-lock.json',
]),
{
files: ['**/*.{js,mjs,cjs}'],
extends: [js.configs.recommended],
languageOptions: { globals: globals.node },
},
{
files: ['**/*.ts'],
extends: [
js.configs.recommended,
tseslint.configs.recommendedTypeChecked,
angular.configs.tsRecommended,
],
languageOptions: {
parserOptions: {
project: './tsconfig.eslint.json',
tsconfigRootDir: import.meta.dirname,
},
},
processor: angular.processInlineTemplates,
},
{ files: ['**/*.html'], extends: [angular.configs.templateRecommended] },
prettier,
);

View File

@@ -0,0 +1,21 @@
import path from 'node:path';
export default {
'*.{ts,js,mjs,cjs,html,css,json,prettierrc}': (files) => {
const maintained = files.filter((file) => {
const relative = path
.relative(import.meta.dirname, file)
.split(path.sep)
.join('/');
return (
!relative.startsWith('../') &&
!/^(node_modules|dist|out-tsc|bazel-out|tmp|typings|__screenshots__|\.history|\.angular|coverage|test-results|unit-results|playwright-report)\//.test(
relative,
) &&
relative !== 'package-lock.json'
);
});
if (!maintained.length) return [];
return [`prettier --write ${maintained.map((file) => JSON.stringify(file)).join(' ')}`];
},
};

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,10 @@
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test --watch=false",
"e2e": "playwright test"
"e2e": "playwright test",
"lint": "eslint . --max-warnings 0",
"lint:fix": "eslint . --fix --max-warnings 0",
"prepare": "node tools/install-hooks.mjs"
},
"private": true,
"dependencies": {
@@ -24,10 +27,19 @@
"@angular/build": "22.2.4",
"@angular/cli": "22.2.4",
"@angular/compiler-cli": "22.2.3",
"@eslint/js": "10.0.1",
"@playwright/test": "1.64.1",
"@types/node": "22.20.5",
"angular-eslint": "22.6.0",
"eslint": "10.13.0",
"eslint-config-prettier": "10.1.8",
"globals": "17.13.0",
"husky": "9.1.7",
"jsdom": "30.1.3",
"lint-staged": "17.6.0",
"prettier": "3.9.10",
"typescript": "6.0.3",
"typescript-eslint": "8.72.1",
"vitest": "5.0.5"
},
"engines": {

View File

@@ -16,7 +16,10 @@ describe('School JSON import', () => {
afterEach(() => http.verify());
async function select() {
const selected = component.select({
target: { files: [{ text: async () => '{"formatVersion":1}' }], value: 'file.json' },
target: {
files: [{ text: () => Promise.resolve('{"formatVersion":1}') }],
value: 'file.json',
},
} as unknown as Event);
await Promise.resolve();
return { selected, request: http.expectOne('/api/dance-schools/import/preview') };

View File

@@ -0,0 +1,245 @@
/** Isolated hook acceptance checks and full-process timing; never stages this checkout. */
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { cpSync, mkdtempSync, readFileSync, writeFileSync, symlinkSync } from 'node:fs';
import { tmpdir, cpus, platform, release } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { performance } from 'node:perf_hooks';
const source = fileURLToPath(new URL('../../', import.meta.url));
const root = mkdtempSync(path.join(tmpdir(), 's2-hook-acceptance-'));
const frontend = path.join(root, 'agiledancers-fe');
const excluded = new Set([
'node_modules',
'dist',
'.angular',
'coverage',
'test-results',
'unit-results',
'playwright-report',
'_',
]);
cpSync(path.join(source, 'agiledancers-fe'), frontend, {
recursive: true,
filter: (file) => !excluded.has(path.basename(file)),
});
cpSync(path.join(source, '.husky'), path.join(root, '.husky'), {
recursive: true,
filter: (file) => !excluded.has(path.basename(file)),
});
symlinkSync(
path.join(source, 'agiledancers-fe/node_modules'),
path.join(frontend, 'node_modules'),
'dir',
);
const env = { ...process.env };
delete env.CI;
delete env.HUSKY;
function run(command, args, cwd = root, environment = env) {
return execFileSync(command, args, {
cwd,
env: environment,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trimEnd();
}
const git = (...args) => run('git', args);
const write = (file, content) => writeFileSync(path.join(root, file), content);
const read = (file) => readFileSync(path.join(root, file), 'utf8');
const fixture = 'agiledancers-fe/src/hook-fixture.ts';
const baseline =
'export const hookValue = { value: 1 };\n\n// Independent edits below.\n\nexport const otherValue = 1;\n';
write(fixture, baseline);
write('unrelated.txt', 'baseline\n');
git('init', '-q');
git('config', 'user.name', 'S2 validation');
git('config', 'user.email', 's2@example.invalid');
git('add', '.');
git('-c', 'core.hooksPath=/dev/null', 'commit', '-qm', 'Fixture baseline');
git('config', 'core.hooksPath', '/dev/null');
run('node', ['tools/install-hooks.mjs'], frontend, { ...env, CI: 'true' });
assert.equal(git('config', '--get', 'core.hooksPath'), '/dev/null');
run('node', ['tools/install-hooks.mjs'], frontend, { ...env, HUSKY: '0' });
assert.equal(git('config', '--get', 'core.hooksPath'), '/dev/null');
console.log('PASS CI and HUSKY=0 skip hook installation');
run('node', ['tools/install-hooks.mjs'], frontend);
assert.equal(git('config', '--get', 'core.hooksPath'), '.husky/_');
const reset = () => git('reset', '--hard', '-q', 'HEAD');
const snapshot = () =>
JSON.stringify([
git('diff', '--cached', '--binary'),
git('diff', '--binary'),
git('status', '--porcelain'),
git('stash', 'list'),
]);
function hook() {
const start = performance.now();
const result = spawnSync('sh', ['.husky/_/pre-commit'], { cwd: root, env, encoding: 'utf8' });
return {
seconds: Number(((performance.now() - start) / 1000).toFixed(3)),
exit: result.status,
output: result.stdout + result.stderr,
};
}
function passed(name) {
console.log(`PASS ${name}`);
}
// Format only the index version while independently staged/unstaged edits survive.
write(fixture, baseline.replace('{ value: 1 }', '{value:2}'));
git('add', fixture);
write(fixture, read(fixture).replace('otherValue = 1', 'otherValue = 2'));
write('unrelated.txt', 'staged\n');
git('add', 'unrelated.txt');
write('unrelated.txt', 'staged\nunstaged\n');
const result = hook();
assert.equal(result.exit, 0, result.output);
assert.match(git('show', `:${fixture}`), /hookValue = \{ value: 2 \}/);
assert.match(git('show', `:${fixture}`), /otherValue = 1/);
assert.match(read(fixture), /otherValue = 2/);
assert.equal(git('show', ':unrelated.txt'), 'staged');
assert.equal(read('unrelated.txt'), 'staged\nunstaged\n');
assert.equal(git('stash', 'list'), '');
passed('partially staged formatting preserves unrelated staged and unstaged changes');
reset();
write('unrelated.txt', 'only backend/documentation\n');
git('add', 'unrelated.txt');
let before = snapshot();
assert.equal(hook().exit, 0);
assert.equal(snapshot(), before);
passed('commits without frontend changes leave state unchanged');
reset();
write('agiledancers-fe/package-lock.json', read('agiledancers-fe/package-lock.json') + '\n\n');
git('add', 'agiledancers-fe/package-lock.json');
before = snapshot();
assert.equal(hook().exit, 0);
assert.equal(snapshot(), before);
passed('generated lockfile is excluded');
reset();
write(
'agiledancers-fe/src/app/app.ts',
read('agiledancers-fe/src/app/app.ts') + '\nexport const formatterCheck={value:2};\n',
);
write(fixture, 'export const broken = ;\n');
git('add', 'agiledancers-fe/src/app/app.ts', fixture);
write('unrelated.txt', 'unstaged survives failures\n');
before = snapshot();
const failed = hook();
assert.notEqual(failed.exit, 0);
assert.match(failed.output, /SyntaxError|Unexpected token/);
assert.equal(snapshot(), before);
passed('formatter failure restores index, worktree and stash');
reset();
write(fixture, baseline.replace('{ value: 1 }', '{value:2}'));
git('add', fixture);
write(fixture, read(fixture).replace('{value:2}', '{value:3}'));
before = snapshot();
const conflict = hook();
assert.notEqual(conflict.exit, 0, conflict.output);
assert.match(conflict.output, /conflict|Failed to restore unstaged/i);
assert.equal(snapshot(), before);
passed('unstaged reconciliation conflict restores original state');
reset();
// Benchmark the rejected combined candidate using the same hook and state protections.
const selectedConfig = read('agiledancers-fe/lint-staged.config.mjs');
write(
'agiledancers-fe/lint-staged.combined.mjs',
`import base from './lint-staged.config.mjs';
export default Object.fromEntries(Object.entries(base).map(([pattern, task]) => [pattern, files => {
const commands = task(files);
const lintable = files.filter(file => /\\.(ts|js|mjs|cjs|html)$/.test(file));
return commands.length && lintable.length
? [...commands, 'eslint --max-warnings 0 ' + lintable.map(file => JSON.stringify(file)).join(' ')] : commands;
}]));\n`,
);
const selectedHook = read('.husky/pre-commit');
write(
'.husky/pre-commit',
selectedHook.replace(
'--concurrent false',
'--concurrent false --config lint-staged.combined.mjs',
),
);
write(fixture, baseline.replace('{ value: 1 }', '{value:2}'));
write(
'agiledancers-fe/src/app/app.ts',
read('agiledancers-fe/src/app/app.ts') + '\nexport const badLint: any = 1;\n',
);
git('add', fixture, 'agiledancers-fe/src/app/app.ts');
before = snapshot();
const lintFailure = hook();
assert.notEqual(lintFailure.exit, 0);
assert.match(lintFailure.output, /no-explicit-any/);
assert.equal(snapshot(), before);
passed('combined candidate restores formatting after ESLint failure');
reset();
const all = git('ls-files', 'agiledancers-fe')
.split('\n')
.filter((file) => !file.endsWith('/package-lock.json'));
const scopes = {
single: ['agiledancers-fe/src/app/app.ts'],
mixed: [
'agiledancers-fe/src/app/app.ts',
'agiledancers-fe/src/app/app.html',
'agiledancers-fe/src/app/app.css',
'agiledancers-fe/angular.json',
'agiledancers-fe/eslint.config.mjs',
'agiledancers-fe/e2e/school-management.spec.ts',
],
all,
};
const timings = [];
for (const mode of ['combined', 'prettier-only']) {
for (const [scope, files] of Object.entries(scopes)) {
for (let repeat = 1; repeat <= 3; repeat++) {
reset();
write(
'.husky/pre-commit',
mode === 'combined'
? selectedHook.replace(
'--concurrent false',
'--concurrent false --config lint-staged.combined.mjs',
)
: selectedHook,
);
for (const file of files) {
const addition = /\.(ts|js|mjs|cjs)$/.test(file)
? '\n// Hook timing fixture.\n'
: file.endsWith('.html')
? '\n<!-- Hook timing fixture. -->\n'
: file.endsWith('.css')
? '\n/* Hook timing fixture. */\n'
: '\n';
write(file, read(file) + addition);
}
git('add', '--', ...files);
const measurement = hook();
assert.equal(measurement.exit, 0, measurement.output);
timings.push({ mode, scope, repeat, files, seconds: measurement.seconds });
console.log(`TIMING ${mode} ${scope} ${repeat}: ${measurement.seconds}s`);
}
}
}
assert.equal(read('agiledancers-fe/lint-staged.config.mjs').includes(selectedConfig.trim()), true);
const report = {
machine: cpus()[0].model,
system: `${platform()} ${release()}`,
node: process.version,
npm: run('npm', ['--version']),
root,
timings,
};
writeFileSync(path.join(root, 'report.json'), JSON.stringify(report, null, 2) + '\n');
console.log(`Evidence: ${path.join(root, 'report.json')}`);
const selected = timings.filter((result) => result.mode === 'prettier-only');
assert.ok(
selected.every((result) => result.seconds <= 3),
'Prettier-only exceeds the three-second budget; do not enable the hook.',
);

View File

@@ -0,0 +1,13 @@
import { existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import husky from 'husky';
// Docker uses CI=true; CI and local opt-out must never change Git configuration.
if (!process.env.CI && process.env.HUSKY !== '0') {
const root = fileURLToPath(new URL('../../', import.meta.url));
if (existsSync(new URL('../../.git', import.meta.url))) {
process.chdir(root);
const error = husky('.husky');
if (error) throw new Error(error);
}
}

View File

@@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": { "types": ["vitest/globals", "node"], "noEmit": true },
"include": ["src/**/*.ts", "e2e/**/*.ts", "*.ts"],
"exclude": [
"node_modules",
"dist",
".angular",
"coverage",
"test-results",
"unit-results",
"playwright-report"
]
}