diff --git a/docs/adr-013-frontend-linting-strategy.md b/docs/adr-013-frontend-linting-strategy.md new file mode 100644 index 00000000..4256369a --- /dev/null +++ b/docs/adr-013-frontend-linting-strategy.md @@ -0,0 +1,74 @@ +# Architectural decision record (ADR) 013: Frontend linting strategy + +## Status + +Accepted on 26 October 2026. The frontend lint command runs Biome followed by +Oxlint, with the recommended Unicorn rules and the `ai-deslop` plugin enabled. + +## Date + +2026-10-26. + +## Context and problem statement + +The repository-owned frontend already uses Biome and project-specific checks. +It needs additional JavaScript and TypeScript rules for code quality and safer +handling of data received from APIs, configuration files, and tools. The added +checks must preserve existing Biome requirements, remain reproducible, and +surface type-aware findings in the same frontend lint command. + +## Decision drivers + +- Apply the recommended Unicorn profile to the frontend workspace. +- Add the requested `maharshi365/deslop` rules without weakening existing + Biome or TypeDoc checks. +- Pin the lint tools and external plugin so rule changes are reviewed with + version updates. +- Keep exceptions narrow and record direct rule conflicts explicitly. +- Validate untrusted input at its boundary rather than relying on unchecked + shape assumptions. + +## Options considered + +- Keep Biome alone. This does not provide the requested Unicorn and deslop + rules. +- Add Oxlint with floating versions. This would allow rule and plugin changes + without a reviewed dependency update. +- Add Oxlint with exact versions and enumerate the adopted rules. This keeps + the selected profile reviewable and makes exceptions visible in the config. + +## Decision outcome + +`frontend-pwa/package.json` pins Oxlint at `1.82.0`, `ai-deslop` at `0.4.9`, and +`oxlint-tsgolint` at `7.0.2004`. `ai-deslop` is loaded as an Oxlint JavaScript +plugin. Oxlint runs with type-aware rules enabled. The frontend `lint` script +runs `biome check .` first and `oxlint .` second. + +`frontend-pwa/.oxlintrc.json` explicitly enables the recommended Unicorn rule +set and the selected deslop rules at error severity. Explicit rule entries make +the adopted profile and its review surface visible next to the tool +configuration. The single disabled rule is `deslop/no-reflect-apply`, because +it conflicts with `unicorn/prefer-reflect-apply`; Unicorn's recommendation +takes precedence. No other rules are disabled. + +Oxlint's type-aware checks use the pinned `oxlint-tsgolint` package. Runtime +schemas belong at the boundary where the frontend or its tooling reads external +data. The task HTTP adapter, audit parser, and frontend configuration scripts +use Zod schemas so validated values have an explicit contract before the +application inspects them. The task schema retains unknown fields so additive +API changes remain available to callers. + +Biome remains the first frontend lint stage and retains its existing rules and +plugins. TypeDoc remains a separate zero-tolerance documentation gate; neither +tool is weakened to accommodate Oxlint. + +## Consequences + +- Contributors run `make frontend-lint` to execute Biome and Oxlint in order. +- Changes to either exact Oxlint package version require reviewing the + configured rule set and its exceptions. +- Type-aware linting relies on the pinned `oxlint-tsgolint` executable and the + TypeScript project configuration supplied by the frontend workspace. +- A future Unicorn or deslop rule conflict must be handled with one narrow + documented exception, preferring Unicorn when the conflict is between those + two plugins. diff --git a/docs/contents.md b/docs/contents.md index 68b43b2e..1af8922e 100644 --- a/docs/contents.md +++ b/docs/contents.md @@ -16,9 +16,6 @@ the first stop when choosing which document to read or update. runtime expectations. - [Developers' guide](developers-guide.md) explains maintainer workflows, quality gates, local services, and implementation conventions. -- [ADR 012: Rust build standard](adr-012-rust-build-standard.md) records why - development builds use the fast flags while coverage and release builds stay - off them. - [Roadmap](roadmap.md) tracks planned delivery work, dependencies, and acceptance criteria. @@ -59,6 +56,10 @@ the first stop when choosing which document to read or update. records migration and coexistence policy. - [ADR 011: Corbusier and Frankie review adapter role segregation](adr-011-corbusier-frankie-review-adapter-role-segregation.md) records adapter responsibility boundaries. +- [ADR 012: Rust build standard](adr-012-rust-build-standard.md) records the + development build flags and release-build boundary. +- [ADR 013: Frontend linting strategy](adr-013-frontend-linting-strategy.md) + records frontend lint tools, selected rules, and the narrow plugin conflict. ## Guides and reference material diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 8e76f936..2fd8432f 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -194,14 +194,48 @@ transport code directly. gateway error mapping. - `createFixtureTaskGateway` is the default local and test adapter. It keeps task state in memory, serializes queued operations over that shared state, - and preserves fixture-first development until the live HTTP gateway is - selected in later roadmap work. + and supports fixture-first development. The HTTP gateway is also available as + an opt-in mode for the browser flow described below. + +Run `make frontend-e2e` to exercise the browser paths. Playwright starts the +fixture app on port 4173 for the `chromium` project and an HTTP-mode app on +port 4174 for `http-chromium`, with `VITE_TASK_GATEWAY_MODE=http` and +`VITE_CORBUSIER_API_BASE_URL=/api/v1`. The HTTP flow intercepts API requests +with Playwright, so it needs no live backend. The fixture remains the default +gateway mode. Roadmap item 4.4.2 is complete; live task UI work remains in +pending item 4.4.3. The shipped `4.4.1` slice boundary is recorded in [`corbusier-design.md`](corbusier-design.md#713-repository-owned-frontend-workspace-boundary). The Whitaker user's guide remains a tooling guide for the Rust lint runner and does not describe these frontend-only APIs. +### Frontend linting + +`make frontend-lint` runs Biome and then Oxlint in `frontend-pwa`. Oxlint uses +the recommended Unicorn profile and the pinned `ai-deslop` plugin with +type-aware rules enabled. The exact versions and the one rule conflict are +recorded in [ADR 013](adr-013-frontend-linting-strategy.md). + +Runtime schemas belong at input boundaries. Frontend transport responses and +tooling configuration are parsed before callers rely on their shape; keep +unchecked shape tests and type assertions out of these paths. + +The lint command supplements the existing Biome checks. Run +`make frontend-typecheck`, `make frontend-docs-check`, and `make frontend-test` +for the separate TypeScript, TypeDoc, and behavioural validation gates. + +The semantic command regression suite creates temporary project fixtures and +copies the script under test into each fixture. Its setup helpers belong only +to that suite; production scripts do not depend on them. The audit command +tests provide a temporary `bun` executable through the child process's path to +exercise exit and signal handling without changing the parent environment. + +The HTTP task gateway unit tests use a file-local envelope replacement helper +for cases that vary one JSON field. It creates a fresh response for each test, +while callers state their expected results. Keep it in that test module; other +adapter and command tests use their own fixtures. + ### TypeScript type checking for tests `frontend-pwa/tsconfig.test.json` extends the base `tsconfig.json` and widens @@ -215,6 +249,8 @@ The separate test pass is required because tests and test configuration use different inclusion patterns and ambient types from the production application sources, while still depending on the application `src/` tree. +Vitest includes `.test.mjs` files under `src/` alongside TypeScript tests. + `make frontend-docs-check` runs `bun run docs:check` inside `frontend-pwa`: TypeDoc's `notDocumented` validation over `frontend-pwa/src` (`frontend-pwa/typedoc.json`), requiring a JSDoc block on every exported diff --git a/frontend-pwa/.oxlintrc.json b/frontend-pwa/.oxlintrc.json new file mode 100644 index 00000000..e4037f96 --- /dev/null +++ b/frontend-pwa/.oxlintrc.json @@ -0,0 +1,183 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["eslint", "typescript", "oxc", "unicorn"], + "jsPlugins": ["ai-deslop"], + "ignorePatterns": [ + "node_modules/**", + "dist/**", + "coverage/**", + "playwright-report/**", + "test-results/**" + ], + "categories": { + "correctness": "off", + "pedantic": "off", + "perf": "off", + "restriction": "off", + "style": "off", + "nursery": "off", + "suspicious": "off" + }, + "options": { + "typeAware": true + }, + "rules": { + "unicorn/catch-error-name": "error", + "unicorn/consistent-assert": "error", + "unicorn/consistent-date-clone": "error", + "unicorn/consistent-empty-array-spread": "error", + "unicorn/consistent-existence-index-check": "error", + "unicorn/consistent-function-scoping": "error", + "unicorn/consistent-template-literal-escape": "error", + "unicorn/empty-brace-spaces": "error", + "unicorn/error-message": "error", + "unicorn/escape-case": "error", + "unicorn/explicit-length-check": "error", + "unicorn/explicit-timer-delay": "error", + "unicorn/filename-case": "error", + "unicorn/import-style": "error", + "unicorn/max-nested-calls": "error", + "unicorn/new-for-builtins": "error", + "unicorn/no-abusive-eslint-disable": "error", + "unicorn/no-accessor-recursion": "error", + "unicorn/no-anonymous-default-export": "error", + "unicorn/no-array-callback-reference": "error", + "unicorn/no-array-fill-with-reference-type": "error", + "unicorn/no-array-method-this-argument": "error", + "unicorn/no-array-reduce": "error", + "unicorn/no-array-reverse": "error", + "unicorn/no-array-sort": "error", + "unicorn/no-await-expression-member": "error", + "unicorn/no-await-in-promise-methods": "error", + "unicorn/no-confusing-array-with": "error", + "unicorn/no-console-spaces": "error", + "unicorn/no-document-cookie": "error", + "unicorn/no-empty-file": "error", + "unicorn/no-immediate-mutation": "error", + "unicorn/no-instanceof-builtins": "error", + "unicorn/no-invalid-fetch-options": "error", + "unicorn/no-invalid-remove-event-listener": "error", + "unicorn/no-lonely-if": "error", + "unicorn/no-magic-array-flat-depth": "error", + "unicorn/no-negated-condition": "error", + "unicorn/no-negation-in-equality-check": "error", + "unicorn/no-nested-ternary": "error", + "unicorn/no-new-array": "error", + "unicorn/no-new-buffer": "error", + "unicorn/no-object-as-default-parameter": "error", + "unicorn/no-process-exit": "error", + "unicorn/no-single-promise-in-promise-methods": "error", + "unicorn/no-static-only-class": "error", + "unicorn/no-thenable": "error", + "unicorn/no-this-assignment": "error", + "unicorn/no-typeof-undefined": "error", + "unicorn/no-unnecessary-array-flat-depth": "error", + "unicorn/no-unnecessary-array-splice-count": "error", + "unicorn/no-unnecessary-await": "error", + "unicorn/no-unnecessary-slice-end": "error", + "unicorn/no-unreadable-array-destructuring": "error", + "unicorn/no-unreadable-iife": "error", + "unicorn/no-useless-collection-argument": "error", + "unicorn/no-useless-error-capture-stack-trace": "error", + "unicorn/no-useless-fallback-in-spread": "error", + "unicorn/no-useless-iterator-to-array": "error", + "unicorn/no-useless-length-check": "error", + "unicorn/no-useless-promise-resolve-reject": "error", + "unicorn/no-useless-spread": "error", + "unicorn/no-useless-switch-case": "error", + "unicorn/no-useless-undefined": "error", + "unicorn/no-null": "error", + "unicorn/no-zero-fractions": "error", + "unicorn/number-literal-case": "error", + "unicorn/numeric-separators-style": "error", + "unicorn/prefer-add-event-listener": "error", + "unicorn/prefer-array-find": "error", + "unicorn/prefer-array-flat": "error", + "unicorn/prefer-array-flat-map": "error", + "unicorn/prefer-array-index-of": "error", + "unicorn/prefer-array-some": "error", + "unicorn/prefer-at": "error", + "unicorn/prefer-bigint-literals": "error", + "unicorn/prefer-blob-reading-methods": "error", + "unicorn/prefer-class-fields": "error", + "unicorn/prefer-classlist-toggle": "error", + "unicorn/prefer-code-point": "error", + "unicorn/prefer-date-now": "error", + "unicorn/prefer-default-parameters": "error", + "unicorn/prefer-dom-node-append": "error", + "unicorn/prefer-dom-node-remove": "error", + "unicorn/prefer-dom-node-text-content": "error", + "unicorn/prefer-event-target": "error", + "unicorn/prefer-export-from": "error", + "unicorn/prefer-global-this": "error", + "unicorn/prefer-includes": "error", + "unicorn/prefer-keyboard-event-key": "error", + "unicorn/prefer-logical-operator-over-ternary": "error", + "unicorn/prefer-math-min-max": "error", + "unicorn/prefer-math-trunc": "error", + "unicorn/prefer-modern-dom-apis": "error", + "unicorn/prefer-modern-math-apis": "error", + "unicorn/prefer-module": "error", + "unicorn/prefer-native-coercion-functions": "error", + "unicorn/prefer-negative-index": "error", + "unicorn/prefer-node-protocol": "error", + "unicorn/prefer-number-coercion": "error", + "unicorn/prefer-number-properties": "error", + "unicorn/prefer-object-from-entries": "error", + "unicorn/prefer-optional-catch-binding": "error", + "unicorn/prefer-prototype-methods": "error", + "unicorn/prefer-query-selector": "error", + "unicorn/prefer-reflect-apply": "error", + // This conflicts with unicorn/prefer-reflect-apply; prefer Unicorn as requested. + "deslop/no-reflect-apply": "off", + "unicorn/prefer-regexp-test": "error", + "unicorn/prefer-response-static-json": "error", + "unicorn/prefer-set-has": "error", + "unicorn/prefer-set-size": "error", + "unicorn/prefer-single-call": "error", + "unicorn/prefer-spread": "error", + "unicorn/prefer-string-raw": "error", + "unicorn/prefer-string-replace-all": "error", + "unicorn/prefer-string-slice": "error", + "unicorn/prefer-string-starts-ends-with": "error", + "unicorn/prefer-string-trim-start-end": "error", + "unicorn/prefer-structured-clone": "error", + "unicorn/prefer-ternary": "error", + "unicorn/prefer-top-level-await": "error", + "unicorn/prefer-type-error": "error", + "unicorn/relative-url-style": "error", + "unicorn/require-array-join-separator": "error", + "unicorn/require-module-attributes": "error", + "unicorn/require-module-specifiers": "error", + "unicorn/require-number-to-fixed-digits-argument": "error", + "unicorn/switch-case-braces": "error", + "unicorn/switch-case-break-position": "error", + "unicorn/text-encoding-identifier-case": "error", + "unicorn/throw-new-error": "error", + "deslop/no-array-filter-map": "error", + "deslop/no-reduce-accumulator-copy": "error", + "deslop/no-chained-type-assertions": "error", + "deslop/no-conditional-empty-object-spread": "error", + "deslop/no-known-value-widening": "error", + "deslop/no-module-mocking": "error", + "deslop/no-object-parameters": "error", + "deslop/no-reflect-get": "error", + "deslop/no-runtime-typeof": "error", + "deslop/no-shape-in-symbol-names": "error", + "deslop/no-unknown-parameters": "error", + "deslop/no-unknown-returns": "error", + "deslop/no-unknown-type-aliases": "error", + "deslop/no-unsafe-dictionary-type": "error", + "deslop/no-widen-then-assert": "error", + "deslop/require-safety-comment-for-type-assertion": "error", + "deslop/no-call-only-assertions": "error", + "deslop/no-poor-substring-tests": "error", + "deslop/no-repeated-expect-call": "error", + "deslop/no-pass-through-type-alias": "error", + "typescript/no-deprecated": "error", + "typescript/no-require-imports": "error", + "typescript/no-floating-promises": "error", + "typescript/no-unnecessary-condition": "error", + "no-unreachable": "error", + } +} diff --git a/frontend-pwa/bun.lock b/frontend-pwa/bun.lock index 0159cdef..7fb47897 100644 --- a/frontend-pwa/bun.lock +++ b/frontend-pwa/bun.lock @@ -9,6 +9,7 @@ "@tanstack/react-router": "^1.170.17", "react": "^19.2.0", "react-dom": "^19.2.0", + "zod": "^4.6.7", }, "devDependencies": { "@axe-core/playwright": "^4.12.1", @@ -23,10 +24,13 @@ "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@vitejs/plugin-react": "^5.1.0", + "ai-deslop": "0.4.9", "axe-core": "^4.12.1", "daisyui": "^5.6.6", "jest-axe": "^10.0.0", "jsdom": "^30.0.1", + "oxlint": "1.82.0", + "oxlint-tsgolint": "7.0.2004", "stylelint": "^17.14.0", "stylelint-declaration-strict-value": "^1.10.11", "tailwindcss": "^4.3.2", @@ -223,6 +227,60 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@7.0.2004", "", { "os": "darwin", "cpu": "arm64" }, "sha512-uJk3ttju7Lz+Nv8WG4CsLyewoL1qf/Ny8S8OksJ7Bm/F14Tc9gMfW6arM1PCc57IzH/X/I79YsCWTgxX1FKibQ=="], + + "@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@7.0.2004", "", { "os": "darwin", "cpu": "x64" }, "sha512-0BFfHTzDxNDFPcu0UZAXT6CyCJHAXM0dZveyDb4KEHa2qF/DSnOKOrmvK/wFuFol9kvijbGM9JfuOsor+zWXRw=="], + + "@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@7.0.2004", "", { "os": "linux", "cpu": "arm64" }, "sha512-TAehQc+qdRXyDN3nl/r/3/oPrBVrbtrDeGMkFyG6mf+XphpoJ2lAMVFLclyvrVZCw02RbKROjyw8h72XwQQHcw=="], + + "@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@7.0.2004", "", { "os": "linux", "cpu": "x64" }, "sha512-LPunZvm6ZbWPfHMAyfZf9ci3HHvNEzLejO8zdqO3GoVGoSEHqqufHUD7+OgeatVSaJP7uXDJ2HaNOHIoH7go4Q=="], + + "@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@7.0.2004", "", { "os": "win32", "cpu": "arm64" }, "sha512-FVWplzDImWhiuawnIZVETaQecv4GWom6Jv4Fx2xj7hhXXDZ5okEgiw1gTCImHTIQsi2ycC5oSxVwrn69bMh7iw=="], + + "@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@7.0.2004", "", { "os": "win32", "cpu": "x64" }, "sha512-7PsxuUx11Ry9DYTWcljTzRbmZY9B6O9fFFfZV+aodQEMy6pfRT3fhoQRv9PtGN/89z1pPz3nFKrsFm6WqNtX7w=="], + + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.82.0", "", { "os": "android", "cpu": "arm" }, "sha512-a3LB+C5Dsj5b/qtmG/mv5WrzuiXEpg1KF5nXWcEvaoN5TYAqkIvxPOwTPp3Jy/FoGpRo8zsTFhMElMXfeoOEzA=="], + + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.82.0", "", { "os": "android", "cpu": "arm64" }, "sha512-OBlhRgNqFblGpGenno/aqOfJLOkQ2B8Ig3iDAalfn0H8hJGZKXPeexCRTDm6uwv6YUjSA9Xnwt1y/Bgj5ZH8uw=="], + + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.82.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dsopxqtY5ZdyT9uLHyGt1SyiLop6hi7hWI3PKpePodkRQOkLaCm+OE4fR9CAz9qdfjiFO8531tX/QDyP/psjFg=="], + + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.82.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-94Lu0SgTClKColU66g1VDuigV3HkcbkJBnTtZjGYfE8UPugaWDgKrm2icjC6HJVUYler2OXaHP/X0TBy8+CowQ=="], + + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.82.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hne/V06ewhh1i0w8+l7GDNROAGCGPmyFuOwiP7YTRu0JycyStJ4785dmF8xU5p0uUwt2emvIF9vc7Xjis+cJ0g=="], + + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.82.0", "", { "os": "linux", "cpu": "arm" }, "sha512-aWY2xtbZf1LneW9Qsv/n2Sp8gOu74JrlQzEtj4coHX2SHFrCfhmAumaU+sI/A5nr+yoTRTSmI/pL2s6ADlNSkw=="], + + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.82.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Fe+TtXCXMh/5f7kWlZ2VAwsMumZWtraFlKVk1NJlL52/beGwfDE7ov+/8gVirHzWokzGu7X65hSPq0ucPDskWQ=="], + + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.82.0", "", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-6azCZ6OJudlvipNttXCCQcyeFfcJ/NvUZdSN1z8elo73kCHtyQC7WTiUcSjWYvJ1jaq9KDUyMAoAS/vNzhBomA=="], + + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.82.0", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-PLEaSD8IAIIlwW4dwOd9YaxuxeOpwiXL4J24rcnE4iNtyM5j9Q9/3+gti08oXpx0u2ygNjRDx9xjWWpQonuJEw=="], + + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.82.0", "", { "os": "linux", "cpu": "ppc64", "libc": "glibc" }, "sha512-D94em/BwknNTn4vqxjHh5wb2oL566eFhArabqKIr0cNZMHOJuiraFp1A8tXpH05bbE5tqwEfLXTI0MWEGtn3Dw=="], + + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.82.0", "", { "os": "linux", "cpu": "none", "libc": "glibc" }, "sha512-MOprxBaoYU2D4VgxXCl3ghydThWtx7Um1lL51kGYNeQ5Al7WzsH7/tqGdNtbLrIWnjq3bsm13+nz/gRIxjrOXw=="], + + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.82.0", "", { "os": "linux", "cpu": "none", "libc": "musl" }, "sha512-5h55QsfJ/luDXZzC20k6SNOY1Az+dCP9WvntKtcUWh2JhckAdwApY2ZusaBTwLENnReXU+A2fJtSrYvZJNKNPg=="], + + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.82.0", "", { "os": "linux", "cpu": "s390x", "libc": "glibc" }, "sha512-IE8NJNLlHr0CaXyGJPGVn0eTkUyoj1I2UfA8x7I4PSOYKsQ/6btVC7Pywrj5onk0cMH25r6Z38SoN3AvE5Zuog=="], + + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.82.0", "", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-XUUUxaBo9XKl+J1B9EmP1cTGQPddzeURvoGkfwh/94PGnbW+hBprDljneoI2M1jzC1bzrIV3ihc7iM9UXl8+tg=="], + + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.82.0", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-SWLSFulX9TDuH6yvbPYp4+VNn6jkkIvvI+KiujDM5rWBRHEfkesCC/pCneIIUr6ovkxZ5fRtpi2v5Cz5FrMJZg=="], + + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.82.0", "", { "os": "none", "cpu": "arm64" }, "sha512-BQy35f6ZUdNr9a6c7B7orxQTcLjByGT2z3WAgmRovpRwmPYAaJ+NTplmMzhdjdJ4qSchfMNZy/Ukg+qRg6zseQ=="], + + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.82.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V4QhSTg5gctZue8RJjsGi7NpQPThr/p1/HfmiMC5kfe1KFEup9SQRVub4A6kijQjdHfxj7bLL1KO3QO7/5bwMQ=="], + + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.82.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-TUSCLaKB2yktpFAJ/r3HAUYsaV/3DT7JS4iNKyoh3a9YNwD0UG7Ezh4D8m23654vQcU6P/RQrCAjRPKe4peP/A=="], + + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.82.0", "", { "os": "win32", "cpu": "x64" }, "sha512-VTVoRIWJTb+wvUX8EYoPArfFH02whuR10goFXE/LHRRX33ajRrFgqbcONXZMiF4C5rnattfkm87HqYn8jb8hmQ=="], + + "@oxlint/plugins": ["@oxlint/plugins@1.82.0", "", {}, "sha512-IDF4UXBNOaCeLoEpVxf5Bg85KOWdeuBEkbo1rGzS2vXcbD+GT1asBSeeTC+5BgW6NFTHVByd6N5giHsFPGtwAA=="], + + "@pkgr/core": ["@pkgr/core@0.3.6", "", {}, "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA=="], + "@playwright/test": ["@playwright/test@1.59.1", "", { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], @@ -387,6 +445,8 @@ "@vitest/utils": ["@vitest/utils@4.1.12", "", { "dependencies": { "@vitest/pretty-format": "4.1.12", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA=="], + "ai-deslop": ["ai-deslop@0.4.9", "", { "dependencies": { "@oxlint/plugins": "1.82.0", "@tailwindcss/node": "^4.3.3", "synckit": "^0.11.13" }, "peerDependencies": { "tailwindcss": "^4.0.0" }, "optionalPeers": ["tailwindcss"] }, "sha512-YiwYxO6mIo31ZtJ8+Mcy8PbfBOX+oGVFC3gP2k8jSXW5cAZlOANEOFBWbpKN2JqfsDfrRNpHfuLe5YXA8Fclzg=="], + "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -657,6 +717,10 @@ "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + "oxlint": ["oxlint@1.82.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.82.0", "@oxlint/binding-android-arm64": "1.82.0", "@oxlint/binding-darwin-arm64": "1.82.0", "@oxlint/binding-darwin-x64": "1.82.0", "@oxlint/binding-freebsd-x64": "1.82.0", "@oxlint/binding-linux-arm-gnueabihf": "1.82.0", "@oxlint/binding-linux-arm-musleabihf": "1.82.0", "@oxlint/binding-linux-arm64-gnu": "1.82.0", "@oxlint/binding-linux-arm64-musl": "1.82.0", "@oxlint/binding-linux-ppc64-gnu": "1.82.0", "@oxlint/binding-linux-riscv64-gnu": "1.82.0", "@oxlint/binding-linux-riscv64-musl": "1.82.0", "@oxlint/binding-linux-s390x-gnu": "1.82.0", "@oxlint/binding-linux-x64-gnu": "1.82.0", "@oxlint/binding-linux-x64-musl": "1.82.0", "@oxlint/binding-openharmony-arm64": "1.82.0", "@oxlint/binding-win32-arm64-msvc": "1.82.0", "@oxlint/binding-win32-ia32-msvc": "1.82.0", "@oxlint/binding-win32-x64-msvc": "1.82.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-+iFM1BGw1ntYJt3QngbJmjbrGxPaKMUADOXOijpWGnYcBPq8YZnQftSS1C+pVcDYy9YxqDVJKQqQkTazTQMboQ=="], + + "oxlint-tsgolint": ["oxlint-tsgolint@7.0.2004", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "7.0.2004", "@oxlint-tsgolint/darwin-x64": "7.0.2004", "@oxlint-tsgolint/linux-arm64": "7.0.2004", "@oxlint-tsgolint/linux-x64": "7.0.2004", "@oxlint-tsgolint/win32-arm64": "7.0.2004", "@oxlint-tsgolint/win32-x64": "7.0.2004" }, "bin": { "tsgolint": "./bin/tsgolint.js" } }, "sha512-vQ9WDF3fiZJvRdSDosNOflerOCOpwol6vWzVDxW76RcU19cjTh7engvkHLvmv+tzedR8fAiJUVUGr6IVZdkzXQ=="], + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], @@ -753,6 +817,8 @@ "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + "synckit": ["synckit@0.11.13", "", { "dependencies": { "@pkgr/core": "^0.3.6" } }, "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg=="], + "table": ["table@6.9.0", "", { "dependencies": { "ajv": "^8.0.1", "lodash.truncate": "^4.4.2", "slice-ansi": "^4.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1" } }, "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A=="], "tailwindcss": ["tailwindcss@4.3.2", "", {}, "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA=="], @@ -821,6 +887,8 @@ "yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="], + "zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], + "@asamuzakjp/css-color/@csstools/css-calc": ["@csstools/css-calc@3.4.1", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.1", "@csstools/css-tokenizer": "^4.0.2" } }, "sha512-EtC7SoN1j6J4E4DCwg5QgbO5TGxgxIA1RXqe+W+qUM+BUcezx9wT+/tiQ/WO2yCX4i5X+Cuf9ciJ22aP4UEwWw=="], "@babel/core/@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], @@ -865,6 +933,8 @@ "@types/babel__traverse/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "ai-deslop/@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], + "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "data-urls/whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="], @@ -913,6 +983,12 @@ "@types/babel__traverse/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + "ai-deslop/@tailwindcss/node/enhanced-resolve": ["enhanced-resolve@5.26.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw=="], + + "ai-deslop/@tailwindcss/node/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "ai-deslop/@tailwindcss/node/tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], + "data-urls/whatwg-url/@exodus/bytes": ["@exodus/bytes@1.15.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ=="], "jest-diff/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], diff --git a/frontend-pwa/package.json b/frontend-pwa/package.json index 05b270c0..b4004cd2 100644 --- a/frontend-pwa/package.json +++ b/frontend-pwa/package.json @@ -9,7 +9,7 @@ "build": "tsc --noEmit && vite build", "preview": "vite preview --host 127.0.0.1 --port 4173", "audit": "bun run scripts/run-audit.mjs", - "lint": "biome check .", + "lint": "biome check . && oxlint .", "docs:check": "typedoc --options typedoc.json", "test:a11y": "vitest --run --config vitest.a11y.config.ts", "lint:classlist": "bun run scripts/check-classlist-length.ts", @@ -26,7 +26,8 @@ "@tanstack/react-query": "^5.101.2", "@tanstack/react-router": "^1.170.17", "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "zod": "^4.6.7" }, "overrides": { "@babel/core": "^7.29.1", @@ -58,10 +59,13 @@ "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@vitejs/plugin-react": "^5.1.0", + "ai-deslop": "0.4.9", "axe-core": "^4.12.1", "daisyui": "^5.6.6", "jest-axe": "^10.0.0", "jsdom": "^30.0.1", + "oxlint": "1.82.0", + "oxlint-tsgolint": "7.0.2004", "stylelint": "^17.14.0", "stylelint-declaration-strict-value": "^1.10.11", "tailwindcss": "^4.3.2", diff --git a/frontend-pwa/playwright.config.ts b/frontend-pwa/playwright.config.ts index 3229d3af..6d69a947 100644 --- a/frontend-pwa/playwright.config.ts +++ b/frontend-pwa/playwright.config.ts @@ -2,7 +2,7 @@ * Configure Playwright end-to-end runs for the repository-owned frontend PWA. * * This module exports the default browser test configuration, including device - * presets and the local Vite web-server contract used by CI and local runs. + * presets and separate fixture and HTTP-mode Vite servers used by test runs. */ import { defineConfig, devices } from '@playwright/test'; @@ -13,15 +13,32 @@ export default defineConfig({ baseURL: 'http://127.0.0.1:4173', trace: 'on-first-retry', }, - webServer: { - command: 'bun run dev --host 127.0.0.1 --port 4173', - port: 4173, - reuseExistingServer: !process.env.CI, - }, + webServer: [ + { + command: 'bun run dev --host 127.0.0.1 --port 4173', + port: 4173, + reuseExistingServer: !process.env.CI, + }, + { + command: 'bun run dev --host 127.0.0.1 --port 4174', + env: { + VITE_CORBUSIER_API_BASE_URL: '/api/v1', + VITE_TASK_GATEWAY_MODE: 'http', + }, + port: 4174, + reuseExistingServer: false, + }, + ], projects: [ { name: 'chromium', + testIgnore: '**/http-task-flow.spec.ts', use: { ...devices['Desktop Chrome'] }, }, + { + name: 'http-chromium', + testMatch: '**/http-task-flow.spec.ts', + use: { ...devices['Desktop Chrome'], baseURL: 'http://127.0.0.1:4174' }, + }, ], }); diff --git a/frontend-pwa/scripts/check-classlist-length.ts b/frontend-pwa/scripts/check-classlist-length.ts index 0ef6959d..72af0e76 100644 --- a/frontend-pwa/scripts/check-classlist-length.ts +++ b/frontend-pwa/scripts/check-classlist-length.ts @@ -8,13 +8,14 @@ * class list exceeds the configured threshold. */ import { readFileSync } from 'node:fs'; -import { relative } from 'node:path'; +import path from 'node:path'; import ts from 'typescript'; +import { z } from 'zod'; -interface SemanticConfig { - maxClasslistLength?: number; -} +const SemanticConfigSchema = z.object({ + maxClasslistLength: z.number().min(1).optional(), +}); interface Violation { file: string; @@ -26,23 +27,35 @@ interface Violation { const CONFIG_PATH = 'tools/semantic-lint.config.json'; const PROJECT_ROOT = process.cwd(); +/** + * Read the configured class-list limit, defaulting to 16 utilities. + * Missing, unreadable, malformed, or schema-invalid configuration throws. + * @example A configured value of `12.8` produces a threshold of `12`. + */ function loadThreshold(): number { const raw = readFileSync(CONFIG_PATH, 'utf8'); - const config = JSON.parse(raw) as SemanticConfig; + const config = SemanticConfigSchema.parse(JSON.parse(raw)); const value = config.maxClasslistLength; - return typeof value === 'number' && Number.isFinite(value) && value > 0 - ? Math.floor(value) - : 16; + return value === undefined ? 16 : Math.floor(value); } +/** + * Return paths for every TSX file under the frontend `src/` directory. + * @example `getTsxFiles()` includes `src/App.tsx` when that file exists. + */ function getTsxFiles(): string[] { const glob = new Bun.Glob('src/**/*.tsx'); - return Array.from(glob.scanSync(PROJECT_ROOT)); + return [...glob.scanSync(PROJECT_ROOT)]; } +/** + * Extract a static string from a JSX attribute value; dynamic expressions + * return `undefined` and are skipped by the class-list check. + * @example `className="p-2"` yields `p-2`; `className={classes}` is skipped. + */ function extractStringLiteral( initializer: ts.JsxAttributeValue, -): string | null { +): string | undefined { if (ts.isStringLiteral(initializer)) { return initializer.text; } @@ -55,7 +68,7 @@ function extractStringLiteral( return expression.text; } } - return null; + return undefined; } /** Return true when the node is a JSX `className` attribute with an initializer. */ @@ -63,7 +76,7 @@ function isClassNameAttribute(node: ts.Node): node is ts.JsxAttribute { return ( ts.isJsxAttribute(node) && node.name.text === 'className' && - node.initializer != null + node.initializer !== undefined ); } @@ -105,6 +118,11 @@ function analyseFile( visit(source); } +/** + * Scan frontend TSX files, report over-limit class lists, and set exit code 1. + * @example `main()` reports an over-limit class list and sets + * `process.exitCode` to `1` when a violation is found. + */ function main(): void { const maxLength = loadThreshold(); const violations: Violation[] = []; @@ -114,7 +132,7 @@ function main(): void { } for (const violation of violations) { - const displayPath = relative(PROJECT_ROOT, violation.file); + const displayPath = path.relative(PROJECT_ROOT, violation.file); console.error( `${displayPath}:${violation.line}:${violation.column} className has ${violation.length} utilities (max ${maxLength}).`, ); diff --git a/frontend-pwa/scripts/check-hardcoded-strings.ts b/frontend-pwa/scripts/check-hardcoded-strings.ts index ea7b23de..84c2fcb1 100644 --- a/frontend-pwa/scripts/check-hardcoded-strings.ts +++ b/frontend-pwa/scripts/check-hardcoded-strings.ts @@ -11,19 +11,25 @@ */ import { readFileSync } from 'node:fs'; -import { dirname, relative, resolve } from 'node:path'; +import path from 'node:path'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; +import { z } from 'zod'; interface HardcodedStringsConfig { minWordLength: number; userFacingAttributes: string[]; } -interface SemanticConfig { - hardcodedStrings?: Partial; -} +const UserFacingAttributesSchema = z.array(z.string()); +const HardcodedStringsConfigSchema = z.object({ + minWordLength: z.number().optional(), + userFacingAttributes: UserFacingAttributesSchema.optional(), +}); +const SemanticConfigSchema = z.object({ + hardcodedStrings: HardcodedStringsConfigSchema.optional(), +}); interface Violation { file: string; @@ -48,9 +54,12 @@ interface VisitContext { results: Violation[]; } -const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); -const PROJECT_ROOT = resolve(SCRIPT_DIR, '..'); -const CONFIG_PATH = resolve(PROJECT_ROOT, 'tools/semantic-lint.config.json'); +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const PROJECT_ROOT = path.resolve(SCRIPT_DIR, '..'); +const CONFIG_PATH = path.resolve( + PROJECT_ROOT, + 'tools/semantic-lint.config.json', +); const DEFAULTS: HardcodedStringsConfig = { minWordLength: 2, @@ -67,14 +76,12 @@ const DEFAULTS: HardcodedStringsConfig = { /** Load semantic-lint config with repo defaults for this rule. */ function loadConfig(): HardcodedStringsConfig { const raw = readFileSync(CONFIG_PATH, 'utf8'); - const config = JSON.parse(raw) as SemanticConfig; + const config = SemanticConfigSchema.parse(JSON.parse(raw)); const section = config.hardcodedStrings; - const userFacingAttributes = Array.isArray(section?.userFacingAttributes) - ? section.userFacingAttributes.filter( - (attribute): attribute is string => - typeof attribute === 'string' && attribute.trim().length > 0, - ) - : []; + const userFacingAttributes = + section?.userFacingAttributes?.filter( + (attribute) => attribute.trim().length > 0, + ) ?? []; return { minWordLength: @@ -91,8 +98,8 @@ function loadConfig(): HardcodedStringsConfig { /** Return the TSX files scanned by this rule. */ function getTsxFiles(): string[] { const glob = new Bun.Glob('src/**/*.tsx'); - return Array.from(glob.scanSync(PROJECT_ROOT), (filePath) => - resolve(PROJECT_ROOT, filePath), + return [...glob.scanSync(PROJECT_ROOT)].map((filePath) => + path.resolve(PROJECT_ROOT, filePath), ); } @@ -131,14 +138,7 @@ function isTCallExpression(node: ts.Node): boolean { /** Return true when the node sits inside a `t(…)` call expression. */ function isInsideTCall(node: ts.Node): boolean { - let cursor = node.parent; - while (cursor) { - if (isTCallExpression(cursor)) { - return true; - } - cursor = cursor.parent; - } - return false; + return ts.findAncestor(node.parent, isTCallExpression) !== undefined; } /** Return true when `data-i18n-ignore` is enabled for a JSX attribute. */ @@ -423,7 +423,7 @@ function main(): void { } for (const v of violations) { - const displayPath = relative(PROJECT_ROOT, v.file); + const displayPath = path.relative(PROJECT_ROOT, v.file); const label = v.kind === 'jsx-text' ? 'hard-coded JSX text' diff --git a/frontend-pwa/scripts/check-locale-vars.ts b/frontend-pwa/scripts/check-locale-vars.ts index 5cd70618..71501786 100644 --- a/frontend-pwa/scripts/check-locale-vars.ts +++ b/frontend-pwa/scripts/check-locale-vars.ts @@ -7,25 +7,39 @@ import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; import { pathToFileURL } from 'node:url'; +import { z } from 'zod'; -type MessageMap = Record; +const MessageMapSchema = z.record(z.string(), z.string()); +type MessageMap = z.infer; type MessagePlaceholders = Map>; const BASE_LOCALE = process.env.I18N_BASE_LOCALE ?? 'en-gb'; const LOCALES_DIR = path.resolve(process.cwd(), 'src/i18n'); const PLACEHOLDER_PATTERN = /\{([a-zA-Z0-9_]+)\}/g; +/** + * List sorted `.ts` catalogue paths in `src/i18n`; filesystem errors propagate. + * For example, `en-gb.ts` and `fr.ts` are returned in path-sorted order. + */ async function listLocaleFiles(): Promise { return fs .readdirSync(LOCALES_DIR, { withFileTypes: true }) .filter((entry) => entry.isFile() && entry.name.endsWith('.ts')) .map((entry) => path.join(LOCALES_DIR, entry.name)) - .sort(); + .toSorted(); } +/** + * Import one locale module and return its sole string-to-string catalogue. + * Import errors propagate; zero or multiple catalogue exports throw an error. + * @example An export `{ greeting: 'Hello' }` is returned as the message map. + */ async function loadMessages(filePath: string): Promise { const module = await import(pathToFileURL(filePath).href); - const exports = Object.values(module).filter(isMessageMap); + const exports = Object.values(module) + .map((candidate) => MessageMapSchema.safeParse(candidate)) + .filter((result) => result.success) + .map((result) => result.data); if (exports.length !== 1) { throw new Error( @@ -36,24 +50,6 @@ async function loadMessages(filePath: string): Promise { return exports[0]; } -/** - * Return true when the value is a non-null object and not an array. - * - * @param value - Candidate value to inspect. - * @returns {value is Record} Whether the value is a plain object candidate. - */ -function isNonArrayObject(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - -function isMessageMap(value: unknown): value is MessageMap { - if (!isNonArrayObject(value)) { - return false; - } - - return Object.values(value).every((entry) => typeof entry === 'string'); -} - function collectPlaceholders(messages: MessageMap): MessagePlaceholders { return new Map( Object.entries(messages).map(([key, message]) => [ diff --git a/frontend-pwa/scripts/find-near-duplicate-classes.ts b/frontend-pwa/scripts/find-near-duplicate-classes.ts index fa6d5e1a..9037a3a5 100644 --- a/frontend-pwa/scripts/find-near-duplicate-classes.ts +++ b/frontend-pwa/scripts/find-near-duplicate-classes.ts @@ -7,9 +7,10 @@ * can be extracted before duplication spreads. */ import { readFileSync } from 'node:fs'; -import { relative } from 'node:path'; +import path from 'node:path'; import ts from 'typescript'; +import { z } from 'zod'; type Occurrence = { file: string; @@ -28,9 +29,16 @@ type NearDuplicateOptions = { failOnViolation: boolean; }; -type SemanticConfig = { - nearDuplicateClasses?: Partial; -}; +const NearDuplicateOptionsSchema = z.object({ + minTokenCount: z.number().int().positive().optional(), + maxJaccardDistance: z.number().min(0).max(1).optional(), + minOccurrences: z.number().int().positive().optional(), + suppressPrefixes: z.array(z.string()).optional(), + failOnViolation: z.boolean().optional(), +}); +const SemanticConfigSchema = z.object({ + nearDuplicateClasses: NearDuplicateOptionsSchema.optional(), +}); type ConceptHint = { id: string; @@ -91,15 +99,24 @@ const CONCEPT_HINTS: ConceptHint[] = [ }, ]; +/** + * Merge configured options with defaults; config read, JSON, or schema errors + * throw. Defaults require 4 tokens, distance at most 0.2, and 2 occurrences. + * @example `loadOptions().minTokenCount` is `6` when config sets it to `6`. + */ function loadOptions(): NearDuplicateOptions { const raw = readFileSync(CONFIG_PATH, 'utf8'); - const config = JSON.parse(raw) as SemanticConfig; - return { ...DEFAULT_OPTIONS, ...(config.nearDuplicateClasses ?? {}) }; + const config = SemanticConfigSchema.parse(JSON.parse(raw)); + return { ...DEFAULT_OPTIONS, ...config.nearDuplicateClasses }; } +/** + * Return paths for every TSX file under the frontend `src/` directory. + * @example `getTsxFiles()` includes `src/App.tsx` when that file exists. + */ function getTsxFiles(): string[] { const glob = new Bun.Glob('src/**/*.tsx'); - return Array.from(glob.scanSync(PROJECT_ROOT)); + return [...glob.scanSync(PROJECT_ROOT)]; } function tokenMatches(pattern: string, token: string): boolean { @@ -113,7 +130,14 @@ function tokenMatches(pattern: string, token: string): boolean { return token === pattern; } -function findConceptHint(tokens: string[]): ConceptHint | null { +/** + * Return the first ordered concept hint whose required token patterns match. + * @example + * const buttonTokens = ['inline-flex', 'items-center', 'px-3', 'py-2', + * 'rounded-md', 'bg-blue-600']; + * findConceptHint(buttonTokens)?.id; // 'button-like' + */ +function findConceptHint(tokens: string[]): ConceptHint | undefined { for (const hint of CONCEPT_HINTS) { const matches = hint.requiredTokens.every((pattern) => tokens.some((token) => tokenMatches(pattern, token)), @@ -122,12 +146,16 @@ function findConceptHint(tokens: string[]): ConceptHint | null { return hint; } } - return null; + return undefined; } +/** + * Extract a static JSX string value; dynamic expressions return `undefined`. + * @example `className="flex gap-2"` yields `flex gap-2`. + */ function extractStringLiteral( initializer: ts.JsxAttributeValue, -): string | null { +): string | undefined { if (ts.isStringLiteral(initializer)) { return initializer.text; } @@ -140,19 +168,23 @@ function extractStringLiteral( return expression.text; } } - return null; + return undefined; } +/** + * Split a class stack into unique, lowercase, sorted tokens. + * @example `normalizeTokens(' P-2 p-2 FLEX ')` yields `['flex', 'p-2']`. + */ function normalizeTokens(raw: string): string[] { - return Array.from( - new Set( + return [ + ...new Set( raw .trim() .split(/\s+/u) .map((token) => token.trim().toLowerCase()) .filter(Boolean), ), - ).sort(); + ].toSorted(); } function shouldSuppress( @@ -249,6 +281,11 @@ function jaccardDistance( return union === 0 ? 1 : 1 - intersection / union; } +/** + * Emit locations and optional concept hints; return the exact-group count. + * @example `reportExactDuplicates(occurrences, options)` returns `1` and logs + * one group when two candidates share a signature and `minOccurrences` is `2`. + */ function reportExactDuplicates( occurrences: readonly Occurrence[], options: NearDuplicateOptions, @@ -270,7 +307,7 @@ function reportExactDuplicates( const locations = group .map( (entry) => - `${relative(PROJECT_ROOT, entry.file)}:${entry.line}:${entry.column}`, + `${path.relative(PROJECT_ROOT, entry.file)}:${entry.line}:${entry.column}`, ) .join(', '); console.error(`Repeated class stack found at ${locations}.`); @@ -281,26 +318,31 @@ function reportExactDuplicates( return findings; } +/** + * Emit pairs within the configured Jaccard distance; return the pair count. + * @example `reportNearDuplicates(occurrences, options)` returns `1` and logs + * one pair when its distance is `0.1` and `maxJaccardDistance` is `0.2`. + */ function reportNearDuplicates( occurrences: readonly Occurrence[], options: NearDuplicateOptions, ): number { const candidates = getCandidateOccurrences(occurrences, options); - const unique = Array.from( - new Map( + const unique = [ + ...new Map( candidates.map((occurrence) => [occurrence.signature, occurrence]), ).values(), - ); + ]; let findings = 0; const seen = new Set(); for (let index = 0; index < unique.length; index += 1) { - const current = unique[index]; + const current = unique.at(index); if (!current) { continue; } for (let nextIndex = index + 1; nextIndex < unique.length; nextIndex += 1) { - const next = unique[nextIndex]; + const next = unique.at(nextIndex); if (!next) { continue; } @@ -308,14 +350,14 @@ function reportNearDuplicates( if (distance > options.maxJaccardDistance) { continue; } - const key = [current.signature, next.signature].sort().join('::'); + const key = [current.signature, next.signature].toSorted().join('::'); if (seen.has(key)) { continue; } seen.add(key); findings += 1; console.error( - `Near-duplicate class stacks found at ${relative(PROJECT_ROOT, current.file)}:${current.line}:${current.column} and ${relative(PROJECT_ROOT, next.file)}:${next.line}:${next.column}.`, + `Near-duplicate class stacks found at ${path.relative(PROJECT_ROOT, current.file)}:${current.line}:${current.column} and ${path.relative(PROJECT_ROOT, next.file)}:${next.line}:${next.column}.`, ); } } diff --git a/frontend-pwa/scripts/run-audit.mjs b/frontend-pwa/scripts/run-audit.mjs index 45134a17..b59038c4 100644 --- a/frontend-pwa/scripts/run-audit.mjs +++ b/frontend-pwa/scripts/run-audit.mjs @@ -6,7 +6,7 @@ */ import { realpathSync } from 'node:fs'; -import { resolve } from 'node:path'; +import path from 'node:path'; import { fileURLToPath } from 'node:url'; import packageJson from '../package.json' with { type: 'json' }; @@ -20,10 +20,12 @@ import { runAuditJson, } from '../security/audit-utils.js'; -const normalise = (path) => - typeof realpathSync.native === 'function' - ? realpathSync.native(path) - : realpathSync(path); +/** + * Resolve symlinks and canonicalize a path; filesystem errors propagate. + * @example If `/repo/src/alias.ts` links to `main.ts`, normalizing it returns + * the canonical path to `/repo/src/main.ts`. + */ +const normalise = (filePath) => realpathSync.native(filePath); const workspacePackageName = packageJson.name; const workspaceKeys = new Set([ @@ -61,6 +63,12 @@ function buildLedgerMaps(auditEntries) { return { ledgerByAdvisory, allowedIds }; } +/** + * Return a ledger validation error, or `undefined` when its expiry is valid. + * Date-only expiries remain valid through that calendar day; invalid or past + * dates return a message suitable for the audit diagnostic. + * @example An expiry of `2030-01-02` is valid at noon on that date. + */ function getLedgerExpiryError(entry, advisoryId, referenceDateValue) { if (!entry) { return `Audit ledger entry missing for advisory ${advisoryId ?? 'unknown'}.`; @@ -88,7 +96,7 @@ function getLedgerExpiryError(entry, advisoryId, referenceDateValue) { return `Audit exception ${entryLabel} for advisory ${entry.advisory} expired on ${rawExpiry}.`; } - return null; + return; } function reportAllowedAdvisories(expected) { @@ -112,14 +120,14 @@ function reportAllowedAdvisories(expected) { * } */ function isExecutedDirectly(meta) { - const invokedPath = process.argv?.[1]; + const invokedPath = process.argv[1]; if (!invokedPath) { return false; } try { const scriptPath = fileURLToPath(meta.url); - const absoluteInvokedPath = resolve(invokedPath); + const absoluteInvokedPath = path.resolve(invokedPath); return normalise(scriptPath) === normalise(absoluteInvokedPath); } catch { return false; @@ -200,9 +208,9 @@ export function main() { if (isExecutedDirectly(import.meta)) { try { const exitCode = main(); - process.exit(exitCode); + process.exitCode = exitCode; } catch (error) { console.error(error); - process.exit(1); + process.exitCode = 1; } } diff --git a/frontend-pwa/security/audit-utils.js b/frontend-pwa/security/audit-utils.js index 0693fbca..a4f23f80 100644 --- a/frontend-pwa/security/audit-utils.js +++ b/frontend-pwa/security/audit-utils.js @@ -10,34 +10,54 @@ */ import { spawnSync } from 'node:child_process'; +import { z } from 'zod'; const GHSA_PATTERN = /GHSA-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}/i; +const AdvisorySchema = z.looseObject({ + github_advisory_id: z.string().optional(), + title: z.string().optional(), + url: z.string().optional(), +}); +const AdvisoryMapSchema = z.record(z.string(), z.array(AdvisorySchema)); +const AuditObjectSchema = z.record(z.string(), z.unknown()); /** * Extract a GHSA identifier from an advisory URL. * * @param {string} url - * @returns {string | null} + * @returns {string | undefined} */ function extractGhsaFromUrl(url) { const match = url.match(GHSA_PATTERN); - return match ? match[0] : null; + return match?.[0]; } +/** + * Read an advisory's GitHub identifier, falling back to its advisory URL. + * Invalid records and records without a GHSA identifier produce `undefined`. + * + * @param {unknown} advisory + * @returns {string | undefined} + * + * @example + * extractGithubAdvisoryId({ url: 'https://github.com/advisories/GHSA-abcd-1234-efgh' }); + * // Returns 'GHSA-abcd-1234-efgh'. + */ function extractGithubAdvisoryId(advisory) { - if (!advisory || typeof advisory !== 'object') { - return null; + const result = AdvisorySchema.safeParse(advisory); + if (!result.success) { + return; } - if (typeof advisory.github_advisory_id === 'string') { - return advisory.github_advisory_id; + if (result.data.github_advisory_id !== undefined) { + return result.data.github_advisory_id; } - if (typeof advisory.url === 'string') { - return extractGhsaFromUrl(advisory.url); + if (result.data.url !== undefined) { + return extractGhsaFromUrl(result.data.url); } - return null; + return; } /** @@ -50,9 +70,13 @@ function assertSpawnSuccess(result) { if (result.error) { throw result.error; } - if (result.status === null) { - const signalSuffix = result.signal ? ` (signal: ${result.signal})` : ''; - throw new Error(`bun audit did not exit normally${signalSuffix}`); + if (result.signal) { + throw new Error( + `bun audit did not exit normally (signal: ${result.signal})`, + ); + } + if (!Number.isInteger(result.status)) { + throw new TypeError('bun audit did not exit normally'); } } @@ -69,7 +93,7 @@ function parseAuditOutput(raw) { } try { - return JSON.parse(stdout); + return AuditObjectSchema.parse(JSON.parse(stdout)); } catch (error) { const wrapped = new Error( `Failed to parse bun audit JSON: ${error.message}`, @@ -100,7 +124,7 @@ export function runAuditJson() { }); assertSpawnSuccess(result); - const status = result.status; + const status = result.status ?? 1; const json = parseAuditOutput(result.stdout); return { json, status }; } @@ -124,23 +148,43 @@ export function runAuditJson() { * console.log(advisories[0].github_advisory_id); */ export function collectAdvisories(auditJson) { - if (!auditJson || typeof auditJson !== 'object') { - return []; + const root = AuditObjectSchema.safeParse(auditJson); + if (!root.success) { + throw new Error('Invalid bun audit advisory payload'); } - const entries = - auditJson.advisories && typeof auditJson.advisories === 'object' - ? Object.entries(auditJson.advisories) - : Object.entries(auditJson); - - return entries.flatMap(([packageName, value]) => - Array.isArray(value) - ? value.map((advisory) => ({ - ...advisory, - package: packageName, - github_advisory_id: extractGithubAdvisoryId(advisory), - })) - : [], + const entries = Object.hasOwn(root.data, 'advisories') + ? root.data.advisories + : root.data; + const advisories = AdvisoryMapSchema.safeParse(entries); + if (!advisories.success) { + throw new Error('Invalid bun audit advisory payload'); + } + return flattenAdvisories(advisories.data); +} + +/** + * Add each package name and normalized GitHub advisory ID to its records. + * Records remain in package order and preserve their original fields. + * + * @param {Record>} advisories + * @returns {Array & { + * package: string, + * github_advisory_id?: string + * }>} Normalized advisory records. + * + * @example + * flattenAdvisories({ pkg: [{ url: 'https://github.com/advisories/GHSA-abcd-1234-efgh' }] }); + * // Returns [{ package: 'pkg', url: 'https://github.com/advisories/GHSA-abcd-1234-efgh', + * // github_advisory_id: 'GHSA-abcd-1234-efgh' }]. + */ +function flattenAdvisories(advisories) { + return Object.entries(advisories).flatMap(([packageName, records]) => + records.map((advisory) => ({ + ...advisory, + package: packageName, + github_advisory_id: extractGithubAdvisoryId(advisory), + })), ); } diff --git a/frontend-pwa/src/docs-check.test.ts b/frontend-pwa/src/docs-check.test.ts index dec31782..4c807789 100644 --- a/frontend-pwa/src/docs-check.test.ts +++ b/frontend-pwa/src/docs-check.test.ts @@ -13,19 +13,20 @@ import { writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; +import path from 'node:path'; -const repositoryRoot = resolve(process.cwd(), '..'); +const repositoryRoot = path.resolve(process.cwd(), '..'); +/** Creates a Bun stub that records its working directory and arguments. */ function createFakeBun(fixtureDirectory: string, exitCode: number) { - const fakeBun = join(fixtureDirectory, 'bun'); + const fakeBun = path.join(fixtureDirectory, 'bun'); writeFileSync( fakeBun, [ '#!/usr/bin/env sh', 'script_dir=$(dirname "$0")', 'pwd > "$script_dir/working-directory"', - 'printf "%s\\n" "$@" > "$script_dir/arguments"', + String.raw`printf "%s\n" "$@" > "$script_dir/arguments"`, `exit ${exitCode}`, ].join('\n'), ); @@ -43,19 +44,24 @@ function runDocsCheck(fakeBun: string) { describe('frontend-docs-check', () => { it('runs the configured Bun command from the frontend workspace', () => { - const fixtureDirectory = mkdtempSync(join(tmpdir(), 'corbusier-make-')); + const fixtureDirectory = mkdtempSync( + path.join(tmpdir(), 'corbusier-make-'), + ); try { const fakeBun = createFakeBun(fixtureDirectory, 0); - const argumentsPath = join(fixtureDirectory, 'arguments'); - const workingDirectoryPath = join(fixtureDirectory, 'working-directory'); + const argumentsPath = path.join(fixtureDirectory, 'arguments'); + const workingDirectoryPath = path.join( + fixtureDirectory, + 'working-directory', + ); const result = runDocsCheck(fakeBun); expect(result.error).toBeUndefined(); expect(result.status).toBe(0); expect(readFileSync(workingDirectoryPath, 'utf8').trim()).toBe( - join(repositoryRoot, 'frontend-pwa'), + path.join(repositoryRoot, 'frontend-pwa'), ); expect(readFileSync(argumentsPath, 'utf8').trim().split('\n')).toEqual([ 'run', @@ -67,7 +73,9 @@ describe('frontend-docs-check', () => { }); it('propagates a failing Bun command', () => { - const fixtureDirectory = mkdtempSync(join(tmpdir(), 'corbusier-make-')); + const fixtureDirectory = mkdtempSync( + path.join(tmpdir(), 'corbusier-make-'), + ); try { const fakeBun = createFakeBun(fixtureDirectory, 23); diff --git a/frontend-pwa/src/routes/task-create-page.tsx b/frontend-pwa/src/routes/task-create-page.tsx index 76e8960e..3184bf50 100644 --- a/frontend-pwa/src/routes/task-create-page.tsx +++ b/frontend-pwa/src/routes/task-create-page.tsx @@ -5,7 +5,7 @@ * `useCreateTaskMutation`, localized copy, and post-success navigation. */ import { useNavigate } from '@tanstack/react-router'; -import { type FormEvent, useState } from 'react'; +import { type SyntheticEvent, useState } from 'react'; import { useI18n } from '../i18n/runtime'; import { useCreateTaskMutation } from '../task_slice/application/task-queries'; @@ -37,7 +37,18 @@ export function TaskCreatePage() { setErrors((current) => ({ ...current, [field]: undefined })); } - async function handleSubmit(event: FormEvent) { + /** + * Validate the current draft, create its task, then navigate to that task. + * Invalid drafts stay on the form with field errors; mutation failures are + * exposed through `mutation.error` for the form's error banner. + * + * @param event - Form submission event whose browser navigation is prevented. + * @returns Resolves after validation, and after navigation if creation succeeds. + * + * @example + * // The form calls this handler; a successful submission opens the new task. + */ + async function handleSubmit(event: SyntheticEvent) { event.preventDefault(); const nextErrors = validateTaskCreateDraft(draft); diff --git a/frontend-pwa/src/task_slice/adapters/fixture/fixture-task-gateway.ts b/frontend-pwa/src/task_slice/adapters/fixture/fixture-task-gateway.ts index 8bcb4186..1241efe1 100644 --- a/frontend-pwa/src/task_slice/adapters/fixture/fixture-task-gateway.ts +++ b/frontend-pwa/src/task_slice/adapters/fixture/fixture-task-gateway.ts @@ -38,11 +38,22 @@ export function createFixtureTaskGateway( const tasks = new Map(seedTasks.map((task) => [task.id, task])); let operationQueue = Promise.resolve(); + /** + * Run a gateway operation after earlier operations have settled. + * A rejected operation still rejects for its caller, while the queue remains + * usable for later operations. + * + * @param operation - Asynchronous operation to serialize with gateway calls. + * @returns The operation's result or rejection. + * + * @example + * await enqueue(async () => 42); // Resolves to 42 after earlier work finishes. + */ function enqueue(operation: () => Promise): Promise { const nextOperation = operationQueue.then(operation, operation); operationQueue = nextOperation.then( - () => undefined, - () => undefined, + () => {}, + () => {}, ); return nextOperation; @@ -204,7 +215,7 @@ function applyTransition(task: Task, targetState: TaskState): Task { * and local development. */ function delay() { - return new Promise((resolve) => window.setTimeout(resolve, 15)); + return new Promise((resolve) => globalThis.setTimeout(resolve, 15)); } /** diff --git a/frontend-pwa/src/task_slice/adapters/http/http-task-gateway.test.ts b/frontend-pwa/src/task_slice/adapters/http/http-task-gateway.test.ts index b489bb48..4a9b05ab 100644 --- a/frontend-pwa/src/task_slice/adapters/http/http-task-gateway.test.ts +++ b/frontend-pwa/src/task_slice/adapters/http/http-task-gateway.test.ts @@ -4,54 +4,70 @@ * The suite validates success parsing plus shared-error mapping without * reaching a real backend. */ +import type { CreateTaskRequest } from '../../domain/task'; import { TaskGatewayError } from '../../ports/task-slice-gateway'; import { createHttpTaskGateway } from './http-task-gateway'; -function createGatewayWithResponse(body: unknown, status = 200) { - return createHttpTaskGateway( - '/api/v1', - vi - .fn() - .mockResolvedValue( - new Response(JSON.stringify(body), { status }), - ) as typeof fetch, +const taskEnvelope = `{ + "data": { + "task": { + "created_at": "2026-04-13T00:00:00.000Z", + "id": "task-1", + "origin": { + "issue_ref": { + "issue_number": 42, + "provider": "github", + "repository": "acme/widgets" + }, + "metadata": { + "assignees": [], + "labels": [], + "title": "Stabilize the transport contract" + }, + "type": "issue" + }, + "state": "draft", + "updated_at": "2026-04-13T00:00:00.000Z" + } + }, + "error": null, + "metadata": { + "request_id": "req-123", + "timestamp": "2026-04-13T00:00:00.000Z", + "version": "v1" + }, + "success": true +}`; + +const idempotencyKeyPattern = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +/** Returns a fetch mock that resolves with the supplied response. */ +function createFetchForResponse(response: Response): typeof fetch { + return vi.fn().mockResolvedValue(response); +} + +/** Builds the gateway with a fetch mock fixed to the supplied response. */ +function createGatewayWithResponse(response: Response) { + return createHttpTaskGateway('/api/v1', createFetchForResponse(response)); +} + +/** + * Builds a gateway with one literal replacement in the task envelope. + * + * @example + * `createGatewayWithTaskField('"issue_number": 42', '"issue_number": 0')` + * serves an envelope containing issue number zero. + */ +function createGatewayWithTaskField(original: string, replacement: string) { + return createGatewayWithResponse( + new Response(taskEnvelope.replace(original, replacement)), ); } describe('http task gateway', () => { - const taskEnvelope = { - data: { - task: { - created_at: '2026-04-13T00:00:00.000Z', - id: 'task-1', - origin: { - issue_ref: { - issue_number: 42, - provider: 'github', - repository: 'acme/widgets', - }, - metadata: { - assignees: [], - labels: [], - title: 'Stabilize the transport contract', - }, - type: 'issue', - }, - state: 'draft', - updated_at: '2026-04-13T00:00:00.000Z', - }, - }, - error: null, - metadata: { - request_id: 'req-123', - timestamp: '2026-04-13T00:00:00.000Z', - version: 'v1', - }, - success: true, - }; - it('parses successful task detail responses', async () => { - const gateway = createGatewayWithResponse(taskEnvelope); + const gateway = createGatewayWithResponse(new Response(taskEnvelope)); await expect(gateway.getTask('task-1')).resolves.toMatchObject({ id: 'task-1', @@ -59,77 +75,80 @@ describe('http task gateway', () => { }); }); + it('preserves task fields added by the API outside the current model', async () => { + const responseBody = taskEnvelope.replace( + '"id": "task-1"', + '"id": "task-1",\n "upstream_extension": "retained"', + ); + const gateway = createGatewayWithResponse(new Response(responseBody)); + + await expect(gateway.getTask('task-1')).resolves.toMatchObject({ + upstream_extension: 'retained', + }); + }); + it('escapes task identifiers in task path segments', async () => { const fetchFn = vi - .fn() - .mockImplementation(() => - Promise.resolve( - new Response(JSON.stringify(taskEnvelope), { status: 200 }), - ), - ) as typeof fetch; + .fn() + .mockResolvedValueOnce(new Response(taskEnvelope)) + .mockResolvedValueOnce(new Response(taskEnvelope)); const gateway = createHttpTaskGateway('/api/v1', fetchFn); await gateway.getTask('task/with reserved?characters'); await gateway.transitionTask('task/with reserved?characters', 'done'); - expect(fetchFn).toHaveBeenNthCalledWith( - 1, + expect(fetchFn.mock.calls.map((call) => call[0])).toEqual([ '/api/v1/tasks/task%2Fwith%20reserved%3Fcharacters', - undefined, - ); + '/api/v1/tasks/task%2Fwith%20reserved%3Fcharacters/state', + ]); expect(fetchFn).toHaveBeenNthCalledWith( 2, '/api/v1/tasks/task%2Fwith%20reserved%3Fcharacters/state', - expect.objectContaining({ method: 'PUT' }), + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ state: 'done' }), + headers: { + 'Content-Type': 'application/json', + 'Idempotency-Key': expect.stringMatching(idempotencyKeyPattern), + }, + }), ); }); it.each([ { - body: { - ...taskEnvelope, - metadata: { - request_id: 'req-123', - timestamp: '', - version: 'v1', - }, - }, - expectedError: new TaskGatewayError( - 'unavailable', - 'The task API returned an invalid success response.', + body: taskEnvelope.replace( + '"request_id": "req-123",\n "timestamp": "2026-04-13T00:00:00.000Z"', + '"request_id": "req-123",\n "timestamp": ""', ), name: 'metadata is incomplete', }, { - body: { - ...taskEnvelope, - metadata: { - ...taskEnvelope.metadata, - version: 'v2', - }, - }, - expectedError: new TaskGatewayError( - 'unavailable', - 'The task API returned an invalid success response.', - ), + body: taskEnvelope.replace('"version": "v1"', '"version": "v2"'), name: 'metadata version is unsupported', }, - ])('rejects a 200 envelope when $name', async ({ body, expectedError }) => { - const gateway = createGatewayWithResponse(body); + ])('rejects a 200 envelope when $name', async ({ body }) => { + const gateway = createGatewayWithResponse(new Response(body)); - await expect(gateway.getTask('task-1')).rejects.toEqual(expectedError); + await expect(gateway.getTask('task-1')).rejects.toEqual( + new TaskGatewayError( + 'unavailable', + 'The task API returned an invalid success response.', + ), + ); }); it('maps shared not-found responses to the task gateway error', async () => { - const gateway = createGatewayWithResponse( + const response = Response.json( { code: 'not_found', details: { reason: 'task_not_found' }, message: 'task task-9 was not found', traceId: 'trace-123', }, - 404, + { status: 404 }, ); + const gateway = createGatewayWithResponse(response); await expect(gateway.getTask('task-9')).rejects.toEqual( new TaskGatewayError('not_found', 'task task-9 was not found'), @@ -138,66 +157,29 @@ describe('http task gateway', () => { it.each([ { - body: { - data: { - task: { - created_at: '2026-04-13T00:00:00.000Z', - id: 123, - origin: { - issue_ref: { - issue_number: 42, - provider: 'github', - repository: 'acme/widgets', - }, - metadata: { - assignees: [], - labels: [], - title: 'Stabilize the transport contract', - }, - type: 'issue', - }, - state: 'draft', - updated_at: '2026-04-13T00:00:00.000Z', - }, - }, - error: null, - metadata: { - request_id: 'req-999', - timestamp: '2026-04-13T00:00:00.000Z', - version: 'v1', - }, - success: true, - }, - expectedError: new TaskGatewayError( - 'unavailable', - 'The task API returned an invalid task shape.', - ), + body: taskEnvelope.replace('"id": "task-1"', '"id": 123'), name: 'the nested task violates the slice contract shape', }, { - body: { - ...taskEnvelope, - data: { - task: { - ...taskEnvelope.data.task, - created_at: '2026-02-30T00:00:00.000Z', - }, - }, - }, - expectedError: new TaskGatewayError( - 'unavailable', - 'The task API returned an invalid task shape.', + body: taskEnvelope.replace( + '"created_at": "2026-04-13T00:00:00.000Z"', + '"created_at": "2026-02-30T00:00:00.000Z"', ), name: 'the nested task has invalid timestamps', }, - ])('rejects a 200 envelope when $name', async ({ body, expectedError }) => { - const gateway = createGatewayWithResponse(body); + ])('rejects a 200 envelope when $name', async ({ body }) => { + const gateway = createGatewayWithResponse(new Response(body)); - await expect(gateway.getTask('task-shape')).rejects.toEqual(expectedError); + await expect(gateway.getTask('task-shape')).rejects.toEqual( + new TaskGatewayError( + 'unavailable', + 'The task API returned an invalid task shape.', + ), + ); }); it('maps invalid transition conflicts separately from validation failures', async () => { - const gateway = createGatewayWithResponse( + const response = Response.json( { code: 'conflict', details: { @@ -207,8 +189,9 @@ describe('http task gateway', () => { message: 'invalid state transition for task task-2', traceId: 'trace-234', }, - 409, + { status: 409 }, ); + const gateway = createGatewayWithResponse(response); await expect(gateway.transitionTask('task-2', 'done')).rejects.toEqual( new TaskGatewayError( @@ -217,4 +200,166 @@ describe('http task gateway', () => { ), ); }); + + it('sends the create request with an idempotency key', async () => { + const fetchFn = createFetchForResponse(new Response(taskEnvelope)); + const gateway = createHttpTaskGateway('/api/v1', fetchFn); + const request = { + provider: 'github', + repository: 'acme/widgets', + issue_number: 42, + title: 'A task', + } satisfies CreateTaskRequest; + + await gateway.createTask(request); + + expect(fetchFn).toHaveBeenCalledWith( + '/api/v1/tasks', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify(request), + headers: { + 'Content-Type': 'application/json', + 'Idempotency-Key': expect.stringMatching(idempotencyKeyPattern), + }, + }), + ); + }); + + it.each([ + { status: 400, kind: 'validation' }, + { status: 401, kind: 'unauthorized' }, + { status: 403, kind: 'unauthorized' }, + { status: 404, kind: 'not_found' }, + { status: 409, kind: 'conflict' }, + { status: 503, kind: 'unavailable' }, + ])('maps HTTP $status to $kind', async ({ status, kind }) => { + const gateway = createGatewayWithResponse( + Response.json({ message: 'API error' }, { status }), + ); + + await expect(gateway.getTask('task-1')).rejects.toMatchObject({ + kind, + message: 'API error', + }); + }); + + it('reports malformed success JSON', async () => { + const gateway = createGatewayWithResponse(new Response('{')); + + await expect(gateway.getTask('task-1')).rejects.toEqual( + new TaskGatewayError( + 'unavailable', + 'The task API returned malformed JSON.', + ), + ); + }); + + it('maps a rejected fetch to unavailable', async () => { + const fetchFn = vi + .fn() + .mockRejectedValue(new Error('offline')); + const gateway = createHttpTaskGateway('/api/v1', fetchFn); + + await expect(gateway.getTask('task-1')).rejects.toEqual( + new TaskGatewayError('unavailable', 'The task API could not be reached.'), + ); + }); + + it.each([ + { body: '{', name: 'malformed JSON' }, + { body: '{"message": 42}', name: 'an invalid error shape' }, + ])('falls back to the HTTP status for $name', async ({ body }) => { + const gateway = createGatewayWithResponse( + new Response(body, { status: 503 }), + ); + + await expect(gateway.getTask('task-1')).rejects.toEqual( + new TaskGatewayError('unavailable', 'The task API returned HTTP 503.'), + ); + }); + + it('normalizes null optional references to absence', async () => { + const responseBody = taskEnvelope.replace( + '"id": "task-1"', + '"id": "task-1", "branch_ref": null, "pull_request_ref": null', + ); + const task = await createGatewayWithResponse( + new Response(responseBody), + ).getTask('task-1'); + + expect(task.branch_ref).toBeUndefined(); + expect(task.pull_request_ref).toBeUndefined(); + }); + + it.each( + Array.from({ length: 12 }, (_, index) => ({ + sequence: index, + text: `extension-${index}`, + nested: { enabled: index % 2 === 0 }, + })), + )('retains generated task extension $sequence', async (extension) => { + const responseBody = taskEnvelope.replace( + '"id": "task-1"', + `"id": "task-1", "extension": ${JSON.stringify(extension)}`, + ); + const gateway = createGatewayWithResponse(new Response(responseBody)); + + await expect(gateway.getTask('task-1')).resolves.toMatchObject({ + extension, + }); + }); + + it.each([ + '2024-02-29T23:59:59Z', + '2026-01-01T00:00:00+23:59', + '2026-12-31T23:59:59.123456-23:59', + ...Array.from( + { length: 12 }, + (_, index) => `2026-${String(index + 1).padStart(2, '0')}-01T12:30:45Z`, + ), + ])('accepts valid calendar timestamp %s', async (timestamp) => { + const gateway = createGatewayWithTaskField( + '"created_at": "2026-04-13T00:00:00.000Z"', + `"created_at": "${timestamp}"`, + ); + + await expect(gateway.getTask('task-1')).resolves.toMatchObject({ + created_at: timestamp, + }); + }); + + it.each([ + ...[0, -1, -42, 1.5, 9_007_199_254_740_992].map((issueNumber) => ({ + name: `issue number ${issueNumber}`, + original: '"issue_number": 42', + replacement: `"issue_number": ${issueNumber}`, + })), + ...[ + '2023-02-29T00:00:00Z', + '2026-04-31T00:00:00Z', + '2026-01-01T24:00:00Z', + '2026-01-01T23:60:00Z', + '2026-01-01T23:59:60Z', + '2026-01-01T00:00:00+24:00', + '2026-01-01T00:00:00-01:60', + ...Array.from( + { length: 12 }, + (_, index) => `2026-${String(index + 1).padStart(2, '0')}-32T12:30:45Z`, + ), + ].map((timestamp) => ({ + name: `calendar timestamp ${timestamp}`, + original: '"created_at": "2026-04-13T00:00:00.000Z"', + replacement: `"created_at": "${timestamp}"`, + })), + ])('rejects invalid $name', async ({ original, replacement }) => { + const gateway = createGatewayWithTaskField(original, replacement); + + await expect(gateway.getTask('task-1')).rejects.toEqual( + new TaskGatewayError( + 'unavailable', + 'The task API returned an invalid task shape.', + ), + ); + }); }); diff --git a/frontend-pwa/src/task_slice/adapters/http/http-task-gateway.ts b/frontend-pwa/src/task_slice/adapters/http/http-task-gateway.ts index dfd1df3f..b1e44c78 100644 --- a/frontend-pwa/src/task_slice/adapters/http/http-task-gateway.ts +++ b/frontend-pwa/src/task_slice/adapters/http/http-task-gateway.ts @@ -4,190 +4,110 @@ * This adapter consumes the stabilized Corbusier task contract while keeping * transport parsing and error mapping inside the adapter boundary. */ -import type { - IssueProvider, - Task, - TaskOrigin, - TaskState, -} from '../../domain/task'; +import { z } from 'zod'; + +import type { CreateTaskRequest, Task, TaskState } from '../../domain/task'; import { TaskGatewayError, type TaskGatewayErrorKind, type TaskSliceGateway, } from '../../ports/task-slice-gateway'; -interface ApiMetadata { - request_id: string; - timestamp: string; - version: string; -} - -interface ApiEnvelope { - data: T; - error: null; - metadata: ApiMetadata; - success: true; -} - -interface TaskEnvelope { - task: Task; -} - -interface SharedErrorResponse { - code: string; - details?: Record; - message: string; - traceId?: string; -} - -const TASK_STATES = new Set([ - 'draft', - 'in_progress', - 'in_review', - 'paused', - 'done', - 'abandoned', -]); - -const INVALID_TASK_SHAPE_MESSAGE = - 'The task API returned an invalid task shape.' as const; +const INVALID_TASK_RESPONSE_MESSAGE = + 'The task API returned an invalid task shape.'; const INVALID_SUCCESS_ENVELOPE_MESSAGE = - 'The task API returned an invalid success response.' as const; + 'The task API returned an invalid success response.'; const CONTRACT_VERSION = 'v1' as const; - -function taskShapeGatewayError(): TaskGatewayError { - return new TaskGatewayError('unavailable', INVALID_TASK_SHAPE_MESSAGE); -} - -function isPlainRecord(value: unknown): value is Record { - return Boolean(value && typeof value === 'object' && !Array.isArray(value)); -} - -function isIssueProvider(value: unknown): value is IssueProvider { - return value === 'github' || value === 'gitlab'; -} - -function requireNonEmptyString( - raw: Record, - field: string, -): string { - const value = raw[field]; - if (typeof value !== 'string' || value.length === 0) { - throw taskShapeGatewayError(); - } - return value; -} - -function requireStringArray(raw: Record, field: string): void { - const value = raw[field]; - if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) { - throw taskShapeGatewayError(); - } -} - -function requireOptionalString( - raw: Record, - key: string, -): void { - if (raw[key] !== undefined && typeof raw[key] !== 'string') { - throw taskShapeGatewayError(); - } -} - -function validateIssueSnapshot(raw: unknown): void { - if (!isPlainRecord(raw)) throw taskShapeGatewayError(); - requireNonEmptyString(raw, 'title'); - requireStringArray(raw, 'labels'); - requireStringArray(raw, 'assignees'); - requireOptionalString(raw, 'description'); - requireOptionalString(raw, 'milestone'); -} +const nonEmptyStringSchema = z.string().min(1); +const positiveIntegerSchema = z.number().int().min(1); +const stringArraySchema = z.array(z.string()); +const timestampSchema = z.string().refine(isValidTimestamp); +const providerSchema = z.enum(['github', 'gitlab']); + +const issueSnapshotSchema = z.looseObject({ + title: nonEmptyStringSchema, + description: z.string().optional(), + labels: stringArraySchema, + assignees: stringArraySchema, + milestone: z.string().optional(), +}); + +// The API may add fields independently; validate known fields and retain +// extensions for callers that can understand them. +const taskSchema = z.looseObject({ + id: nonEmptyStringSchema, + origin: z.looseObject({ + type: z.literal('issue'), + issue_ref: z.looseObject({ + provider: providerSchema, + repository: nonEmptyStringSchema, + issue_number: positiveIntegerSchema, + }), + metadata: issueSnapshotSchema, + }), + branch_ref: z + .looseObject({ + provider: providerSchema, + repository: nonEmptyStringSchema, + branch_name: nonEmptyStringSchema, + }) + .nullish() + .transform((reference) => reference || undefined), + pull_request_ref: z + .looseObject({ + provider: providerSchema, + repository: nonEmptyStringSchema, + pull_request_number: positiveIntegerSchema, + }) + .nullish() + .transform((reference) => reference || undefined), + state: z.enum([ + 'draft', + 'in_progress', + 'in_review', + 'paused', + 'done', + 'abandoned', + ]), + created_at: timestampSchema, + updated_at: timestampSchema, +}) satisfies z.ZodType; + +const successEnvelopeSchema = z.object({ + data: z.object({ task: z.unknown() }), + error: z.null(), + metadata: z.object({ + request_id: nonEmptyStringSchema, + timestamp: timestampSchema, + version: z.literal(CONTRACT_VERSION), + }), + success: z.literal(true), +}); + +const sharedErrorResponseSchema = z.object({ + code: z.string().optional(), + details: z.unknown().optional(), + message: z.string(), + traceId: z.string().optional(), +}); + +const invalidTaskTransitionSchema = z.object({ + reason: z.literal('invalid_task_transition'), +}); /** - * Returns true when `value` is a finite integer ≥ `min`. - * Narrows the type to `number` for the caller. + * Create the gateway error used when a task response violates its contract. + * Callers can report the failure as an unavailable API response. + * + * @returns A stable error for malformed task data. + * + * @example + * throw taskContractGatewayError(); */ -function isIntegerAtLeast(value: unknown, min: number): value is number { - return typeof value === 'number' && Number.isInteger(value) && value >= min; -} - -/** Throws if `record[field]` is not an integer ≥ `minValue`. */ -function requireIntegerField( - record: Record, - field: string, - minValue: number, -): void { - if (!isIntegerAtLeast(record[field], minValue)) { - throw taskShapeGatewayError(); - } -} - -function validateIssueRef(raw: unknown): void { - if (!isPlainRecord(raw)) throw taskShapeGatewayError(); - if (!isIssueProvider(raw.provider)) throw taskShapeGatewayError(); - requireNonEmptyString(raw, 'repository'); - requireIntegerField(raw, 'issue_number', 1); -} - -function validateTaskOrigin(raw: unknown): asserts raw is TaskOrigin { - if (!isPlainRecord(raw)) throw taskShapeGatewayError(); - if (raw.type !== 'issue') throw taskShapeGatewayError(); - validateIssueRef(raw.issue_ref); - validateIssueSnapshot(raw.metadata); -} - -function validateOptionalBranchRef(raw: unknown): void { - if (!isPlainRecord(raw)) throw taskShapeGatewayError(); - if (!isIssueProvider(raw.provider)) throw taskShapeGatewayError(); - requireNonEmptyString(raw, 'repository'); - requireNonEmptyString(raw, 'branch_name'); -} - -function validateOptionalPullRequestRef(raw: unknown): void { - if (!isPlainRecord(raw)) throw taskShapeGatewayError(); - if (!isIssueProvider(raw.provider)) throw taskShapeGatewayError(); - requireNonEmptyString(raw, 'repository'); - requireIntegerField(raw, 'pull_request_number', 1); -} - -function validateTaskState(task: Record): void { - if ( - typeof task.state !== 'string' || - !(TASK_STATES as ReadonlySet).has(task.state) - ) { - throw taskShapeGatewayError(); - } -} - -function validateOptionalRef( - raw: unknown, - validator: (v: unknown) => void, -): void { - if (raw !== undefined && raw !== null) { - validator(raw); - } -} - -function requireTimestampFields(task: Record): void { - if (!isValidTimestamp(task.created_at)) { - throw taskShapeGatewayError(); - } - if (!isValidTimestamp(task.updated_at)) { - throw taskShapeGatewayError(); - } -} - -function validateParsedTaskEnvelopeTask(task: unknown): Task { - if (!isPlainRecord(task)) throw taskShapeGatewayError(); - requireNonEmptyString(task, 'id'); - validateTaskState(task); - validateTaskOrigin(task.origin); - validateOptionalRef(task.branch_ref, validateOptionalBranchRef); - validateOptionalRef(task.pull_request_ref, validateOptionalPullRequestRef); - requireTimestampFields(task); - return task as unknown as Task; +function taskContractGatewayError(): TaskGatewayError { + return new TaskGatewayError('unavailable', INVALID_TASK_RESPONSE_MESSAGE); } /** @@ -195,7 +115,10 @@ function validateParsedTaskEnvelopeTask(task: unknown): Task { * Centralizes the repeated Content-Type + Idempotency-Key header pair and * JSON serialization so that createTask and transitionTask stay DRY. */ -function mutationInit(body: unknown, method: 'POST' | 'PUT'): RequestInit { +function mutationInit( + body: CreateTaskRequest | { state: TaskState }, + method: 'POST' | 'PUT', +): RequestInit { return { body: JSON.stringify(body), headers: { @@ -243,6 +166,18 @@ export function createHttpTaskGateway( }; } +/** + * Fetch one task resource and decode the task API's success envelope. + * Network failures and invalid responses reject with `TaskGatewayError`. + * + * @param fetchFn - Fetch implementation, injectable for adapter tests. + * @param resource - Task API URL to request. + * @param init - Optional request method, headers, and body. + * @returns The validated task from the response. + * + * @example + * await sendTaskRequest(fetch, '/tasks/123'); // Resolves to the validated task. + */ async function sendTaskRequest( fetchFn: typeof fetch, resource: string, @@ -262,32 +197,7 @@ async function sendTaskRequest( throw await readGatewayError(response); } - const envelope = await parseSuccessEnvelope(response); - return validateParsedTaskEnvelopeTask(envelope.data.task); -} - -function isValidEnvelopeShell( - parsed: unknown, -): parsed is Record { - return ( - isPlainRecord(parsed) && - parsed.success === true && - parsed.error === null && - isPlainRecord(parsed.metadata) && - hasValidMetadata(parsed.metadata) - ); -} - -function hasValidMetadata(metadata: Record): boolean { - return ( - isNonEmptyString(metadata.request_id) && - metadata.version === CONTRACT_VERSION && - isValidTimestamp(metadata.timestamp) - ); -} - -function isNonEmptyString(value: unknown): value is string { - return typeof value === 'string' && value.length > 0; + return parseSuccessEnvelope(response); } interface ParsedTimestamp { @@ -303,10 +213,21 @@ interface ParsedTimestamp { const TIMESTAMP_RE = /^(?\d{4})-(?\d{2})-(?\d{2})T(?\d{2}):(?\d{2}):(?\d{2})(?:\.(?\d+))?(?Z|[+-]\d{2}:\d{2})$/; -function parseTimestampGroups(value: string): ParsedTimestamp | null { +/** + * Parse the components of an ISO timestamp with an explicit UTC offset. + * Returns `undefined` when the string does not match the timestamp syntax; + * calendar and range checks are performed by `isValidTimestamp`. + * + * @param value - Timestamp string to parse. + * @returns Parsed components, or `undefined` when the syntax does not match. + * + * @example + * parseTimestampGroups('2024-02-29T12:30:00Z'); // Returns the parsed components. + */ +function parseTimestampGroups(value: string): ParsedTimestamp | undefined { const match = TIMESTAMP_RE.exec(value); const groups = match?.groups; - if (!groups) return null; + if (!groups) return undefined; return { year: Number(groups.year), month: Number(groups.month), @@ -347,10 +268,18 @@ function matchesCalendarDate(p: ParsedTimestamp): boolean { ); } -function isValidTimestamp(value: unknown): value is string { - if (!isNonEmptyString(value)) return false; +/** + * Check that a timestamp has valid syntax, calendar values, time, and offset. + * + * @param value - Timestamp string to validate. + * @returns `true` only for a valid timestamp with an explicit offset. + * + * @example + * isValidTimestamp('2024-02-29T12:30:00Z'); // Returns true. + */ +function isValidTimestamp(value: string): boolean { const parts = parseTimestampGroups(value); - if (parts === null) return false; + if (parts === undefined) return false; return ( isValidTimeOfDay(parts.hour, parts.minute, parts.second) && isValidOffset(parts.offset) && @@ -358,21 +287,21 @@ function isValidTimestamp(value: unknown): value is string { ); } -function hasValidTaskPayload(data: unknown): boolean { - return ( - isPlainRecord(data) && - 'task' in data && - data.task !== null && - data.task !== undefined - ); -} - -async function parseSuccessEnvelope( - response: Response, -): Promise> { - let parsed: unknown; +/** + * Decode and validate a successful task API response. + * Malformed JSON, an invalid envelope, or an invalid task rejects with a + * `TaskGatewayError` of kind `unavailable`. + * + * @param response - Successful HTTP response from the task API. + * @returns The validated task in `data.data.task`. + * + * @example + * await parseSuccessEnvelope(response); // Resolves to the response task. + */ +async function parseSuccessEnvelope(response: Response): Promise { + let responseBody: unknown; try { - parsed = await response.json(); + responseBody = await response.json(); } catch { throw new TaskGatewayError( 'unavailable', @@ -380,60 +309,81 @@ async function parseSuccessEnvelope( ); } - if (!isValidEnvelopeShell(parsed)) { - throw new TaskGatewayError('unavailable', INVALID_SUCCESS_ENVELOPE_MESSAGE); - } - if (!hasValidTaskPayload(parsed.data)) { + const envelope = successEnvelopeSchema.safeParse(responseBody); + if (!envelope.success) { throw new TaskGatewayError('unavailable', INVALID_SUCCESS_ENVELOPE_MESSAGE); } - return parsed as unknown as ApiEnvelope; + const task = taskSchema.safeParse(envelope.data.data.task); + if (!task.success) throw taskContractGatewayError(); + return task.data; } +/** + * Convert a non-success HTTP response into the gateway's typed error. + * Invalid or unreadable error bodies use the HTTP-status fallback and the + * `unavailable` kind. + * + * @param response - Non-success response returned by the task API. + * @returns A gateway error with the mapped kind and API or fallback message. + * + * @example + * const error = await readGatewayError(response); // Inspect `error.kind` and `error.message`. + */ async function readGatewayError(response: Response): Promise { - const HTTP_STATUS_TO_ERROR_KIND: Readonly< - Partial> - > = { - 400: 'validation', - 401: 'unauthorized', - 403: 'unauthorized', - 404: 'not_found', - 409: 'conflict', - } as const; - - function classifyErrorKind(statusCode: number): TaskGatewayErrorKind { - return HTTP_STATUS_TO_ERROR_KIND[statusCode] ?? 'unavailable'; - } - - function mapErrorKind( - statusCode: number, - errorBody: SharedErrorResponse, - ): TaskGatewayErrorKind { - const reason = errorBody.details?.reason; - if (typeof reason === 'string' && reason === 'invalid_task_transition') { - return 'conflict'; - } - - return classifyErrorKind(statusCode); - } - const fallbackMessage = `The task API returned HTTP ${response.status}.`; - let body: unknown; + let responseBody: unknown; try { - body = await response.json(); + responseBody = await response.json(); } catch { return new TaskGatewayError('unavailable', fallbackMessage); } - if (!isPlainRecord(body) || typeof body.message !== 'string') { + const errorBody = sharedErrorResponseSchema.safeParse(responseBody); + if (!errorBody.success) { return new TaskGatewayError('unavailable', fallbackMessage); } - const errorBody = body as unknown as SharedErrorResponse; + const errorKind = invalidTaskTransitionSchema.safeParse( + errorBody.data.details, + ).success + ? 'conflict' + : (errorKindForStatus(response.status) ?? 'unavailable'); return new TaskGatewayError( - mapErrorKind(response.status, errorBody), - errorBody.message || fallbackMessage, + errorKind, + errorBody.data.message || fallbackMessage, ); } + +/** + * Map task API status codes to their corresponding gateway error kinds. + * Unknown statuses return `undefined` so callers can choose a fallback. + * + * @param status - HTTP status code from the task API. + * @returns A mapped kind for known client errors, otherwise `undefined`. + * + * @example + * errorKindForStatus(404); // Returns 'not_found'. + */ +function errorKindForStatus(status: number): TaskGatewayErrorKind | undefined { + switch (status) { + case 400: { + return 'validation'; + } + case 401: + case 403: { + return 'unauthorized'; + } + case 404: { + return 'not_found'; + } + case 409: { + return 'conflict'; + } + default: { + return undefined; + } + } +} diff --git a/frontend-pwa/src/task_slice/application/task-gateway-context.test.tsx b/frontend-pwa/src/task_slice/application/task-gateway-context.test.tsx index c991a441..a59ade4d 100644 --- a/frontend-pwa/src/task_slice/application/task-gateway-context.test.tsx +++ b/frontend-pwa/src/task_slice/application/task-gateway-context.test.tsx @@ -9,7 +9,7 @@ import { TaskGatewayProvider, useTaskGateway } from './task-gateway-context'; describe('useTaskGateway', () => { it('throws when called outside a TaskGatewayProvider', () => { - const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); try { expect(() => renderHook(() => useTaskGateway())).toThrow( diff --git a/frontend-pwa/src/task_slice/application/task-gateway-context.tsx b/frontend-pwa/src/task_slice/application/task-gateway-context.tsx index 7dec8656..a8a20263 100644 --- a/frontend-pwa/src/task_slice/application/task-gateway-context.tsx +++ b/frontend-pwa/src/task_slice/application/task-gateway-context.tsx @@ -11,7 +11,9 @@ import { createContext, type PropsWithChildren, useContext } from 'react'; import type { TaskSliceGateway } from '../ports/task-slice-gateway'; -const TaskGatewayContext = createContext(null); +const TaskGatewayContext = createContext( + undefined, +); /** * Injects a `TaskSliceGateway` implementation into the React context tree. diff --git a/frontend-pwa/src/task_slice/ui/task-create-form.test.tsx b/frontend-pwa/src/task_slice/ui/task-create-form.test.tsx index e998555c..9c973e44 100644 --- a/frontend-pwa/src/task_slice/ui/task-create-form.test.tsx +++ b/frontend-pwa/src/task_slice/ui/task-create-form.test.tsx @@ -1,9 +1,9 @@ /** * @file Unit tests for the `TaskCreateForm` presentational component. */ -import { fireEvent, render, screen } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import type { FormEvent } from 'react'; +import type { SyntheticEvent } from 'react'; import { I18nProvider } from '../../i18n/runtime'; import type { TaskCreateDraft, TaskCreateErrors } from '../domain/task-form'; @@ -22,13 +22,14 @@ const emptyDraft: TaskCreateDraft = { const noErrors: TaskCreateErrors = {}; +/** Renders the form with default values and returns its event handlers. */ function renderForm(overrides?: { draft?: Partial; errors?: TaskCreateErrors; isSubmitting?: boolean; submitError?: string; onChange?: (field: string, value: string) => void; - onSubmit?: (event: FormEvent) => void; + onSubmit?: (event: SyntheticEvent) => void; }) { const onChange = overrides?.onChange ?? vi.fn(); const onSubmit = overrides?.onSubmit ?? vi.fn(); @@ -103,12 +104,17 @@ describe('TaskCreateForm', () => { }); it('calls onSubmit when the form is submitted', async () => { - const { onSubmit } = renderForm(); + let submittedFormClass: string | undefined; + renderForm({ + onSubmit: (event) => { + submittedFormClass = event.currentTarget.className; + }, + }); const submitButton = screen.getByRole('button', { name: 'Create task' }); - fireEvent.submit(submitButton.closest('form') as HTMLFormElement); + await userEvent.click(submitButton); - expect(onSubmit).toHaveBeenCalledTimes(1); + expect(submittedFormClass).toBe('task-create-form'); }); it('matches the expected rendered structure for the idle state', () => { diff --git a/frontend-pwa/src/task_slice/ui/task-create-form.tsx b/frontend-pwa/src/task_slice/ui/task-create-form.tsx index d9517293..fd7e8476 100644 --- a/frontend-pwa/src/task_slice/ui/task-create-form.tsx +++ b/frontend-pwa/src/task_slice/ui/task-create-form.tsx @@ -6,7 +6,7 @@ * `TaskCreateFormProps`; this component is purely presentational and carries * no side-effects. Field names and labels are resolved via `useI18n`. */ -import type { FormEvent } from 'react'; +import type { SyntheticEvent } from 'react'; import { useI18n } from '../../i18n/runtime'; import type { @@ -21,7 +21,7 @@ interface TaskCreateFormProps { isSubmitting: boolean; submitError?: string; onChange(field: TaskCreateField, value: string): void; - onSubmit(event: FormEvent): void; + onSubmit(event: SyntheticEvent): void; } /** @@ -49,7 +49,7 @@ export function TaskCreateForm({
{submitError}
- ) : null} + ) : undefined}
{error ? ( {error} - ) : null} + ) : undefined} ); } diff --git a/frontend-pwa/src/task_slice/ui/task-detail-card.tsx b/frontend-pwa/src/task_slice/ui/task-detail-card.tsx index c94fbe72..08cd1dba 100644 --- a/frontend-pwa/src/task_slice/ui/task-detail-card.tsx +++ b/frontend-pwa/src/task_slice/ui/task-detail-card.tsx @@ -81,7 +81,7 @@ export function TaskDetailCard({ task }: { task: Task }) { {task.origin.metadata.description}

- ) : null} + ) : undefined} ); } diff --git a/frontend-pwa/src/test/audit-command.test.mjs b/frontend-pwa/src/test/audit-command.test.mjs new file mode 100644 index 00000000..ebb8758c --- /dev/null +++ b/frontend-pwa/src/test/audit-command.test.mjs @@ -0,0 +1,69 @@ +/** + * @file End-to-end checks for the Bun audit wrapper and its exit status. + */ +import { spawnSync } from 'node:child_process'; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const frontendRoot = process.cwd(); +const auditScript = path.join(frontendRoot, 'scripts/run-audit.mjs'); + +describe('audit command', () => { + let fakeBin; + let bun; + + beforeAll(() => { + const lookup = spawnSync('which', ['bun'], { encoding: 'utf8' }); + if (lookup.status !== 0 || !lookup.stdout.trim()) { + throw new Error('Bun must be available to exercise the audit command'); + } + bun = lookup.stdout.trim(); + fakeBin = mkdtempSync(path.join(tmpdir(), 'corbusier-audit-')); + const executable = path.join(fakeBin, 'bun'); + writeFileSync( + executable, + `#!/bin/sh +case "$AUDIT_FIXTURE" in + clean) printf '%s' '{"advisories":{}}'; exit 0 ;; + invalid_json) printf '%s' '{'; exit 0 ;; + malformed) printf '%s' '{"advisories":{"lodash":[{"github_advisory_id":5}]}}'; exit 0 ;; + unexpected) printf '%s' '{"advisories":{"lodash":[{"github_advisory_id":"GHSA-abcd-efgh-ijkl"}]}}'; exit 0 ;; + nonzero) printf '%s' '{"advisories":{}}'; exit 7 ;; + signal) kill -TERM $$ ;; +esac +exit 99 +`, + ); + chmodSync(executable, 0o755); + }); + + afterAll(() => { + rmSync(fakeBin, { recursive: true, force: true }); + }); + + it.each([ + ['clean', 0, ''], + ['invalid_json', 1, 'Failed to parse bun audit JSON'], + ['malformed', 1, 'Invalid bun audit advisory payload'], + ['unexpected', 1, 'Unexpected vulnerabilities detected by bun audit'], + ['nonzero', 7, ''], + ['signal', 1, 'bun audit did not exit normally'], + ])('handles %s audit output', (scenario, status, errorText) => { + const result = spawnSync(bun, ['run', auditScript], { + cwd: frontendRoot, + encoding: 'utf8', + env: { + ...process.env, + AUDIT_FIXTURE: scenario, + PATH: `${fakeBin}${path.delimiter}${process.env.PATH ?? ''}`, + }, + }); + + expect(result.status).toBe(status); + if (errorText !== '') { + expect(result.stderr).toContain(errorText); + } + }); +}); diff --git a/frontend-pwa/src/test/audit-utils.test.mjs b/frontend-pwa/src/test/audit-utils.test.mjs new file mode 100644 index 00000000..2b4607a6 --- /dev/null +++ b/frontend-pwa/src/test/audit-utils.test.mjs @@ -0,0 +1,91 @@ +/** + * @file + * Unit tests for parsing Bun audit output at the command boundary. + */ +import { describe, expect, it } from 'vitest'; + +import { + collectAdvisories, + partitionAdvisoriesById, +} from '../../security/audit-utils.js'; + +describe('collectAdvisories', () => { + it('extracts advisory details from Bun nested output', () => { + const advisories = collectAdvisories({ + advisories: { + lodash: [ + { + title: 'Prototype pollution', + url: 'https://github.com/advisories/GHSA-abcd-efgh-ijkl', + }, + ], + }, + }); + + expect(advisories).toMatchObject([ + { + github_advisory_id: 'GHSA-abcd-efgh-ijkl', + package: 'lodash', + title: 'Prototype pollution', + }, + ]); + }); + + it('accepts direct package entries and empty audit output', () => { + const directAdvisories = collectAdvisories({ + lodash: [{ github_advisory_id: 'GHSA-abcd-efgh-ijkl' }], + }); + + expect(directAdvisories).toMatchObject([ + { + github_advisory_id: 'GHSA-abcd-efgh-ijkl', + package: 'lodash', + }, + ]); + expect(collectAdvisories({})).toEqual([]); + }); + + it('rejects a malformed record rather than dropping valid advisories', () => { + const mixed = { + advisories: { + lodash: [ + { github_advisory_id: 'GHSA-abcd-efgh-ijkl' }, + { github_advisory_id: 42 }, + ], + }, + }; + + expect(() => collectAdvisories(mixed)).toThrow( + 'Invalid bun audit advisory payload', + ); + }); + + it.each([ + { advisories: 'invalid' }, + { advisories: { lodash: 'invalid' } }, + { lodash: [{ title: 5 }] }, + { lodash: ['invalid'] }, + ])('rejects malformed advisory maps: %j', (input) => { + expect(() => collectAdvisories(input)).toThrow( + 'Invalid bun audit advisory payload', + ); + }); + + it('prefers the explicit advisory ID and rejects unidentified records', () => { + const advisories = collectAdvisories({ + lodash: [ + { + github_advisory_id: 'GHSA-aaaa-bbbb-cccc', + url: 'https://github.com/advisories/GHSA-dddd-eeee-ffff', + }, + { url: 'https://example.com/advisory' }, + ], + }); + + const result = partitionAdvisoriesById(advisories, ['GHSA-aaaa-bbbb-cccc']); + expect(result.expected).toMatchObject([ + { github_advisory_id: 'GHSA-aaaa-bbbb-cccc', package: 'lodash' }, + ]); + expect(result.unexpected).toMatchObject([{ package: 'lodash' }]); + }); +}); diff --git a/frontend-pwa/src/test/semantic-cli.test.mjs b/frontend-pwa/src/test/semantic-cli.test.mjs new file mode 100644 index 00000000..7d470576 --- /dev/null +++ b/frontend-pwa/src/test/semantic-cli.test.mjs @@ -0,0 +1,195 @@ +/** + * @file Exercise semantic lint scripts as commands against isolated project + * fixtures. + * The fixture copies only the script under test and shares installed packages; + * it never changes the real frontend configuration or process environment. + * Run through the frontend Vitest suite to check observable command outcomes. + */ +import { spawnSync } from 'node:child_process'; +import { + copyFileSync, + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +const frontendRoot = process.cwd(); +let fixtureRoot; + +/** Run one copied Bun script with its working directory set to the fixture. */ +function runScript(name) { + const script = path.join(fixtureRoot, 'scripts', name); + copyFileSync(path.join(frontendRoot, 'scripts', name), script); + const result = spawnSync('bun', [script], { + cwd: fixtureRoot, + encoding: 'utf8', + timeout: 30_000, + }); + if (result.error) { + throw result.error; + } + return result; +} + +/** Write the semantic-lint configuration consumed by the fixture commands. */ +function writeConfig(config) { + writeFileSync( + path.join(fixtureRoot, 'tools', 'semantic-lint.config.json'), + JSON.stringify(config), + ); +} + +/** Supply one TSX source file for scripts that inspect component markup. */ +function writeComponent(source) { + writeFileSync(path.join(fixtureRoot, 'src', 'fixture.tsx'), source); +} + +describe('semantic lint commands', () => { + beforeEach(() => { + fixtureRoot = mkdtempSync(path.join(tmpdir(), 'corbusier-semantic-')); + for (const directory of ['scripts', 'src', 'tools']) { + mkdirSync(path.join(fixtureRoot, directory)); + } + symlinkSync( + path.join(frontendRoot, 'node_modules'), + path.join(fixtureRoot, 'node_modules'), + 'junction', + ); + }); + + afterEach(() => { + rmSync(fixtureRoot, { recursive: true, force: true }); + }); + + it('uses the default class limit and accepts an explicit higher limit', () => { + const classes = Array.from({ length: 17 }, (_, index) => `c${index}`).join( + ' ', + ); + writeComponent(`
`); + writeConfig({}); + + const defaultResult = runScript('check-classlist-length.ts'); + expect(defaultResult.status).toBe(1); + expect(defaultResult.stderr).toContain('17 utilities (max 16)'); + + writeConfig({ maxClasslistLength: 17 }); + const configuredResult = runScript('check-classlist-length.ts'); + expect(configuredResult.status).toBe(0); + }); + + it.each([ + 0, + 0.5, + -1, + '16', + ])('rejects an invalid class limit %s', (maxClasslistLength) => { + writeConfig({ maxClasslistLength }); + const result = runScript('check-classlist-length.ts'); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('maxClasslistLength'); + }); + + it('floors a fractional class limit while preserving a minimum of one', () => { + writeConfig({ maxClasslistLength: 1.9 }); + writeComponent('
'); + const withinLimit = runScript('check-classlist-length.ts'); + expect(withinLimit.status).toBe(0); + + writeComponent('
'); + const overLimit = runScript('check-classlist-length.ts'); + expect(overLimit.status).toBe(1); + expect(overLimit.stderr).toContain('2 utilities (max 1)'); + }); + + it('reports repeated stacks without failing by default and can fail explicitly', () => { + writeComponent( + '<>
', + ); + writeConfig({}); + const defaultResult = runScript('find-near-duplicate-classes.ts'); + expect(defaultResult.status).toBe(0); + expect(defaultResult.stderr).toContain('Repeated class stack'); + + writeConfig({ nearDuplicateClasses: { failOnViolation: true } }); + const configuredResult = runScript('find-near-duplicate-classes.ts'); + expect(configuredResult.status).toBe(1); + expect(configuredResult.stderr).toContain('Repeated class stack'); + }); + + it.each([ + { minTokenCount: 0 }, + { maxJaccardDistance: 1.1 }, + { minOccurrences: 1.5 }, + { suppressPrefixes: ['safe-', 1] }, + { failOnViolation: 'yes' }, + ])('rejects invalid near-duplicate options %#', (options) => { + writeConfig({ nearDuplicateClasses: options }); + const result = runScript('find-near-duplicate-classes.ts'); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('nearDuplicateClasses'); + }); + + it('uses default user-facing attributes when the configured list is empty', () => { + writeComponent(''); + writeConfig({}); + const defaultResult = runScript('check-hardcoded-strings.ts'); + expect(defaultResult.status).toBe(0); + + writeConfig({ hardcodedStrings: { minWordLength: 1 } }); + const configuredResult = runScript('check-hardcoded-strings.ts'); + expect(configuredResult.status).toBe(1); + expect(configuredResult.stderr).toContain('hard-coded JSX text'); + }); + + it('accepts matching locale maps and rejects a non-string catalogue', () => { + const localeDir = path.join(fixtureRoot, 'src', 'i18n'); + mkdirSync(localeDir); + writeFileSync( + path.join(localeDir, 'en-gb.ts'), + 'export const messages = { greeting: "Hello {name}" };\n', + ); + writeFileSync( + path.join(localeDir, 'fr.ts'), + 'export const messages = { greeting: "Bonjour {name}" };\n', + ); + + const matchingResult = runScript('check-locale-vars.ts'); + expect(matchingResult.status).toBe(0); + expect(matchingResult.stdout).toContain('Locale catalogues match'); + + writeFileSync( + path.join(localeDir, 'fr.ts'), + 'export const messages = { greeting: 12 };\n', + ); + const invalidResult = runScript('check-locale-vars.ts'); + expect(invalidResult.status).not.toBe(0); + expect(invalidResult.stderr).toContain( + 'Expected exactly one message catalogue export', + ); + }); +}); diff --git a/frontend-pwa/src/test/test-utils.tsx b/frontend-pwa/src/test/test-utils.tsx index a5bf09b8..006de4ea 100644 --- a/frontend-pwa/src/test/test-utils.tsx +++ b/frontend-pwa/src/test/test-utils.tsx @@ -15,12 +15,16 @@ import { createAppRouter } from '../app/router'; import { createFixtureTaskGateway } from '../task_slice/adapters/fixture/fixture-task-gateway'; import type { Task } from '../task_slice/domain/task'; -export async function renderApp( - { initialPath = '/tasks/new', seedTasks } = {} as { - initialPath?: string; - seedTasks?: Task[]; - }, -) { +interface RenderAppOptions { + initialPath?: string; + seedTasks?: Task[]; +} + +/** Mounts the app with fixture tasks and waits for its initial route to load. */ +export async function renderApp({ + initialPath = '/tasks/new', + seedTasks, +}: RenderAppOptions = {}) { const history = createMemoryHistory({ initialEntries: [initialPath] }); const router = createAppRouter(); router.update({ history }); diff --git a/frontend-pwa/tests/e2e/http-task-flow.spec.ts b/frontend-pwa/tests/e2e/http-task-flow.spec.ts new file mode 100644 index 00000000..9a6d3a26 --- /dev/null +++ b/frontend-pwa/tests/e2e/http-task-flow.spec.ts @@ -0,0 +1,129 @@ +/** + * @file Exercise HTTP-backed task flows through the rendered application. + * + * Playwright intercepts the API boundary while the application uses its real + * HTTP gateway and renders the returned task or mapped error state. + */ +import { expect, test } from '@playwright/test'; + +const taskEnvelope = `{ + "data": { + "task": { + "id": "http-task-1", + "origin": { + "type": "issue", + "issue_ref": { + "provider": "github", + "repository": "acme/widgets", + "issue_number": 84 + }, + "metadata": { + "title": "HTTP-backed task", + "labels": [], + "assignees": [] + } + }, + "state": "draft", + "created_at": "2026-04-13T00:00:00.000Z", + "updated_at": "2026-04-13T00:00:00.000Z" + } + }, + "error": null, + "metadata": { + "request_id": "req-http-1", + "timestamp": "2026-04-13T00:00:00.000Z", + "version": "v1" + }, + "success": true +}`; + +test('creates a task over HTTP and loads its detail', async ({ page }) => { + await page.route('**/api/v1/tasks', async (route) => { + await route.fulfill({ + body: taskEnvelope, + contentType: 'application/json', + status: 201, + }); + }); + await page.route('**/api/v1/tasks/http-task-1', async (route) => { + await route.fulfill({ + body: taskEnvelope, + contentType: 'application/json', + }); + }); + await page.goto('/tasks/new'); + await page.getByLabel('Repository').fill('acme/widgets'); + await page.getByLabel('Issue number').fill('84'); + await page.getByLabel('Title').fill('HTTP-backed task'); + + const createRequest = page.waitForRequest( + (request) => + request.url().endsWith('/api/v1/tasks') && request.method() === 'POST', + ); + await page.getByRole('button', { name: 'Create task' }).click(); + const request = await createRequest; + + expect(request.postData()).toBe( + JSON.stringify({ + provider: 'github', + repository: 'acme/widgets', + issue_number: 84, + title: 'HTTP-backed task', + }), + ); + expect(request.headers()['idempotency-key']).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + await expect(page.getByText('Task detail')).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'HTTP-backed task' }), + ).toBeVisible(); + + // Reloading clears the create mutation's query cache and exercises GET. + const detailRequest = page.waitForRequest( + (candidate) => + candidate.url().endsWith('/api/v1/tasks/http-task-1') && + candidate.method() === 'GET', + ); + await page.goto('/tasks/http-task-1'); + await detailRequest; + await expect( + page.getByRole('heading', { name: 'HTTP-backed task' }), + ).toBeVisible(); +}); + +test('maps an HTTP not-found response to the task not-found view', async ({ + page, +}) => { + await page.route('**/api/v1/tasks/missing', async (route) => { + await route.fulfill({ + json: { code: 'not_found', message: 'Task not found' }, + status: 404, + }); + }); + + await page.goto('/tasks/missing'); + + await expect(page.getByText('Task not found')).toBeVisible(); + await expect( + page.getByRole('link', { name: 'Return to task creation' }), + ).toBeVisible(); +}); + +test('shows the error state for a malformed successful HTTP response', async ({ + page, +}) => { + await page.route('**/api/v1/tasks/invalid', async (route) => { + await route.fulfill({ + body: '{', + contentType: 'application/json', + status: 200, + }); + }); + + await page.goto('/tasks/invalid'); + + await expect(page.getByRole('alert')).toHaveText( + 'Something went wrong loading the task.', + ); +}); diff --git a/frontend-pwa/tests/setup-vitest-a11y.ts b/frontend-pwa/tests/setup-vitest-a11y.ts index a6cbaede..f69d4ace 100644 --- a/frontend-pwa/tests/setup-vitest-a11y.ts +++ b/frontend-pwa/tests/setup-vitest-a11y.ts @@ -31,28 +31,40 @@ declare module 'vitest' { } } -expect.extend( - toHaveNoViolations as unknown as Parameters[0], +expect.extend(toHaveNoViolations); + +declare global { + var IS_REACT_ACT_ENVIRONMENT: boolean; +} + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; +globalThis.scrollTo = () => {}; + +const matchMediaDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'matchMedia', ); +const existingMatchMedia = + matchMediaDescriptor?.get?.call(globalThis) ?? matchMediaDescriptor?.value; -( - globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT: boolean; - } -).IS_REACT_ACT_ENVIRONMENT = true; - -window.scrollTo = () => {}; - -if (!window.matchMedia) { - window.matchMedia = (query: string): MediaQueryList => - ({ - media: query, - matches: false, - onchange: null, - addEventListener: () => undefined, - removeEventListener: () => undefined, - addListener: () => undefined, - removeListener: () => undefined, - dispatchEvent: () => true, - }) as MediaQueryList; +if (!existingMatchMedia) { + globalThis.matchMedia = (query: string): MediaQueryList => { + const members = new Map([ + ['media', query], + ['matches', false], + ['onchange', undefined], + ['addEventListener', () => {}], + ['removeEventListener', () => {}], + ['dispatchEvent', () => true], + ['addListener', () => {}], + ['removeListener', () => {}], + ]); + + // SAFETY: The proxy supplies the MediaQueryList members used by tests and + // no-op legacy listener methods that JSDOM's replacement still needs. + return new Proxy( + {}, + { get: (_target, property) => members.get(property) }, + ) as MediaQueryList; + }; } diff --git a/frontend-pwa/tools/stylelint.config.cjs b/frontend-pwa/tools/stylelint.config.cjs index 561c4617..1413bfe7 100644 --- a/frontend-pwa/tools/stylelint.config.cjs +++ b/frontend-pwa/tools/stylelint.config.cjs @@ -1,3 +1,6 @@ +/** + * @file Configures Stylelint checks for frontend CSS, used by `semantic:lint`. + */ module.exports = { plugins: ["stylelint-declaration-strict-value"], rules: { @@ -12,7 +15,7 @@ module.exports = { "fill", "stroke", ], - { ignoreValues: ["transparent", "currentColor", "/^var\\(--/"] }, + { ignoreValues: ['transparent', 'currentColor', String.raw`/^var\(--/`] }, ], "declaration-no-important": true, "declaration-property-unit-disallowed-list": { diff --git a/frontend-pwa/vitest.config.ts b/frontend-pwa/vitest.config.ts index de289511..512dd737 100644 --- a/frontend-pwa/vitest.config.ts +++ b/frontend-pwa/vitest.config.ts @@ -10,7 +10,7 @@ export default defineConfig({ test: { environment: 'jsdom', globals: true, - include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], + include: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'src/**/*.test.mjs'], setupFiles: ['./src/test/setup.ts'], }, });