From 35449fdf91d02aa82aaa35850774d73eea785b00 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 30 Aug 2026 18:19:25 -0700 Subject: [PATCH 01/13] docs: specify Angular 22 consumer support --- .../2026-08-30-angular-22-support-design.md | 302 ++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-30-angular-22-support-design.md diff --git a/docs/superpowers/specs/2026-08-30-angular-22-support-design.md b/docs/superpowers/specs/2026-08-30-angular-22-support-design.md new file mode 100644 index 000000000..ad8e5cfa0 --- /dev/null +++ b/docs/superpowers/specs/2026-08-30-angular-22-support-design.md @@ -0,0 +1,302 @@ +# Angular 22 Consumer Support Design + +**Date:** 2026-08-30 +**Status:** Reviewed; awaiting user approval +**Scope:** Add Angular 22 to the supported consumer-version contract without upgrading the monorepo authoring toolchain to Angular 22 + +## Summary + +Threadplane will add Angular 22 as a supported consumer version by widening Angular peer ranges, making the fresh-consumer smoke harness version-aware, and adding strict install/build/runtime coverage for Angular 20, 21, and 22. The packages will continue to be built by the current Angular 21 toolchain in this change. + +The monorepo-wide Angular 22 migration is intentionally deferred. That migration requires Nx 23.1 or newer, TypeScript 6, a newer Node floor, and broader source/configuration migrations. Combining it with the consumer-support change would make failures harder to attribute and would expand the release risk without being necessary to validate Angular 22 consumers. + +## Context + +The public compatibility matrix currently marks Angular 20 and 21 as supported and Angular 22 as planned. Angular-facing package manifests likewise advertise only `^20.0.0 || ^21.0.0`. The root workspace currently builds with Angular 21.1 and TypeScript 5.9. + +Angular 22 changes more than the framework version: + +- Angular 22 requires TypeScript `>=6.0.0 <6.1.0` and Node `^22.22.3 || ^24.15.0 || ^26.0.0` ([Angular version compatibility](https://angular.dev/reference/versions)). +- Nx supports Angular 22 starting at Nx 23.1 ([Nx and Angular compatibility](https://nx.dev/docs/kb/angular-nx-version-matrix)). +- TypeScript 6 changes defaults and deprecates `baseUrl`, which this workspace uses ([TypeScript 6 release notes](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-6-0.html)). +- Angular 22 makes `ChangeDetectionStrategy.OnPush` the component default ([Angular advanced component configuration](https://angular.dev/guide/components/advanced-configuration)). Angular's update migration adds an explicit eager strategy to existing components to preserve prior behavior ([Angular core migrations](https://github.com/angular/angular/blob/main/packages/core/schematics/migrations.json)). + +The publishable Angular libraries already use partial-Ivy compilation. Angular recommends partial-Ivy for npm libraries because application builds process that portable form with the consumer's compiler ([Angular library guidance](https://angular.dev/tools/libraries/creating-libraries)). + +## Goals + +1. Make npm accept Angular 22 consumers without `--force` or `--legacy-peer-deps`. +2. Prove that the exact package artifacts intended for release install and build in fresh Angular 20, 21, and 22 consumers. +3. Prove that a representative browser surface bootstraps and renders under each supported major, including Angular 22's change-detection default. +4. Keep package metadata, the executable compatibility matrix, and public documentation aligned. +5. Update public claims only after all supported-major checks pass. + +## Non-goals + +- Upgrading the root workspace to Angular 22, Nx 23, or TypeScript 6. +- Running Angular migration schematics across every application and library. +- Modernizing components to OnPush where doing so is unrelated to compatibility. +- Dropping Angular 20 or 21 support. +- Reworking the canonical chat demo or its backend behavior. +- Testing every feature against every Angular major. The matrix is a compatibility boundary test, not a duplicate of the full repository test suite. + +## Chosen approach + +### Support first; toolchain migration later + +The support change will preserve the current Angular 21 build toolchain and validate the resulting partial-Ivy artifacts in external consumers. This isolates the consumer-facing contract from root migration work. + +The alternatives were rejected for this change: + +1. **One-shot root migration:** moving the root to Angular 22 would also require Nx 23.1+, TypeScript 6, Node updates, lint/documentation-tool upgrades, and Angular migrations. This is too broad for a support declaration. +2. **Oldest-compiler release lane:** compiling publishable artifacts with Angular 20 would most closely follow Angular's documented rule that an application compiler should not be older than the library compiler. It would require a second build toolchain or release workspace and is disproportionate for the current release process. Angular 20 compatibility remains an empirically tested project contract rather than a guarantee derived solely from Angular's compiler-version rule. + +The selected design does not claim that peer-range editing alone creates support. Support is established by the external artifact matrix described below. + +## Support contract + +The supported majors are Angular 20, 21, and 22. A major is supported only when all of these checks pass against a fresh consumer: + +1. npm resolves dependencies with legacy peer behavior disabled. +2. The consumer installs locally packed artifacts produced by the normal production library builds. +3. Angular completes a production application build. +4. A browser loads the generated application, Angular bootstraps successfully, and representative Threadplane UI is visible. +5. The browser run reports no uncaught page errors or console errors attributable to the packages. + +The matrix will select one maintained minor line per major: + +| Angular major | Framework line | TypeScript line | Node used in CI | +| --- | --- | --- | --- | +| 20 | latest supported 20.3 patch | 5.9 | 22.22.3 or newer | +| 21 | latest supported 21.2 patch | 5.9 | 22.22.3 or newer | +| 22 | latest supported 22.x patch | 6.0 | 22.22.3 or newer | + +Exact patch versions live in one executable version registry rather than being repeated in workflow YAML, template metadata, and scripts. Version updates to that registry are ordinary dependency-maintenance changes. + +## Design components + +### 1. Executable Angular version registry + +Create a small module next to the smoke harness that exports the supported-major records. Each record owns: + +- Angular framework package versions; +- Angular CLI, builder, and compiler versions; +- TypeScript version; +- version-aligned Angular CDK and Google Maps dependencies used by the copied demo; +- the minimum Node version required by that lane. + +The module validates unsupported majors early and produces a clear error listing accepted values. Unit tests cover record completeness, supported-major parsing, and rejection of unsupported values. + +This module is the executable compatibility source of truth. Package peer ranges and website copy remain separate formats, so a verification script will compare them to the registry rather than attempting to generate package manifests or React source. + +### 2. Version-aware fresh-consumer generator + +Extend `examples/chat/smoke/cli.mjs` with `--angular-major <20|21|22>`. The generator will: + +1. Copy the existing scaffold and canonical example sources. +2. Apply the selected Angular record to the generated `package.json`. +3. Pin Threadplane dependencies to local tarballs or the requested published version as it does today. +4. Include all direct dependencies required by the copied application. This fixes the current drift where the copied example imports Angular CDK, Google Maps, LangGraph SDK, licensing, and rendering dependencies that are absent from the smoke template. +5. Run installation with `legacy-peer-deps` explicitly disabled, regardless of the repository `.npmrc`. +6. Optionally run the production build and runtime verifier. + +The checked-in smoke template should describe the consumer structure, not act as an independent Angular-version declaration. The generator overwrites every version-controlled Angular dependency from the selected registry before installation. + +The generated consumer must actively compile a representative runtime import from every public Angular-facing package: `@threadplane/chat`, `@threadplane/langgraph`, `@threadplane/ag-ui`, `@threadplane/render`, and `@threadplane/telemetry`. Installing a tarball without importing it does not count as compatibility coverage. The canonical chat surface already exercises chat, LangGraph, render, and telemetry; the generated compatibility entrypoint will add an AG-UI probe and will keep explicit probes for the other packages so later demo refactors cannot silently reduce matrix coverage. Each probe must reference a runtime export in reachable application code so TypeScript-only imports or tree-shaken dead code cannot produce a false green build. + +Install failures must preserve npm's output and identify the selected Angular lane. Unknown versions or missing local artifacts fail before changing the target directory. + +### 3. Packaged-artifact CI matrix + +The existing production library build remains the artifact producer. A new compatibility job will depend on it and use a matrix of Angular 20, 21, and 22. + +For each lane, CI will: + +1. Check out the same revision and restore/install root dependencies. +2. Download one uploaded production-build artifact from the library build job so every lane tests identical bytes. +3. Invoke the fresh-consumer generator with local package packing and the matrix major. +4. Install with `npm_config_legacy_peer_deps=false`. +5. Run the generated consumer's production build. +6. Start the generated application and run the backend-free browser verification. + +The job uses an explicit Node version satisfying Angular 22, not the ambiguous `node-version: 22` label. The Angular 20 and 21 lanes use the same Node runtime so Angular major is the only intended variable. + +The compatibility job should run whenever publishable-library source, Angular-facing package metadata, the smoke harness, the compatibility verifier, root dependency metadata, or the workflow itself changes. It may run unconditionally at first if integrating a new scope key would make the change unnecessarily complex. + +The existing library producer is conditional, so compatibility-triggering changes must also force that producer to run and upload the production artifact. A matrix job must never be skipped or left waiting because its artifact-producing dependency was filtered out by CI scope detection. + +### 4. Backend-free runtime verification + +The runtime check reuses the generated canonical chat consumer but does not send a prompt or require a LangGraph deployment. It verifies the cold welcome state, which is locally renderable. + +The verifier launches a Chromium page against the generated app and asserts: + +- the root route reaches the embed welcome state; +- the “How can I help?” heading is visible; +- the message input is visible; +- a welcome suggestion is visible; +- at least one Threadplane custom element/component host is present; +- a visible AG-UI compatibility marker rendered by code that imports and instantiates the AG-UI probe is present; +- no uncaught page exception occurs; +- no unexpected console error occurs. + +The existing root Playwright dependency can drive this check; the generated consumer does not need to own a second Playwright installation. Request failures to the intentionally absent backend are ignored only if they are known, narrowly matched startup probes. The preferred design is for cold bootstrap not to contact the backend at all. + +This browser step is required because Angular 22 changes the default change-detection strategy. A production build proves linker and type compatibility but cannot prove that component state reaches the DOM. + +### 5. Explicit change-detection behavior + +Published components that omit `changeDetection` currently inherit a version-dependent default. The implementation will inventory those components and make their intended behavior explicit: + +- choose `ChangeDetectionStrategy.OnPush` when existing state flow and tests demonstrate OnPush compatibility; +- otherwise choose `ChangeDetectionStrategy.Default` to preserve Angular 20–22 behavior; +- add or extend focused component tests for any component whose strategy becomes explicit. + +`Default` is used for preservation rather than the new `Eager` spelling because the source must still compile against the current Angular 21.1 toolchain and remain compatible with Angular 20 consumers. No component is converted to OnPush solely as cleanup. + +### 6. Peer metadata and drift guard + +Update Angular peer ranges to `^20.0.0 || ^21.0.0 || ^22.0.0` in: + +- `libs/chat/package.json`; +- `libs/langgraph/package.json`; +- `libs/ag-ui/package.json`; +- `libs/render/package.json`; +- `libs/telemetry/package.json`; +- `libs/cockpit-telemetry/package.json`; +- `libs/example-layouts/package.json`. + +The first five are public package contracts. The last two are internal manifests kept aligned to prevent workspace-only resolution from concealing incompatibilities. + +A repository verification script will fail if: + +- an Angular-facing manifest omits a supported major; +- a manifest advertises a major absent from the executable registry; +- the pricing compatibility data disagrees with the registry; +- Angular 22 is still represented as planned after the matrix is enabled. + +The check reads structured package JSON and the exported version registry. Website compatibility data should be moved to a small exported data module if necessary so the verifier and React component can consume a stable structure without parsing TSX text. + +### 7. Documentation and public claims + +Only after all three compatibility lanes pass: + +- move Angular 22 from “Planned” to “Supported” in the pricing matrix; +- update pricing detail copy that names Angular 20 and 21; +- update root and package README compatibility badges/ranges; +- update active installation documentation for chat, LangGraph, AG-UI, and render; +- document Angular 22's Node minimum where consumers choose framework versions. + +Historical posts remain historical. Generated public context or API/narrative documentation is regenerated only if its source inputs actually change; no generator runs solely because compatibility metadata changed elsewhere. + +## Data flow + +```text +supported version registry + │ + ├── smoke generator rewrites fresh consumer dependencies + │ │ + │ ├── strict npm install of packed dist artifacts + │ ├── Angular production build + │ └── backend-free browser bootstrap + │ + └── drift verifier compares + ├── Angular peer ranges + └── website compatibility data +``` + +The release artifact flows in one direction: normal production library build → npm tarballs → external consumer. Compatibility tests must not import library source through workspace path mappings. + +## Failure behavior and diagnostics + +- **Peer-resolution failure:** npm output is retained and the lane fails before build. The test must never retry with legacy peers. +- **Missing smoke dependency:** build output identifies the unresolved package; the dependency is added to the explicit consumer manifest rather than hidden through root resolution. +- **Angular compiler/linker failure:** the affected major fails independently, preserving the other matrix results. +- **Runtime bootstrap failure:** Playwright retains console errors, page exceptions, and a screenshot/trace as CI artifacts. +- **Documentation drift:** the verifier names the mismatched file, actual range/status, and expected supported majors. +- **Unsupported requested major:** the CLI exits non-zero before removing or copying the target and prints the supported values. + +## Testing strategy + +### Unit tests + +- Version registry contains complete records for 20, 21, and 22. +- CLI argument parsing accepts supported majors and rejects unsupported or missing values. +- Package rewriting pins all Angular-related dependencies to the selected record. +- The drift verifier detects missing, extra, and stale majors. + +### Integration tests + +- Existing smoke-generator tests continue to cover local tarball packing and Threadplane package pinning. +- A lightweight fixture verifies that each lane produces the expected consumer `package.json` without running npm. +- Production package builds are inspected to confirm their emitted manifests contain the new peer ranges. + +### Compatibility tests + +For each Angular major, run strict install, production build, and browser bootstrap using packed release artifacts. The generated compatibility entrypoint must cause all five public Angular-facing packages to be compiled, and the browser check must exercise at least the chat surface plus the AG-UI probe. Angular 22 must execute under a supported Node and TypeScript 6 combination. + +### Existing project verification + +Run targeted lint, unit tests, and production builds for `chat`, `langgraph`, `ag-ui`, `render`, and `telemetry`, followed by website tests/build for the compatibility-copy changes. + +## Rollout and release gate + +The support change is one atomic branch/PR. Its implementation commits may be incremental, but no intermediate commit is a releasable support state and the final PR state must satisfy every release gate below. + +1. Add the executable registry, harness repair, compatibility entrypoint, and their tests while retaining the existing public support claims. +2. Widen peers and make component change-detection behavior explicit so strict Angular 22 installation can succeed. +3. Add the three-major CI matrix, download the single production-build artifact in every lane, and confirm all packaged-artifact lanes pass. +4. Update documentation and pricing to mark Angular 22 supported. +5. Add/enable the drift verifier only after the executable registry, peer ranges, and website support status represent the same final set. Make the compatibility job and drift check required for relevant changes. +6. Publish the next package release only after generated tarball inspection and a final strict Angular 22 smoke run. + +The drift verifier has one final-state contract; it does not need a permissive “planned” mode. During implementation it may be introduced after the other metadata changes, and the PR is not ready to merge until the verifier passes. + +If the Angular 22 browser lane reveals behavior that cannot be fixed without a root Angular 22 migration, Angular 22 remains planned and that blocker becomes input to the separate toolchain-migration design. + +## Risks and mitigations + +### Angular 20 is older than the build compiler + +Angular documents that an application's compiler should be at least as new as a dependent library's compiler. The repository already builds with Angular 21 while advertising Angular 20. The Angular 20 lane therefore remains an empirical compatibility commitment backed by CI, not a guarantee derived from Angular's documented compiler direction. A future oldest-compiler release design may remove that ambiguity. + +### Root npm settings can conceal peer errors + +The root `.npmrc` enables `legacy-peer-deps`. Compatibility installs explicitly disable it and run in a generated consumer so peer failures remain visible. + +### Smoke-template drift + +Copying the full canonical demo makes the smoke test representative but creates dependency drift. The generated manifest will explicitly include every imported third-party package, and build failures are treated as harness defects rather than worked around with root resolution. + +### Angular 22 default OnPush behavior + +Missing component metadata can turn a compile-success into a runtime regression. Explicit strategies plus browser assertions make the behavior stable across supported majors. + +### Matrix cost and flakiness + +Build artifacts are produced once and reused. The runtime assertion is backend-free and narrow. Full feature E2E remains on the root workspace rather than being multiplied across three majors. + +## Deferred Angular 22 root migration + +A separate design will cover: + +- updating to the latest Nx 22 minor, then Nx 23.1+ using generated migrations; +- upgrading root Angular packages and `ng-packagr` to 22; +- adopting TypeScript 6 and resolving `baseUrl`, module-resolution, and implicit `types` assumptions; +- enforcing a Node version supported by Angular 22 in local setup and every CI job that runs `npm ci`; +- updating Angular ESLint, typescript-eslint, Analog, TypeDoc, and other TypeScript/Angular-coupled tools; +- reviewing Angular 22 component, template, hydration, HTTP, routing, test-runner, and builder migrations; +- running the full workspace test/build/E2E surface. + +That migration may follow immediately after consumer support, but it is not a prerequisite for advertising Angular 22 consumption once this design's release gate is satisfied. + +## Definition of done + +1. Every Angular-facing manifest advertises Angular 20, 21, and 22 consistently. +2. Strict fresh-consumer installs succeed for all three majors without legacy peer behavior. +3. The same packed production artifacts build successfully in all three consumers. +4. Backend-free Chromium bootstrap checks pass in all three consumers with no unexpected errors. +5. Published component behavior does not depend unintentionally on the consumer compiler's default change-detection strategy. +6. CI prevents compatibility metadata and public compatibility copy from drifting. +7. Pricing, active documentation, and README compatibility statements mark Angular 22 supported only after the matrix is green. +8. Root Angular 22/Nx 23/TypeScript 6 migration work remains outside this change. From 29d16ae8d893f9af77dcb9ec6b3cec65c4437996 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 30 Aug 2026 18:47:20 -0700 Subject: [PATCH 02/13] docs: plan Angular 22 consumer support --- .../plans/2026-08-30-angular-22-support.md | 1276 +++++++++++++++++ 1 file changed, 1276 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-30-angular-22-support.md diff --git a/docs/superpowers/plans/2026-08-30-angular-22-support.md b/docs/superpowers/plans/2026-08-30-angular-22-support.md new file mode 100644 index 000000000..2276d9ce3 --- /dev/null +++ b/docs/superpowers/plans/2026-08-30-angular-22-support.md @@ -0,0 +1,1276 @@ +# Angular 22 Consumer Support Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add Angular 22 to Threadplane's supported consumer contract and prove Angular 20, 21, and 22 compatibility with strict installs, production builds, and backend-free browser smoke tests against identical packed artifacts. + +**Architecture:** Keep the root authoring/build toolchain on Angular 21. Introduce an executable version registry for fresh consumers, build the publishable packages once, and test the resulting `dist/libs` artifact in a three-major CI matrix. Make package peers, component change-detection behavior, pricing data, and documentation explicit and enforce their agreement with a drift verifier. + +**Tech Stack:** Angular 20/21/22, TypeScript 5.9/6.0, Node 22.22.3, npm, Nx, ng-packagr partial-Ivy, Node test runner, Vitest, Playwright, GitHub Actions. + +--- + +## Required workflows and constraints + +- Use `@superpowers:test-driven-development` for Tasks 1–8: add a failing focused test, observe the expected failure, implement the minimum change, then rerun the focused test. +- Use `@superpowers:verification-before-completion` before every task commit and before the final completion claim. +- Use `npm` and Nx commands from the repository root. Pass `workdir` rather than changing directories inside shell commands. +- Do not upgrade root Angular, Nx, TypeScript, or Node dependency declarations in this plan. +- Do not run documentation generators unless an edited source is confirmed to feed generated output. +- Preserve unrelated worktree changes. This plan document is committed before execution begins; `35449fdf` remains the design-review boundary used for the final aggregate diff. + +## File structure + +### New files + +| Path | Responsibility | +| --- | --- | +| `examples/chat/smoke/angular-versions.mjs` | Executable Angular 20/21/22 lane registry and peer-range constant | +| `examples/chat/smoke/angular-versions.spec.mjs` | Registry completeness and invalid-major tests | +| `examples/chat/smoke/consumer-package.mjs` | Pure package-rewrite and strict npm-environment helpers | +| `examples/chat/smoke/consumer-package.spec.mjs` | Package rewrite and strict-install tests | +| `examples/chat/smoke/template/src/compatibility-probe.ts` | Reachable runtime imports and visible AG-UI probe | +| `examples/chat/smoke/runtime-smoke.mjs` | Backend-free Chromium bootstrap verification | +| `examples/chat/smoke/runtime-smoke.spec.mjs` | Runtime-smoke argument and package-marker contract tests | +| `libs/chat/src/lib/a2ui/catalog/change-detection.spec.ts` | Guards explicit eager-compatible metadata on catalog components | +| `scripts/verify-angular-support.mjs` | Checks peer ranges, supported-major data, and final support status | +| `scripts/verify-angular-support.spec.mjs` | Drift-verifier regression tests | +| `apps/website/src/components/pricing/angular-support.mjs` | Structured website compatibility rows | + +### Existing files modified + +- Smoke harness: `examples/chat/smoke/cli.mjs`, `examples/chat/smoke/project.json`, `examples/chat/smoke/README.md`, `examples/chat/smoke/template/package.json`, `examples/chat/smoke/template/src/main.ts`. +- Explicit change detection: the 12 A2UI catalog component files listed in Task 4. +- Angular peers: `libs/{chat,langgraph,ag-ui,render,telemetry,cockpit-telemetry,example-layouts}/package.json` and `package-lock.json`. +- Pricing: `CompatibilityMatrix.tsx`, `CompatibilityMatrix.spec.tsx`, `PricingDetails.tsx`. +- CI scope and workflow: nine `project.json` files listed in Task 7, `scripts/ci-scope.mjs`, `scripts/ci-scope.spec.mjs`, `scripts/ci-workflow.spec.mjs`, `.github/workflows/ci.yml`. +- Public docs: root/package READMEs and four active installation pages listed in Task 8. + +## Task 1: Add the executable Angular version registry + +**Files:** + +- Create: `examples/chat/smoke/angular-versions.mjs` +- Create: `examples/chat/smoke/angular-versions.spec.mjs` + +- [ ] **Step 1: Write the failing registry tests** + +Create `angular-versions.spec.mjs`: + +```js +// SPDX-License-Identifier: MIT +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + ANGULAR_LANES, + ANGULAR_PEER_RANGE, + SUPPORTED_ANGULAR_MAJORS, + getAngularLane, +} from './angular-versions.mjs'; + +describe('Angular consumer version registry', () => { + it('supports exactly Angular 20, 21, and 22', () => { + assert.deepEqual(SUPPORTED_ANGULAR_MAJORS, [20, 21, 22]); + assert.equal(ANGULAR_PEER_RANGE, '^20.0.0 || ^21.0.0 || ^22.0.0'); + }); + + it('defines deterministic framework, tooling, and Node versions per lane', () => { + for (const major of SUPPORTED_ANGULAR_MAJORS) { + const lane = ANGULAR_LANES[major]; + assert.equal(lane.major, major); + assert.match(lane.node, /^22\./); + for (const name of [ + '@angular/common', '@angular/compiler', '@angular/core', '@angular/forms', + '@angular/platform-browser', '@angular/router', '@angular/cdk', + '@angular/google-maps', + ]) { + assert.match(lane.dependencies[name], new RegExp(`^${major}\\.`), `${major} mislabeled ${name}`); + } + for (const name of ['@angular/build', '@angular/cli', '@angular/compiler-cli']) { + assert.match(lane.devDependencies[name], new RegExp(`^${major}\\.`), `${major} mislabeled ${name}`); + } + assert.ok(lane.devDependencies.typescript, `${major} missing typescript`); + } + }); + + it('uses TypeScript 6 only for Angular 22', () => { + assert.match(ANGULAR_LANES[20].devDependencies.typescript, /^5\.9\./); + assert.match(ANGULAR_LANES[21].devDependencies.typescript, /^5\.9\./); + assert.match(ANGULAR_LANES[22].devDependencies.typescript, /^6\.0\./); + }); + + it('rejects unsupported majors with the accepted values', () => { + assert.throws( + () => getAngularLane('23'), + /Unsupported Angular major 23\. Expected one of: 20, 21, 22/ + ); + }); +}); +``` + +- [ ] **Step 2: Run the test and verify the missing-module failure** + +Run: + +```bash +node --test examples/chat/smoke/angular-versions.spec.mjs +``` + +Expected: FAIL because `angular-versions.mjs` does not exist. + +- [ ] **Step 3: Implement the registry** + +Create `angular-versions.mjs` with these exact lane versions, current as of the design date: + +```js +// SPDX-License-Identifier: MIT + +export const ANGULAR_PEER_RANGE = '^20.0.0 || ^21.0.0 || ^22.0.0'; +export const SUPPORTED_ANGULAR_MAJORS = Object.freeze([20, 21, 22]); + +function lane(major, framework, cli, cdk, typescript) { + return Object.freeze({ + major, + node: '22.22.3', + dependencies: Object.freeze({ + '@angular/common': framework, + '@angular/compiler': framework, + '@angular/core': framework, + '@angular/forms': framework, + '@angular/platform-browser': framework, + '@angular/router': framework, + '@angular/cdk': cdk, + '@angular/google-maps': cdk, + }), + devDependencies: Object.freeze({ + '@angular/build': cli, + '@angular/cli': cli, + '@angular/compiler-cli': framework, + typescript, + }), + }); +} + +export const ANGULAR_LANES = Object.freeze({ + 20: lane(20, '20.3.30', '20.3.35', '20.2.14', '5.9.3'), + 21: lane(21, '21.2.22', '21.2.22', '21.2.14', '5.9.3'), + 22: lane(22, '22.1.4', '22.1.6', '22.1.4', '6.0.3'), +}); + +export function getAngularLane(value) { + const major = Number(value); + const selected = ANGULAR_LANES[major]; + if (!selected) { + throw new Error( + `Unsupported Angular major ${value}. Expected one of: ${SUPPORTED_ANGULAR_MAJORS.join(', ')}` + ); + } + return selected; +} +``` + +- [ ] **Step 4: Run the focused tests** + +Run: + +```bash +node --test examples/chat/smoke/angular-versions.spec.mjs +``` + +Expected: 4 tests PASS. + +- [ ] **Step 5: Commit the registry** + +```bash +git add examples/chat/smoke/angular-versions.mjs examples/chat/smoke/angular-versions.spec.mjs +git commit -m "test(smoke): define Angular compatibility lanes" +``` + +## Task 2: Make consumer package generation deterministic and strict + +**Files:** + +- Create: `examples/chat/smoke/consumer-package.mjs` +- Create: `examples/chat/smoke/consumer-package.spec.mjs` +- Modify: `examples/chat/smoke/cli.mjs` +- Modify: `examples/chat/smoke/template/package.json` +- Modify: `examples/chat/smoke/project.json` +- Modify: `examples/chat/smoke/README.md` + +- [ ] **Step 1: Write failing tests for package rewriting and strict npm configuration** + +Create `consumer-package.spec.mjs` covering: + +```js +// SPDX-License-Identifier: MIT +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { ANGULAR_LANES, getAngularLane } from './angular-versions.mjs'; +import { applyAngularLane, strictNpmEnv } from './consumer-package.mjs'; +import { parseArgs } from './cli.mjs'; + +describe('applyAngularLane', () => { + for (const major of [20, 21, 22]) { + it(`rewrites every Angular and TypeScript package for Angular ${major}`, () => { + const original = { + dependencies: Object.fromEntries( + Object.keys(ANGULAR_LANES[major].dependencies).map((name) => [name, 'old']) + ), + devDependencies: Object.fromEntries( + Object.keys(ANGULAR_LANES[major].devDependencies).map((name) => [name, 'old']) + ), + }; + original.dependencies.keep = '1.0.0'; + const result = applyAngularLane(original, ANGULAR_LANES[major]); + assert.deepEqual( + Object.fromEntries(Object.keys(ANGULAR_LANES[major].dependencies).map( + (name) => [name, result.dependencies[name]] + )), + ANGULAR_LANES[major].dependencies + ); + assert.deepEqual( + Object.fromEntries(Object.keys(ANGULAR_LANES[major].devDependencies).map( + (name) => [name, result.devDependencies[name]] + )), + ANGULAR_LANES[major].devDependencies + ); + assert.equal(result.dependencies.keep, '1.0.0'); + assert.notEqual(result, original); + }); + } +}); + +describe('strictNpmEnv', () => { + it('overrides repository and user legacy-peer settings', () => { + const env = strictNpmEnv({ EXISTING: 'yes', npm_config_legacy_peer_deps: 'true' }); + assert.equal(env.EXISTING, 'yes'); + assert.equal(env.npm_config_legacy_peer_deps, 'false'); + assert.equal(env.NPM_CONFIG_LEGACY_PEER_DEPS, 'false'); + }); +}); + +describe('--angular-major parsing', () => { + it('accepts a supported value', () => { + assert.equal(parseArgs(['--angular-major', '22']).angularMajor, '22'); + }); + + it('rejects a missing value', () => { + assert.throws(() => parseArgs(['--angular-major']), /--angular-major requires a value/); + }); + + it('rejects an unsupported value during lane selection', () => { + const options = parseArgs(['--angular-major', '23']); + assert.throws(() => getAngularLane(options.angularMajor), /Unsupported Angular major 23/); + }); +}); +``` + +- [ ] **Step 2: Run the tests and confirm they fail because the helper is missing** + +Run: + +```bash +node --test examples/chat/smoke/consumer-package.spec.mjs +``` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND`. + +- [ ] **Step 3: Implement the pure helpers** + +Create `consumer-package.mjs`: + +```js +// SPDX-License-Identifier: MIT + +export function applyAngularLane(packageJson, selectedLane) { + return { + ...packageJson, + dependencies: { + ...packageJson.dependencies, + ...selectedLane.dependencies, + }, + devDependencies: { + ...packageJson.devDependencies, + ...selectedLane.devDependencies, + }, + }; +} + +export function strictNpmEnv(base = process.env) { + return { + ...base, + npm_config_legacy_peer_deps: 'false', + NPM_CONFIG_LEGACY_PEER_DEPS: 'false', + }; +} +``` + +- [ ] **Step 4: Refactor `cli.mjs` to use the selected lane** + +Make these precise changes: + +1. Import `getAngularLane`, `applyAngularLane`, and `strictNpmEnv`. +2. Add `angularMajor: '21'` to defaults and parse `--angular-major`. +3. Call `getAngularLane(options.angularMajor)` immediately after parsing arguments, before resolving or deleting the target. +4. Replace `pinPackageSpecs` with `writeConsumerPackage({ target, version, packageSpecs, angularLane })`; apply the lane before pinning Threadplane tarballs. +5. Include Angular major, framework version, TypeScript version, and required Node version in `SMOKE_RUN.md`. +6. Pass `env: strictNpmEnv()` to the child `npm install`. +7. Extend `runChild` to forward `opts.env`. +8. Immediately before install, print `Angular lane : framework , TypeScript , Node >=` so an install failure is diagnosable without opening `SMOKE_RUN.md`. +9. Export `parseArgs`, guard the CLI entrypoint with `process.argv[1] === fileURLToPath(import.meta.url)`, and import `getAngularLane` in the parsing test so tests can exercise the CLI without running `main()`. + +The child spawn must become: + +```js +const child = spawn(cmd, args, { + cwd: opts.cwd, + env: opts.env ?? process.env, + stdio: 'inherit', + shell: process.platform === 'win32', +}); +``` + +- [ ] **Step 5: Repair the template's direct dependency closure** + +Keep the Angular 21 values as readable defaults—they are overwritten by the registry—and add these dependencies to `examples/chat/smoke/template/package.json`: + +```json +"@angular/cdk": "21.2.14", +"@angular/google-maps": "21.2.14", +"@ag-ui/client": "^0.0.52", +"@ag-ui/core": "^0.0.52", +"@json-render/core": "^0.16.0", +"@langchain/langgraph-sdk": "^1.7.4", +"@noble/ed25519": "^2.3.0", +"zod": "^3.25.0" +``` + +Add `"@types/google.maps": "^3.58.1"` to `devDependencies`. Do not add optional `katex` or `posthog-js` merely to silence optional peers. + +Change the template build script to `"build": "ng build --configuration production"`. Add a test that reads the template manifest and asserts this exact command so every compatibility lane fulfills the production-build contract even though the template's interactive serve default remains development. + +- [ ] **Step 6: Update the Nx target and smoke README** + +- Add `--angular-major 21` to `examples-chat-smoke:verify-local`. +- Document `--angular-major 20|21|22`, the default of 21, exact registry pinning, and strict peer resolution. +- Remove wording that implies the template itself owns the Angular version. + +- [ ] **Step 7: Run focused tests and a no-install generation for each lane** + +Run: + +```bash +node --test examples/chat/smoke/angular-versions.spec.mjs examples/chat/smoke/consumer-package.spec.mjs +node examples/chat/smoke/cli.mjs --non-interactive --fresh --target /tmp/threadplane-ng20-plan-smoke --version 0.0.62 --angular-major 20 --no-install --no-start +node examples/chat/smoke/cli.mjs --non-interactive --fresh --target /tmp/threadplane-ng21-plan-smoke --version 0.0.62 --angular-major 21 --no-install --no-start +node examples/chat/smoke/cli.mjs --non-interactive --fresh --target /tmp/threadplane-ng22-plan-smoke --version 0.0.62 --angular-major 22 --no-install --no-start +node --input-type=module -e "import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import { ANGULAR_LANES } from './examples/chat/smoke/angular-versions.mjs'; for (const major of [20,21,22]) { const pkg=JSON.parse(await readFile('/tmp/threadplane-ng'+major+'-plan-smoke/package.json','utf8')); for (const [name,version] of Object.entries(ANGULAR_LANES[major].dependencies)) assert.equal(pkg.dependencies[name],version,name); for (const [name,version] of Object.entries(ANGULAR_LANES[major].devDependencies)) assert.equal(pkg.devDependencies[name],version,name); }" +``` + +Expected: tests PASS; the integration assertion proves all generated manifests contain every selected lane version; no install occurs. + +- [ ] **Step 8: Commit the deterministic generator** + +```bash +git add examples/chat/smoke +git commit -m "feat(smoke): generate strict versioned Angular consumers" +``` + +## Task 3: Add reachable package probes and backend-free runtime smoke + +**Files:** + +- Create: `examples/chat/smoke/template/src/compatibility-probe.ts` +- Create: `examples/chat/smoke/runtime-smoke.mjs` +- Create: `examples/chat/smoke/runtime-smoke.spec.mjs` +- Modify: `examples/chat/smoke/template/src/main.ts` +- Modify: `examples/chat/smoke/cli.mjs` +- Modify: `examples/chat/smoke/README.md` + +- [ ] **Step 1: Write the failing runtime contract test** + +The test must import exported parsing/constants from `runtime-smoke.mjs` and inspect the template probe source: + +```js +// SPDX-License-Identifier: MIT +import { readFile } from 'node:fs/promises'; +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { COMPATIBILITY_PACKAGES, parseRuntimeArgs } from './runtime-smoke.mjs'; + +describe('runtime compatibility smoke', () => { + it('requires a generated target directory', () => { + assert.throws(() => parseRuntimeArgs([]), /--target is required/); + }); + + it('requires visible markers for every public Angular package', async () => { + assert.deepEqual(COMPATIBILITY_PACKAGES, [ + 'ag-ui', 'chat', 'langgraph', 'render', 'telemetry', + ]); + const source = await readFile( + new URL('./template/src/compatibility-probe.ts', import.meta.url), + 'utf8' + ); + for (const packageName of COMPATIBILITY_PACKAGES) { + assert.match( + source, + new RegExp(`data-threadplane-compatibility=["']${packageName}["']`) + ); + } + }); + + it('stubs the cold-start thread refresh and telemetry endpoints', async () => { + const source = await readFile(new URL('./runtime-smoke.mjs', import.meta.url), 'utf8'); + assert.match(source, /\/threads\/search/); + assert.match(source, /\/ingest/); + }); +}); +``` + +- [ ] **Step 2: Run it and verify the missing-module failure** + +Run: + +```bash +node --test examples/chat/smoke/runtime-smoke.spec.mjs +``` + +Expected: FAIL because `runtime-smoke.mjs` and the probe do not exist. + +- [ ] **Step 3: Add the Angular compatibility probe** + +Create a standalone component that: + +- imports `provideFakeAgent` and `injectAgent` from `@threadplane/ag-ui`; +- holds reachable runtime references to `ChatComponent`, LangGraph `provideAgent`, `RenderSpecComponent`, and `provideThreadplaneTelemetry`; +- renders one visible `... ready` for each of `ag-ui`, `chat`, `langgraph`, `render`, and `telemetry`; +- bootstraps with `provideFakeAgent({ tokens: ['compatibility'] })` so AG-UI dependency injection is executed without a backend. + +Use this implementation shape: + +```ts +// SPDX-License-Identifier: MIT +import { + ChangeDetectionStrategy, + Component, + provideZonelessChangeDetection, +} from '@angular/core'; +import { bootstrapApplication } from '@angular/platform-browser'; +import { injectAgent as injectAgUiAgent, provideFakeAgent } from '@threadplane/ag-ui'; +import { ChatComponent } from '@threadplane/chat'; +import { provideAgent as provideLangGraphAgent } from '@threadplane/langgraph'; +import { RenderSpecComponent } from '@threadplane/render'; +import { provideThreadplaneTelemetry } from '@threadplane/telemetry/browser'; + +const PACKAGE_REFS = [ + ['chat', ChatComponent], + ['langgraph', provideLangGraphAgent], + ['render', RenderSpecComponent], + ['telemetry', provideThreadplaneTelemetry], +] as const; + +@Component({ + selector: 'threadplane-compatibility-probe', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + `, + styles: [` + :host { display: block; font: 10px/1.2 monospace; padding: 2px 4px; } + span + span { margin-left: 4px; } + `], +}) +class CompatibilityProbeComponent { + private readonly agUiAgent = injectAgUiAgent(); + protected readonly agUiReady = Boolean(this.agUiAgent); + private readonly packageRefs = Object.fromEntries(PACKAGE_REFS) as Record; + + protected packageReady(name: string) { + return Boolean(this.packageRefs[name]); + } +} + +export function bootstrapCompatibilityProbe() { + const host = document.createElement('threadplane-compatibility-probe'); + document.body.append(host); + return bootstrapApplication(CompatibilityProbeComponent, { + providers: [ + provideZonelessChangeDetection(), + ...provideFakeAgent({ tokens: ['compatibility'] }), + ], + }); +} +``` + +- [ ] **Step 4: Bootstrap the probe from template `main.ts`** + +Use one shared failure path: + +```ts +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { App } from './app/app'; +import { bootstrapCompatibilityProbe } from './compatibility-probe'; + +Promise.all([ + bootstrapApplication(App, appConfig), + bootstrapCompatibilityProbe(), +]).catch((err) => console.error(err)); +``` + +- [ ] **Step 5: Implement `runtime-smoke.mjs`** + +The script must: + +1. Export `COMPATIBILITY_PACKAGES` and `parseRuntimeArgs` for the unit test. +2. Require `--target`; accept optional `--port` defaulting to `4300`. +3. Spawn `npm run start -- --configuration production --host 127.0.0.1 --port ` in the generated target. The explicit production configuration selects the copied `environment.ts`, whose backend and telemetry endpoints are under `/api`; do not rely on the template's default development serve configuration. On POSIX, spawn the server in its own process group (`detached: true`) so teardown can terminate both npm and the Angular child process. +4. Poll `/embed` for at most 60 seconds. +5. Launch Chromium from the root `@playwright/test` dependency. +6. Before navigation, route `**/api/**` inside Playwright. Fulfill `POST /api/threads/search` with HTTP 200 and `[]`, fulfill `/api/ingest` with HTTP 204, and fail the smoke on any other `/api/` request so new cold-start backend dependencies cannot appear silently. +7. Start Playwright tracing with screenshots and DOM snapshots. +8. Fail on `pageerror` or console `error` after the deterministic API routes are installed. Do not add a blanket console exception for `LangGraphThreadsAdapter.refresh`; the `/threads/search` stub must make refresh resolve cleanly. +9. Assert the “How can I help?” heading, message input, one welcome suggestion, and all five compatibility markers are visible. +10. Require the AG-UI marker text to equal `ag-ui ready`. +11. On failure, write `runtime-smoke.png` and `runtime-smoke-trace.zip` under the generated target and include captured server output in the thrown error. +12. Always close the browser and terminate the complete server process tree in `finally`. On POSIX call `process.kill(-child.pid, 'SIGTERM')`; on Windows call `child.kill('SIGTERM')`. Ignore only `ESRCH`, which means the process already exited. + +Install the route before `page.goto()`: + +```js +await page.route('**/api/**', async (route) => { + const request = route.request(); + const pathname = new URL(request.url()).pathname; + if (request.method() === 'POST' && pathname.endsWith('/threads/search')) { + await route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }); + return; + } + if (request.method() === 'POST' && pathname.endsWith('/ingest')) { + await route.fulfill({ status: 204, body: '' }); + return; + } + throw new Error(`Unexpected backend request during compatibility smoke: ${request.method()} ${pathname}`); +}); +``` + +The structural unit assertion above prevents a later refactor from removing the backend-free stubs while retaining the “no console errors” assertion. Also assert that the spawn arguments contain `--configuration`, `production`. The browser execution remains the behavioral proof that the stub response shapes are accepted by the SDK. + +Guard `main()` with `process.argv[1] === fileURLToPath(import.meta.url)` so tests can import the module without starting a server. + +- [ ] **Step 6: Add `--runtime` to the smoke CLI** + +Parse a boolean `--runtime` option. Require installation when runtime is requested, invoke: + +```js +await runChild(process.execPath, [join(SCRIPT_DIR, 'runtime-smoke.mjs'), '--target', target], { + cwd: SCRIPT_DIR, +}); +``` + +Run it after the production build and document it in the README. Keep `--runtime` opt-in locally; CI will always pass it. + +- [ ] **Step 7: Run the focused tests** + +Run: + +```bash +node --test examples/chat/smoke/runtime-smoke.spec.mjs +``` + +Expected: tests PASS. Full browser execution waits until Task 5, when peer ranges permit strict Angular 22 installation. + +- [ ] **Step 8: Commit runtime compatibility coverage** + +```bash +git add examples/chat/smoke +git commit -m "test(smoke): add browser compatibility probes" +``` + +## Task 4: Make A2UI catalog change detection version-independent + +**Files:** + +- Create: `libs/chat/src/lib/a2ui/catalog/change-detection.spec.ts` +- Modify: + - `libs/chat/src/lib/a2ui/catalog/audio-player.component.ts` + - `libs/chat/src/lib/a2ui/catalog/card.component.ts` + - `libs/chat/src/lib/a2ui/catalog/column.component.ts` + - `libs/chat/src/lib/a2ui/catalog/divider.component.ts` + - `libs/chat/src/lib/a2ui/catalog/icon.component.ts` + - `libs/chat/src/lib/a2ui/catalog/image.component.ts` + - `libs/chat/src/lib/a2ui/catalog/list.component.ts` + - `libs/chat/src/lib/a2ui/catalog/modal.component.ts` + - `libs/chat/src/lib/a2ui/catalog/row.component.ts` + - `libs/chat/src/lib/a2ui/catalog/tabs.component.ts` + - `libs/chat/src/lib/a2ui/catalog/text.component.ts` + - `libs/chat/src/lib/a2ui/catalog/video.component.ts` + +- [ ] **Step 1: Write the failing explicit-metadata test** + +Create a Vitest table containing the 12 filenames and component classes. For each row: + +```ts +const source = readFileSync(new URL(`./${file}`, import.meta.url), 'utf8'); +expect(source).toContain('changeDetection: ChangeDetectionStrategy.Default'); +expect((component as unknown as { ɵcmp: { onPush: boolean } }).ɵcmp.onPush).toBe(false); +``` + +Import each component directly from its file. Do not use `a2uiBasicCatalog()` because the test must name the source file that omitted metadata. + +- [ ] **Step 2: Run the focused spec and confirm all 12 rows fail on the source assertion** + +Run: + +```bash +npx nx test chat -- --run libs/chat/src/lib/a2ui/catalog/change-detection.spec.ts +``` + +Expected: 12 failures reporting missing explicit `ChangeDetectionStrategy.Default`. + +- [ ] **Step 3: Add explicit Default metadata** + +In each listed component: + +1. Add `ChangeDetectionStrategy` to the `@angular/core` import. +2. Add `changeDetection: ChangeDetectionStrategy.Default,` immediately after `standalone: true` or `selector` according to local decorator ordering. + +Do not convert these components to OnPush in this support change. The purpose is to preserve their Angular 20/21 behavior when linked by Angular 22. + +- [ ] **Step 4: Run the focused and full chat tests** + +Run: + +```bash +npx nx test chat -- --run libs/chat/src/lib/a2ui/catalog/change-detection.spec.ts +npx nx test chat +``` + +Expected: focused test reports 12 passing rows; full chat suite PASS. + +- [ ] **Step 5: Commit explicit behavior** + +```bash +git add libs/chat/src/lib/a2ui/catalog +git commit -m "fix(chat): preserve A2UI change detection across Angular versions" +``` + +## Task 5: Widen peer ranges and prove source/package metadata agreement + +**Files:** + +- Create: `scripts/verify-angular-support.mjs` +- Create: `scripts/verify-angular-support.spec.mjs` +- Modify: `libs/chat/package.json` +- Modify: `libs/langgraph/package.json` +- Modify: `libs/ag-ui/package.json` +- Modify: `libs/render/package.json` +- Modify: `libs/telemetry/package.json` +- Modify: `libs/cockpit-telemetry/package.json` +- Modify: `libs/example-layouts/package.json` +- Modify: `package-lock.json` + +- [ ] **Step 1: Write a failing real-repository peer-range test** + +Create a Node test that imports `ANGULAR_PEER_RANGE` and a verifier function, then verifies the actual seven manifests. It must fail before the manifests are changed. + +Also add isolated fixture cases proving the verifier reports: + +- one missing Angular 22 peer; +- one unexpected Angular 23 peer; +- an optional Angular peer (`telemetry`) is still checked; +- a package with multiple Angular peers reports the exact field. + +- [ ] **Step 2: Run the test and verify the missing-module failure** + +Run: + +```bash +node --test scripts/verify-angular-support.spec.mjs +``` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND` because the verifier does not exist yet. + +- [ ] **Step 3: Implement the peer verifier** + +`verify-angular-support.mjs` must export: + +```js +export const ANGULAR_MANIFESTS = [ + 'libs/chat/package.json', + 'libs/langgraph/package.json', + 'libs/ag-ui/package.json', + 'libs/render/package.json', + 'libs/telemetry/package.json', + 'libs/cockpit-telemetry/package.json', + 'libs/example-layouts/package.json', +]; + +export async function verifyPeerRanges({ root = process.cwd() } = {}) { /* ... */ } +``` + +For each manifest, inspect only `peerDependencies` keys beginning with `@angular/`. Require at least one Angular peer per listed manifest and require every value to equal `ANGULAR_PEER_RANGE`. Aggregate all mismatches into one thrown error so a single run reports every stale field. + +When run as the main module, execute all currently implemented checks and print: + +```text +Angular support metadata verified: 20, 21, 22 +``` + +- [ ] **Step 4: Rerun the test and prove it detects the stale peers** + +Run: + +```bash +node --test scripts/verify-angular-support.spec.mjs +``` + +Expected: FAIL for the intended reason and name every current Angular peer field whose value is still `^20.0.0 || ^21.0.0`. Do not update manifests until this red assertion has been observed. + +- [ ] **Step 5: Update all Angular peer ranges** + +Replace every Angular peer in the seven manifests with: + +```text +^20.0.0 || ^21.0.0 || ^22.0.0 +``` + +Preserve `peerDependenciesMeta`, including telemetry's optional Angular peer. + +- [ ] **Step 6: Refresh only lockfile metadata** + +Run: + +```bash +npm install --package-lock-only --ignore-scripts +``` + +Expected: exit 0. Inspect `git diff -- package-lock.json` and confirm changes are limited to workspace package peer metadata/resolution effects caused by these manifest edits. + +- [ ] **Step 7: Run peer verification and production package builds** + +Run: + +```bash +node --test scripts/verify-angular-support.spec.mjs +node scripts/verify-angular-support.mjs +npx nx run-many -t build --projects=chat,langgraph,ag-ui,render,a2ui,licensing,telemetry --configuration=production +node scripts/verify-release-versions.mjs +``` + +Expected: all commands PASS. Inspect `dist/libs/{chat,langgraph,ag-ui,render,telemetry}/package.json` and confirm emitted public peers include Angular 22. + +- [ ] **Step 8: Run all three strict local consumer lanes under supported Node** + +First verify: + +```bash +node --version +``` + +Expected: `v22.22.3` or a newer version allowed by Angular 22. If the current shell is older, switch the shell's Node runtime before continuing; do not bypass engine checks. + +Then run each lane with a distinct target: + +```bash +node examples/chat/smoke/cli.mjs --non-interactive --fresh --target /tmp/threadplane-angular-20 --local-dist-root dist/libs --angular-major 20 --install --build --runtime +node examples/chat/smoke/cli.mjs --non-interactive --fresh --target /tmp/threadplane-angular-21 --local-dist-root dist/libs --angular-major 21 --install --build --runtime +node examples/chat/smoke/cli.mjs --non-interactive --fresh --target /tmp/threadplane-angular-22 --local-dist-root dist/libs --angular-major 22 --install --build --runtime +``` + +Expected for every lane: strict `npm install`, production `ng build`, and five-package browser markers PASS without `--legacy-peer-deps` or `--force`. + +- [ ] **Step 9: Commit peer support** + +```bash +git add libs/chat/package.json libs/langgraph/package.json libs/ag-ui/package.json libs/render/package.json libs/telemetry/package.json libs/cockpit-telemetry/package.json libs/example-layouts/package.json package-lock.json scripts/verify-angular-support.mjs scripts/verify-angular-support.spec.mjs +git commit -m "feat: add Angular 22 package peers" +``` + +## Task 6: Make pricing support data structured and drift-checked + +**Files:** + +- Create: `apps/website/src/components/pricing/angular-support.mjs` +- Modify: `apps/website/src/components/pricing/CompatibilityMatrix.tsx` +- Modify: `apps/website/src/components/pricing/CompatibilityMatrix.spec.tsx` +- Modify: `apps/website/src/components/pricing/PricingDetails.tsx` +- Modify: `scripts/verify-angular-support.mjs` +- Modify: `scripts/verify-angular-support.spec.mjs` + +- [ ] **Step 1: Update the component test first** + +Change the conservative-content test to require: + +- `Angular 20, 21, 22` in the Supported row; +- the Planned row to contain `—` rather than Angular 22; +- no text matching `Planned.*Angular 22`. + +Add a test that imports the structured data and asserts its supported majors equal `[20, 21, 22]`. + +- [ ] **Step 2: Run the website spec and verify the missing-module failure** + +Run: + +```bash +npx nx test website -- --run apps/website/src/components/pricing/CompatibilityMatrix.spec.tsx +``` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND` because `angular-support.mjs` does not exist yet. + +- [ ] **Step 3: Add structured pricing data that preserves the current stale status** + +Create `angular-support.mjs`: + +```js +// SPDX-License-Identifier: MIT + +export const WEBSITE_SUPPORTED_ANGULAR_MAJORS = Object.freeze([20, 21]); + +export const ANGULAR_COMPATIBILITY_ROWS = Object.freeze([ + { label: 'Supported', versions: 'Angular 20, 21', tone: 'success' }, + { label: 'Experimental', versions: '—', tone: 'warn' }, + { label: 'Planned', versions: 'Angular 22', tone: 'info' }, + { label: 'Unsupported', versions: 'Angular ≤19', tone: 'muted' }, +]); +``` + +Have `CompatibilityMatrix.tsx` import and render `ANGULAR_COMPATIBILITY_ROWS`. Retain a local TypeScript row type and use `satisfies`/a narrow cast if the `.mjs` inference widens `tone` to `string`. + +- [ ] **Step 4: Rerun the website spec and prove it detects the stale status** + +Run: + +```bash +npx nx test website -- --run apps/website/src/components/pricing/CompatibilityMatrix.spec.tsx +``` + +Expected: FAIL for the intended reason: supported majors are `[20, 21]`, the Supported row omits 22, and the Planned row still contains Angular 22. + +- [ ] **Step 5: Mark Angular 22 supported in structured data** + +Change the module to the final state: + +```js +export const WEBSITE_SUPPORTED_ANGULAR_MAJORS = Object.freeze([20, 21, 22]); + +export const ANGULAR_COMPATIBILITY_ROWS = Object.freeze([ + { label: 'Supported', versions: 'Angular 20, 21, 22', tone: 'success' }, + { label: 'Experimental', versions: '—', tone: 'warn' }, + { label: 'Planned', versions: '—', tone: 'info' }, + { label: 'Unsupported', versions: 'Angular ≤19', tone: 'muted' }, +]); +``` + +Change `PricingDetails.tsx` to `Angular 20, 21, and 22 support`. + +- [ ] **Step 6: Extend the drift verifier** + +Import `WEBSITE_SUPPORTED_ANGULAR_MAJORS` from the `.mjs` module. Add `verifyWebsiteMajors()` that compares it with `SUPPORTED_ANGULAR_MAJORS` and checks that the Planned row does not contain any supported major. Invoke it from the CLI entrypoint. + +Add failing fixture tests for: + +- website missing Angular 22; +- website advertising Angular 23; +- Angular 22 appearing under Planned. + +- [ ] **Step 7: Run focused website and verifier tests** + +Run: + +```bash +npx nx test website -- --run apps/website/src/components/pricing/CompatibilityMatrix.spec.tsx +node --test scripts/verify-angular-support.spec.mjs +node scripts/verify-angular-support.mjs +``` + +Expected: all PASS. + +- [ ] **Step 8: Commit structured support data** + +```bash +git add apps/website/src/components/pricing scripts/verify-angular-support.mjs scripts/verify-angular-support.spec.mjs +git commit -m "feat(website): mark Angular 22 supported" +``` + +## Task 7: Add the artifact-based CI compatibility matrix + +**Files:** + +- Modify: `libs/chat/project.json` +- Modify: `libs/langgraph/project.json` +- Modify: `libs/ag-ui/project.json` +- Modify: `libs/render/project.json` +- Modify: `libs/a2ui/project.json` +- Modify: `libs/licensing/project.json` +- Modify: `libs/telemetry/project.json` +- Modify: `examples/chat/angular/project.json` +- Modify: `examples/chat/smoke/project.json` +- Modify: `scripts/ci-scope.mjs` +- Modify: `scripts/ci-scope.spec.mjs` +- Modify: `scripts/ci-workflow.spec.mjs` +- Modify: `.github/workflows/ci.yml` + +- [ ] **Step 1: Write failing scope-classifier tests** + +Add `angular_compatibility` to the expected `SCOPE_KEYS` list and tests proving: + +- a publishable package tagged `scope:angular-compatibility` sets both its existing scopes and `angular_compatibility`; +- `examples-chat-angular` and `examples-chat-smoke` can set `angular_compatibility` without forcing unrelated scopes; +- unrelated website-only and PostHog changes leave it false; +- global CI files still return full scope including the new key. + +Add direct changed-file tests proving `angular_compatibility` becomes true even with an empty affected-project list for: + +- `scripts/verify-angular-support.mjs` and its spec; +- each of the seven public/internal Angular-facing manifests, including `libs/cockpit-telemetry/package.json` and `libs/example-layouts/package.json`; +- `apps/website/src/components/pricing/angular-support.mjs`, `CompatibilityMatrix.tsx`, and `PricingDetails.tsx`; +- the root/package README files and four active installation pages from Task 8; +- any file below `examples/chat/smoke/` or `examples/chat/angular/src/app/`. + +- [ ] **Step 2: Write failing workflow-shape tests** + +Extend `ci-workflow.spec.mjs` with helpers that slice the `library`, `angular-compatibility`, and `required-pr-checks` jobs. Assert: + +1. `library` uploads `dist/libs` as `threadplane-library-dist`. +2. `angular-compatibility` needs both `ci-scope` and `library`. +3. The matrix is exactly `[20, 21, 22]` and uses Node `22.22.3`. +4. The job downloads `threadplane-library-dist`, installs Chromium, and invokes the smoke CLI with `--install --build --runtime`. +5. `required-pr-checks` includes the job and calls `require_scoped "angular_compatibility"`. +6. The library job condition includes the new scope, guaranteeing the artifact producer runs for smoke-only compatibility changes. +7. A failure-only artifact upload retains `runtime-smoke.png` and `runtime-smoke-trace.zip` for each matrix lane. + +- [ ] **Step 3: Run the CI tests and observe failures** + +Run: + +```bash +node --test scripts/ci-scope.spec.mjs scripts/ci-workflow.spec.mjs +``` + +Expected: FAIL on the absent scope key, artifact upload, matrix job, and required-check wiring. + +- [ ] **Step 4: Tag the affected Nx projects** + +Add `scope:angular-compatibility` to the tags of: + +- the seven artifact-producing library projects (`chat`, `langgraph`, `ag-ui`, `render`, `a2ui`, `licensing`, `telemetry`); +- `examples-chat-angular`, because its `src/app` is copied into the consumer; +- `examples-chat-smoke`, because it owns the generator and template. + +Do not add the tag to every example or website project. + +- [ ] **Step 5: Add the new CI scope output** + +Add `angular_compatibility` to `SCOPE_KEYS` after `library`. Export and implement `isAngularCompatibilityChange(changedFiles)` using an exact file set plus these two prefixes: + +```js +const ANGULAR_COMPATIBILITY_PREFIXES = [ + 'examples/chat/smoke/', + 'examples/chat/angular/src/app/', +]; +``` + +The exact file set contains the verifier/spec, all seven manifests from Task 5, the three pricing support files, all README files and installation pages from Task 8, and the two internal manifests called out in Step 1. After Nx affected tags are mapped, set `scope.angular_compatibility = true` when this function matches. This direct path rule ensures metadata-only changes cannot skip the verifier even when Nx has no affected project. + +Expose the new scope from the `ci-scope` job as: + +```yaml +angular_compatibility: ${{ steps.scope.outputs.angular_compatibility }} +``` + +Update the test's documented key count from 10 to 11. + +- [ ] **Step 6: Upload one production artifact from the library job** + +Change the library condition to run when either `library` or `angular_compatibility` is selected. After all production builds and verification steps pass, add: + +```yaml +- name: Upload production library artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: threadplane-library-dist + path: dist/libs + if-no-files-found: error + retention-days: 1 +``` + +Also run these focused metadata tests in the library job before upload: + +```yaml +- run: node --test examples/chat/smoke/*.spec.mjs scripts/verify-angular-support.spec.mjs +- run: node scripts/verify-angular-support.mjs +``` + +- [ ] **Step 7: Add the three-major matrix job** + +Add a job immediately after `library`: + +```yaml +angular-compatibility: + name: "Angular ${{ matrix.angular }} — packaged consumer" + needs: [ci-scope, library] + if: github.event_name == 'push' || needs.ci-scope.outputs.angular_compatibility == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + angular: [20, 21, 22] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: 22.22.3 + cache: npm + - run: npm ci + - name: Download production library artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: threadplane-library-dist + path: dist/libs + - name: Install Chromium + run: npx playwright install --with-deps chromium + - name: Generate, install, build, and run consumer + run: >- + node examples/chat/smoke/cli.mjs + --non-interactive --fresh + --target "${{ runner.temp }}/threadplane-angular-${{ matrix.angular }}" + --local-dist-root dist/libs + --angular-major "${{ matrix.angular }}" + --install --build --runtime + - name: Upload compatibility diagnostics on failure + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: angular-${{ matrix.angular }}-compatibility-diagnostics + path: | + ${{ runner.temp }}/threadplane-angular-${{ matrix.angular }}/runtime-smoke.png + ${{ runner.temp }}/threadplane-angular-${{ matrix.angular }}/runtime-smoke-trace.zip + if-no-files-found: warn + retention-days: 7 +``` + +Do not rebuild libraries inside matrix lanes. The downloaded artifact is the only `dist/libs` source. + +- [ ] **Step 8: Wire the stable required check** + +Add `angular-compatibility` to `required-pr-checks.needs`. Add result/scope env variables and: + +```bash +require_scoped \ + "angular_compatibility" \ + "Angular compatibility matrix" \ + "$RESULT_ANGULAR_COMPATIBILITY" \ + "$SCOPE_ANGULAR_COMPATIBILITY" +``` + +Keep the existing behavior that treats an unexpected failure/cancellation as a failure even when a scope was false. + +- [ ] **Step 9: Run workflow and scope verification** + +Run: + +```bash +node --test scripts/ci-scope.spec.mjs scripts/ci-workflow.spec.mjs +node --test examples/chat/smoke/*.spec.mjs scripts/verify-angular-support.spec.mjs +``` + +Expected: all PASS. + +- [ ] **Step 10: Commit CI coverage** + +```bash +git add .github/workflows/ci.yml scripts/ci-scope.mjs scripts/ci-scope.spec.mjs scripts/ci-workflow.spec.mjs libs/chat/project.json libs/langgraph/project.json libs/ag-ui/project.json libs/render/project.json libs/a2ui/project.json libs/licensing/project.json libs/telemetry/project.json examples/chat/angular/project.json examples/chat/smoke/project.json +git commit -m "ci: test packaged libraries across Angular majors" +``` + +## Task 8: Update active compatibility documentation + +**Files:** + +- Modify: `README.md` +- Modify: `libs/chat/README.md` +- Modify: `libs/langgraph/README.md` +- Modify: `libs/ag-ui/README.md` +- Modify: `libs/render/README.md` +- Modify: `libs/telemetry/README.md` +- Modify: `apps/website/content/docs/chat/getting-started/installation.mdx` +- Modify: `apps/website/content/docs/langgraph/getting-started/installation.mdx` +- Modify: `apps/website/content/docs/ag-ui/getting-started/installation.mdx` +- Modify: `apps/website/content/docs/render/getting-started/installation.mdx` + +- [ ] **Step 1: Add a failing documentation assertion to the drift verifier tests** + +Extend `verify-angular-support.spec.mjs` so the real-repository check requires: + +- the explicit peer-range blocks in the root, chat, LangGraph, AG-UI, render, and telemetry READMEs to include `^22.0.0`; +- active installation pages to contain `Angular 20, 21, and 22`; +- no active installation page to retain the exact stale range `^20.0.0 || ^21.0.0`. + +Do not scan historical blog content. + +- [ ] **Step 2: Run the verifier test and observe stale documentation failures** + +Run: + +```bash +node --test scripts/verify-angular-support.spec.mjs +``` + +Expected: FAIL and list the active documents that still omit Angular 22. + +- [ ] **Step 3: Update README badges and peer blocks** + +- Change package badges from `Angular 20+ | 21` to `Angular 20 | 21 | 22` with URL-encoded badge text. +- Keep broad prose such as “Angular 20+” where it remains accurate. +- Add `|| ^22.0.0` to every explicit Angular peer block. +- Do not change historical comparisons or unrelated positioning copy. + +- [ ] **Step 4: Update the four installation pages** + +Each page must state: + +```text +Supported Angular majors: 20, 21, and 22. +Angular 22 requires Node.js 22.22.3 or a supported newer Node line. +``` + +Update the chat page's explicit peer-range list to include Angular 22. Preserve other dependency guidance. + +- [ ] **Step 5: Confirm generated-context scope** + +Run: + +```bash +rg -n "Angular 20|Angular 21|Angular 22|\^20\.0\.0" apps/website/public +``` + +Expected: no generated public-context file requiring regeneration. If the command finds a generated file whose source was changed, stop and identify the smallest documented generator before running it. + +- [ ] **Step 6: Run docs, website, and drift verification** + +Run: + +```bash +node --test scripts/verify-angular-support.spec.mjs +node scripts/verify-angular-support.mjs +npx nx lint website +npx nx test website +npx nx build website +``` + +Expected: all PASS. No API or narrative docs generator should be needed because no API/JSDoc source changes in this task. + +- [ ] **Step 7: Commit documentation** + +```bash +git add README.md libs/chat/README.md libs/langgraph/README.md libs/ag-ui/README.md libs/render/README.md libs/telemetry/README.md apps/website/content/docs/chat/getting-started/installation.mdx apps/website/content/docs/langgraph/getting-started/installation.mdx apps/website/content/docs/ag-ui/getting-started/installation.mdx apps/website/content/docs/render/getting-started/installation.mdx scripts/verify-angular-support.mjs scripts/verify-angular-support.spec.mjs +git commit -m "docs: document Angular 22 support" +``` + +## Task 9: Run the full release gate and review the final diff + +**Files:** + +- Verify all files changed by Tasks 1–8; no new implementation files are expected. + +- [ ] **Step 1: Confirm supported Node and clean dependency state** + +Run: + +```bash +node --version +npm ci +``` + +Expected: Node `v22.22.3` or a supported newer line; `npm ci` exits 0. + +- [ ] **Step 2: Run all new focused tests** + +Run: + +```bash +node --test examples/chat/smoke/*.spec.mjs scripts/verify-angular-support.spec.mjs scripts/ci-scope.spec.mjs scripts/ci-workflow.spec.mjs +node scripts/verify-angular-support.mjs +``` + +Expected: all tests and drift checks PASS. + +- [ ] **Step 3: Run targeted lint and unit tests** + +Run: + +```bash +npx nx run-many -t lint --projects=chat,langgraph,ag-ui,render,telemetry +npx nx run-many -t test --projects=chat,langgraph,ag-ui,render,telemetry +``` + +Expected: all five project lint and test targets PASS. + +- [ ] **Step 4: Build the exact release artifact set** + +Run: + +```bash +npx nx run-many -t build --projects=chat,langgraph,ag-ui,render,a2ui,licensing,telemetry --configuration=production +node scripts/verify-release-versions.mjs +``` + +Expected: all production builds and release-version checks PASS. + +- [ ] **Step 5: Repeat the strict three-major consumer matrix** + +Run the three Task 5 smoke commands again using fresh targets and `--runtime`. Do not reuse existing consumer `node_modules` directories. + +Expected: Angular 20, 21, and 22 each pass strict install, production build, canonical welcome render, AG-UI injection, and all five package markers. + +- [ ] **Step 6: Verify website changes** + +Run: + +```bash +npx nx lint website +npx nx test website +npx nx build website +``` + +Expected: PASS. + +- [ ] **Step 7: Inspect package tarballs and final diff** + +Run: + +```bash +npm pack dist/libs/chat --dry-run +npm pack dist/libs/langgraph --dry-run +npm pack dist/libs/ag-ui --dry-run +npm pack dist/libs/render --dry-run +npm pack dist/libs/telemetry --dry-run +git diff --check 35449fdf..HEAD +git diff --stat 35449fdf..HEAD +git status --short +``` + +Expected: + +- tarball previews contain the expected package metadata and build outputs; +- diff check reports no whitespace errors; +- only the committed implementation plan plus Angular 22 support files are present after `35449fdf`; +- worktree is clean after all logical commits. + +- [ ] **Step 8: Record any verification limits** + +If a local environment cannot run Node 22.22.3+, Chromium, or one of the consumer lanes, do not claim the release gate passed. Report the exact skipped command and rely on the corresponding CI matrix result before merging. + +## Out of scope follow-up + +Create a separate spec and plan before upgrading the root workspace to Angular 22. That follow-up must cover Nx 23.1+, TypeScript 6 configuration changes, Node enforcement across all `npm ci` jobs, Angular migrations, and Angular/TypeScript-coupled tooling upgrades. None of those upgrades belong in this implementation plan. From 1981e7f703a5c689d9f155b356957087c49e3437 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 30 Aug 2026 19:26:42 -0700 Subject: [PATCH 03/13] test(smoke): define Angular compatibility lanes --- examples/chat/smoke/angular-versions.mjs | 46 ++++++++ examples/chat/smoke/angular-versions.spec.mjs | 107 ++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 examples/chat/smoke/angular-versions.mjs create mode 100644 examples/chat/smoke/angular-versions.spec.mjs diff --git a/examples/chat/smoke/angular-versions.mjs b/examples/chat/smoke/angular-versions.mjs new file mode 100644 index 000000000..9b9de60cf --- /dev/null +++ b/examples/chat/smoke/angular-versions.mjs @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT + +export const ANGULAR_PEER_RANGE = '^20.0.0 || ^21.0.0 || ^22.0.0'; +export const SUPPORTED_ANGULAR_MAJORS = Object.freeze([20, 21, 22]); + +function lane(major, framework, cli, cdk, typescript) { + return Object.freeze({ + major, + node: '22.22.3', + dependencies: Object.freeze({ + '@angular/common': framework, + '@angular/compiler': framework, + '@angular/core': framework, + '@angular/forms': framework, + '@angular/platform-browser': framework, + '@angular/router': framework, + '@angular/cdk': cdk, + '@angular/google-maps': cdk, + }), + devDependencies: Object.freeze({ + '@angular/build': cli, + '@angular/cli': cli, + '@angular/compiler-cli': framework, + typescript, + }), + }); +} + +export const ANGULAR_LANES = Object.freeze({ + 20: lane(20, '20.3.30', '20.3.35', '20.2.14', '5.9.3'), + 21: lane(21, '21.2.22', '21.2.22', '21.2.14', '5.9.3'), + 22: lane(22, '22.1.4', '22.1.6', '22.1.4', '6.0.3'), +}); + +export function getAngularLane(value) { + const major = Number(value); + const selected = ANGULAR_LANES[major]; + if (!selected) { + throw new Error( + `Unsupported Angular major ${value}. Expected one of: ${SUPPORTED_ANGULAR_MAJORS.join( + ', ' + )}` + ); + } + return selected; +} diff --git a/examples/chat/smoke/angular-versions.spec.mjs b/examples/chat/smoke/angular-versions.spec.mjs new file mode 100644 index 000000000..506d357eb --- /dev/null +++ b/examples/chat/smoke/angular-versions.spec.mjs @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: MIT + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + ANGULAR_LANES, + ANGULAR_PEER_RANGE, + SUPPORTED_ANGULAR_MAJORS, + getAngularLane, +} from './angular-versions.mjs'; + +test('defines the supported Angular majors and peer range', () => { + assert.deepEqual(SUPPORTED_ANGULAR_MAJORS, [20, 21, 22]); + assert.equal(ANGULAR_PEER_RANGE, '^20.0.0 || ^21.0.0 || ^22.0.0'); +}); + +test('defines the exact dependency and toolchain pins for every lane', () => { + assert.deepEqual(ANGULAR_LANES, { + 20: { + major: 20, + node: '22.22.3', + dependencies: { + '@angular/common': '20.3.30', + '@angular/compiler': '20.3.30', + '@angular/core': '20.3.30', + '@angular/forms': '20.3.30', + '@angular/platform-browser': '20.3.30', + '@angular/router': '20.3.30', + '@angular/cdk': '20.2.14', + '@angular/google-maps': '20.2.14', + }, + devDependencies: { + '@angular/build': '20.3.35', + '@angular/cli': '20.3.35', + '@angular/compiler-cli': '20.3.30', + typescript: '5.9.3', + }, + }, + 21: { + major: 21, + node: '22.22.3', + dependencies: { + '@angular/common': '21.2.22', + '@angular/compiler': '21.2.22', + '@angular/core': '21.2.22', + '@angular/forms': '21.2.22', + '@angular/platform-browser': '21.2.22', + '@angular/router': '21.2.22', + '@angular/cdk': '21.2.14', + '@angular/google-maps': '21.2.14', + }, + devDependencies: { + '@angular/build': '21.2.22', + '@angular/cli': '21.2.22', + '@angular/compiler-cli': '21.2.22', + typescript: '5.9.3', + }, + }, + 22: { + major: 22, + node: '22.22.3', + dependencies: { + '@angular/common': '22.1.4', + '@angular/compiler': '22.1.4', + '@angular/core': '22.1.4', + '@angular/forms': '22.1.4', + '@angular/platform-browser': '22.1.4', + '@angular/router': '22.1.4', + '@angular/cdk': '22.1.4', + '@angular/google-maps': '22.1.4', + }, + devDependencies: { + '@angular/build': '22.1.6', + '@angular/cli': '22.1.6', + '@angular/compiler-cli': '22.1.4', + typescript: '6.0.3', + }, + }, + }); +}); + +test('freezes the registry and every lane configuration object', () => { + assert.ok(Object.isFrozen(SUPPORTED_ANGULAR_MAJORS)); + assert.ok(Object.isFrozen(ANGULAR_LANES)); + + for (const major of SUPPORTED_ANGULAR_MAJORS) { + const lane = ANGULAR_LANES[major]; + assert.ok(Object.isFrozen(lane)); + assert.ok(Object.isFrozen(lane.dependencies)); + assert.ok(Object.isFrozen(lane.devDependencies)); + } +}); + +test('selects each supported lane from numeric and string majors', () => { + for (const major of SUPPORTED_ANGULAR_MAJORS) { + assert.strictEqual(getAngularLane(major), ANGULAR_LANES[major]); + assert.strictEqual(getAngularLane(String(major)), ANGULAR_LANES[major]); + } +}); + +test('rejects unsupported Angular majors', () => { + assert.throws( + () => getAngularLane('23'), + new Error('Unsupported Angular major 23. Expected one of: 20, 21, 22') + ); +}); From cd9f7e22a71e1f968e7618f9b0012a483de004bd Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 30 Aug 2026 19:37:01 -0700 Subject: [PATCH 04/13] feat(smoke): generate strict versioned Angular consumers --- examples/chat/smoke/README.md | 11 +- examples/chat/smoke/cli.mjs | 186 +++++++++++++----- examples/chat/smoke/consumer-package.mjs | 23 +++ examples/chat/smoke/consumer-package.spec.mjs | 132 +++++++++++++ examples/chat/smoke/project.json | 6 +- examples/chat/smoke/template/package.json | 13 +- 6 files changed, 314 insertions(+), 57 deletions(-) create mode 100644 examples/chat/smoke/consumer-package.mjs create mode 100644 examples/chat/smoke/consumer-package.spec.mjs diff --git a/examples/chat/smoke/README.md b/examples/chat/smoke/README.md index 5b79ce872..e8b1c4dfa 100644 --- a/examples/chat/smoke/README.md +++ b/examples/chat/smoke/README.md @@ -20,9 +20,10 @@ node examples/chat/smoke/cli.mjs 3. Resolves the latest `@threadplane/chat` version from npm; prompts to override. 4. Copies `template/` (Angular CLI scaffold sans `src/app/`) into the target. 5. Copies `examples/chat/angular/src/app/` into the target's `src/app/`. -6. Pins `@threadplane/*` deps to the resolved version, runs `npm install`. -7. Optionally runs `npm start`. -8. Drops `CHECKLIST.md` and `SMOKE_RUN.md` (capture metadata) in the target. +6. Selects an exact Angular compatibility lane with `--angular-major 20|21|22` (default: `21`) and rewrites all Angular, Angular CLI, and TypeScript pins from the registry. +7. Pins `@threadplane/*` deps to the resolved version, then runs `npm install` with strict peer resolution (`legacy-peer-deps=false`). +8. Optionally runs `npm start`. +9. Drops `CHECKLIST.md` and `SMOKE_RUN.md` (capture metadata, including the selected Angular lane) in the target. ## What's in `template/` @@ -34,7 +35,9 @@ reviewable surface is just the scaffold; the app body never drifts. The placeholder `"@threadplane/chat": "*"` in `template/package.json` is a valid semver range ("any version"); the CLI replaces it with the -explicit `^X.Y.Z` it resolved before writing. +explicit `^X.Y.Z` it resolved before writing. The template supplies a +direct dependency closure for the default Angular 21 scaffold, while the +generator owns the exact Angular version pins for every generated consumer. ## Don't run `npm install` directly in `template/` diff --git a/examples/chat/smoke/cli.mjs b/examples/chat/smoke/cli.mjs index 93ce3a24f..fb56a0b0e 100755 --- a/examples/chat/smoke/cli.mjs +++ b/examples/chat/smoke/cli.mjs @@ -12,19 +12,26 @@ import { createInterface } from 'node:readline/promises'; import { stdin as input, stdout as output, exit } from 'node:process'; -import { - cp, mkdtemp, rm, writeFile, readFile, -} from 'node:fs/promises'; -import { existsSync } from 'node:fs'; +import { cp, mkdtemp, rm, writeFile, readFile } from 'node:fs/promises'; +import { existsSync, realpathSync } from 'node:fs'; import { join, resolve, dirname, relative } from 'node:path'; import { spawn, execFile, execSync } from 'node:child_process'; import { homedir, tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; +import { getAngularLane } from './angular-versions.mjs'; +import { applyAngularLane, strictNpmEnv } from './consumer-package.mjs'; + const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); const TEMPLATE_DIR = join(SCRIPT_DIR, 'template'); const DEMO_APP_DIR = resolve(SCRIPT_DIR, '..', 'angular', 'src', 'app'); -const DEMO_ENVIRONMENTS_DIR = resolve(SCRIPT_DIR, '..', 'angular', 'src', 'environments'); +const DEMO_ENVIRONMENTS_DIR = resolve( + SCRIPT_DIR, + '..', + 'angular', + 'src', + 'environments' +); const CHECKLIST = join(SCRIPT_DIR, 'CHECKLIST.md'); const DEFAULT_TARGET = join(homedir(), 'tmp', 'threadplane'); @@ -41,6 +48,7 @@ const THREADPLANE_PACKAGES = [ function parseArgs(argv) { const options = { action: undefined, + angularMajor: '21', build: false, install: undefined, nonInteractive: false, @@ -59,9 +67,12 @@ function parseArgs(argv) { return value; }; - if (arg === '--non-interactive' || arg === '--yes') options.nonInteractive = true; + if (arg === '--non-interactive' || arg === '--yes') + options.nonInteractive = true; else if (arg === '--target') options.target = readValue(); - else if (arg === '--version') options.version = readValue().replace(/^[v^~]+/, ''); + else if (arg === '--version') + options.version = readValue().replace(/^[v^~]+/, ''); + else if (arg === '--angular-major') options.angularMajor = readValue(); else if (arg === '--fresh') options.action = 'fresh'; else if (arg === '--update') options.action = 'update'; else if (arg === '--install') options.install = true; @@ -70,11 +81,13 @@ function parseArgs(argv) { else if (arg === '--no-start') options.start = false; else if (arg === '--build') options.build = true; else if (arg === '--local-dist-root') options.localDistRoot = readValue(); - else if (arg === '--pack-destination') options.packDestination = readValue(); + else if (arg === '--pack-destination') + options.packDestination = readValue(); else if (arg === '--package') { const spec = readValue(); const [name, value] = spec.split('='); - if (!name || !value) throw new Error('--package expects @scope/name=specifier'); + if (!name || !value) + throw new Error('--package expects @scope/name=specifier'); options.packageSpecs.set(name, value); } else { throw new Error(`Unknown argument: ${arg}`); @@ -86,26 +99,39 @@ function parseArgs(argv) { async function main() { const options = parseArgs(process.argv.slice(2)); + const angularLane = getAngularLane(options.angularMajor); const rl = options.nonInteractive ? null : createInterface({ input, output }); const ask = (q, def) => { if (!rl) return Promise.resolve(def); - return rl.question(def !== undefined ? `${q} (${def}) ` : `${q} `).then(v => v.trim() || def); + return rl + .question(def !== undefined ? `${q} (${def}) ` : `${q} `) + .then((v) => v.trim() || def); }; console.log('\n📦 Threadplane chat smoke generator\n'); - const target = resolve(await ask('Target directory:', options.target ?? DEFAULT_TARGET)); + const target = resolve( + await ask('Target directory:', options.target ?? DEFAULT_TARGET) + ); let action = options.action ?? 'fresh'; if (existsSync(target) && !options.nonInteractive && !options.action) { const choice = await ask( 'Directory exists. [r]efresh / [u]pdate in place / [c]ancel:', - 'c', + 'c' ); const c = choice.toLowerCase(); - if (c.startsWith('c')) { console.log('Cancelled.'); rl.close(); exit(0); } + if (c.startsWith('c')) { + console.log('Cancelled.'); + rl.close(); + exit(0); + } if (c.startsWith('u')) action = 'update'; else if (c.startsWith('r')) action = 'fresh'; - else { console.log('Unrecognised choice; cancelling.'); rl.close(); exit(1); } + else { + console.log('Unrecognised choice; cancelling.'); + rl.close(); + exit(1); + } } let version = options.version; @@ -120,18 +146,27 @@ async function main() { const packageSpecs = new Map(options.packageSpecs); if (options.localDistRoot) { - for (const [name, spec] of await packLocalPackages(options.localDistRoot, options.packDestination)) { + for (const [name, spec] of await packLocalPackages( + options.localDistRoot, + options.packDestination + )) { packageSpecs.set(name, spec); } } - const installAnswer = options.install === undefined - ? await ask('Run `npm install` now? [Y/n]:', 'Y') - : undefined; - const doInstall = options.install ?? !installAnswer.toLowerCase().startsWith('n'); - const doStart = options.start ?? (doInstall && !options.nonInteractive - ? !(await ask('Run `npm start` after install? [Y/n]:', 'Y')).toLowerCase().startsWith('n') - : false); + const installAnswer = + options.install === undefined + ? await ask('Run `npm install` now? [Y/n]:', 'Y') + : undefined; + const doInstall = + options.install ?? !installAnswer.toLowerCase().startsWith('n'); + const doStart = + options.start ?? + (doInstall && !options.nonInteractive + ? !(await ask('Run `npm start` after install? [Y/n]:', 'Y')) + .toLowerCase() + .startsWith('n') + : false); rl?.close(); @@ -143,19 +178,29 @@ async function main() { console.log(`→ Copying app code from ${DEMO_APP_DIR} ...`); await cp(DEMO_APP_DIR, join(target, 'src', 'app'), { recursive: true }); console.log(`→ Copying environments from ${DEMO_ENVIRONMENTS_DIR} ...`); - await cp(DEMO_ENVIRONMENTS_DIR, join(target, 'src', 'environments'), { recursive: true }); + await cp(DEMO_ENVIRONMENTS_DIR, join(target, 'src', 'environments'), { + recursive: true, + }); } else { - console.log(`\n→ Updating in place at ${target} (skipping scaffold copy) ...`); + console.log( + `\n→ Updating in place at ${target} (skipping scaffold copy) ...` + ); } - await pinPackageSpecs({ target, version, packageSpecs }); + await writeConsumerPackage({ target, version, packageSpecs, angularLane }); await cp(CHECKLIST, join(target, 'CHECKLIST.md')); - await writeFile(join(target, 'SMOKE_RUN.md'), await buildSmokeRun({ target, version, packageSpecs })); + await writeFile( + join(target, 'SMOKE_RUN.md'), + await buildSmokeRun({ target, version, packageSpecs, angularLane }) + ); console.log('→ Wrote SMOKE_RUN.md'); if (doInstall) { console.log('\n→ Running npm install ...'); - await runChild('npm', ['install'], { cwd: target }); + console.log( + `Angular lane ${angularLane.major}: framework ${angularLane.dependencies['@angular/core']}, TypeScript ${angularLane.devDependencies.typescript}, Node >=${angularLane.node}` + ); + await runChild('npm', ['install'], { cwd: target, env: strictNpmEnv() }); } if (options.build) { @@ -164,7 +209,9 @@ async function main() { } console.log(`\n✓ Smoke consumer ready at ${target}`); - console.log(' Backend: cd examples/chat/python && uv run langgraph dev --port 2024'); + console.log( + ' Backend: cd examples/chat/python && uv run langgraph dev --port 2024' + ); console.log(` App: cd ${target} && npm start`); console.log(' URL: http://localhost:4200'); console.log(` Checklist: cat ${join(target, 'CHECKLIST.md')}\n`); @@ -176,32 +223,49 @@ async function main() { } async function readLocalVersion(localDistRoot) { - const packageJson = JSON.parse(await readFile(resolve(localDistRoot, 'chat', 'package.json'), 'utf8')); + const packageJson = JSON.parse( + await readFile(resolve(localDistRoot, 'chat', 'package.json'), 'utf8') + ); return packageJson.version; } async function resolvePublishedVersion() { try { - return execSync('npm view @threadplane/chat version', { encoding: 'utf8' }).trim(); + return execSync('npm view @threadplane/chat version', { + encoding: 'utf8', + }).trim(); } catch (error) { - console.error('Could not resolve @threadplane/chat version from npm:', error.message); + console.error( + 'Could not resolve @threadplane/chat version from npm:', + error.message + ); exit(1); } } async function packLocalPackages(localDistRoot, packDestination) { const distRoot = resolve(localDistRoot); - const destination = resolve(packDestination ?? await mkdtemp(join(tmpdir(), 'threadplane-packs-'))); + const destination = resolve( + packDestination ?? (await mkdtemp(join(tmpdir(), 'threadplane-packs-'))) + ); const specs = new Map(); for (const pkg of THREADPLANE_PACKAGES) { const packageRoot = join(distRoot, pkg.dist); if (!existsSync(join(packageRoot, 'package.json'))) { - throw new Error(`Missing built package at ${packageRoot}. Run public package builds before smoke verification.`); + throw new Error( + `Missing built package at ${packageRoot}. Run public package builds before smoke verification.` + ); } - const output = await execFileText('npm', ['pack', packageRoot, '--pack-destination', destination]); + const output = await execFileText('npm', [ + 'pack', + packageRoot, + '--pack-destination', + destination, + ]); const tarball = output.trim().split(/\r?\n/).filter(Boolean).at(-1); - if (!tarball) throw new Error(`npm pack did not report a tarball for ${pkg.name}`); + if (!tarball) + throw new Error(`npm pack did not report a tarball for ${pkg.name}`); const tarballPath = join(destination, tarball); specs.set(pkg.name, `file:${tarballPath}`); } @@ -209,9 +273,15 @@ async function packLocalPackages(localDistRoot, packDestination) { return specs; } -async function pinPackageSpecs({ target, version, packageSpecs }) { +async function writeConsumerPackage({ + target, + version, + packageSpecs, + angularLane, +}) { const pkgPath = join(target, 'package.json'); - const pkg = JSON.parse(await readFile(pkgPath, 'utf8')); + const packageJson = JSON.parse(await readFile(pkgPath, 'utf8')); + const pkg = applyAngularLane(packageJson, angularLane); for (const { name } of THREADPLANE_PACKAGES) { if (!pkg.dependencies?.[name]) continue; @@ -219,27 +289,37 @@ async function pinPackageSpecs({ target, version, packageSpecs }) { } await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); - console.log(`→ Pinned ${THREADPLANE_PACKAGES.map(({ name }) => name).join(', ')}`); + console.log( + `→ Pinned ${THREADPLANE_PACKAGES.map(({ name }) => name).join(', ')}` + ); } -async function buildSmokeRun({ target, version, packageSpecs }) { +async function buildSmokeRun({ target, version, packageSpecs, angularLane }) { const lines = [ '# Smoke run capture', '', `- Timestamp: ${new Date().toISOString()}`, `- Target: ${target}`, `- Threadplane version (pinned): ^${version}`, + `- Angular major: ${angularLane.major}`, + `- Angular framework version: ${angularLane.dependencies['@angular/core']}`, + `- TypeScript version: ${angularLane.devDependencies.typescript}`, + `- Required Node version: >=${angularLane.node}`, `- Node: ${process.version}`, `- Platform: ${process.platform} ${process.arch}`, ]; try { const sha = execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim(); lines.push(`- Workspace git SHA: ${sha}`); - } catch { /* outside a repo, ignore */ } + } catch { + /* outside a repo, ignore */ + } try { const npmV = execSync('npm --version', { encoding: 'utf8' }).trim(); lines.push(`- npm: ${npmV}`); - } catch { /* ignore */ } + } catch { + /* ignore */ + } lines.push('', '## Resolved package specs', ''); for (const { name } of THREADPLANE_PACKAGES) { const spec = packageSpecs.get(name) ?? `^${version}`; @@ -248,7 +328,9 @@ async function buildSmokeRun({ target, version, packageSpecs }) { continue; } try { - const resolved = execSync(`npm view ${name}@${spec} version`, { encoding: 'utf8' }).trim(); + const resolved = execSync(`npm view ${name}@${spec} version`, { + encoding: 'utf8', + }).trim(); lines.push(`- ${name}@${resolved}`); } catch { lines.push(`- ${name}: ${spec} (resolution failed)`); @@ -274,15 +356,25 @@ function runChild(cmd, args, opts = {}) { return new Promise((resolveP, rejectP) => { const child = spawn(cmd, args, { cwd: opts.cwd, + env: opts.env ?? process.env, stdio: 'inherit', shell: process.platform === 'win32', }); - child.on('exit', (code) => (code === 0 ? resolveP() : rejectP(new Error(`${cmd} exited ${code}`)))); + child.on('exit', (code) => + code === 0 ? resolveP() : rejectP(new Error(`${cmd} exited ${code}`)) + ); child.on('error', rejectP); }); } -main().catch(err => { - console.error('\n✖ Smoke generator failed:', err.message); - exit(1); -}); +export { parseArgs }; + +if ( + process.argv[1] && + realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)) +) { + main().catch((err) => { + console.error('\n✖ Smoke generator failed:', err.message); + exit(1); + }); +} diff --git a/examples/chat/smoke/consumer-package.mjs b/examples/chat/smoke/consumer-package.mjs new file mode 100644 index 000000000..e8e8aa408 --- /dev/null +++ b/examples/chat/smoke/consumer-package.mjs @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT + +export function applyAngularLane(packageJson, selectedLane) { + return { + ...packageJson, + dependencies: { + ...packageJson.dependencies, + ...selectedLane.dependencies, + }, + devDependencies: { + ...packageJson.devDependencies, + ...selectedLane.devDependencies, + }, + }; +} + +export function strictNpmEnv(base = process.env) { + return { + ...base, + npm_config_legacy_peer_deps: 'false', + NPM_CONFIG_LEGACY_PEER_DEPS: 'false', + }; +} diff --git a/examples/chat/smoke/consumer-package.spec.mjs b/examples/chat/smoke/consumer-package.spec.mjs new file mode 100644 index 000000000..c254e9196 --- /dev/null +++ b/examples/chat/smoke/consumer-package.spec.mjs @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: MIT + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile, mkdtemp, rm, symlink } from 'node:fs/promises'; +import { spawn } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { ANGULAR_LANES, getAngularLane } from './angular-versions.mjs'; +import { applyAngularLane, strictNpmEnv } from './consumer-package.mjs'; +import { parseArgs } from './cli.mjs'; + +function runNode(args) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, args, { + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stderr = ''; + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + child.on('error', reject); + child.on('close', (code) => resolve({ code, stderr })); + }); +} + +test('applies every selected Angular lane without replacing unrelated dependencies', () => { + for (const major of [20, 21, 22]) { + const packageJson = { + dependencies: { + ...Object.fromEntries( + Object.keys(ANGULAR_LANES[major].dependencies).map((name) => [ + name, + 'old', + ]) + ), + keep: '1.0.0', + }, + devDependencies: Object.fromEntries( + Object.keys(ANGULAR_LANES[major].devDependencies).map((name) => [ + name, + 'old', + ]) + ), + }; + + const result = applyAngularLane(packageJson, ANGULAR_LANES[major]); + + assert.notStrictEqual(result, packageJson); + assert.notStrictEqual(result.dependencies, packageJson.dependencies); + assert.notStrictEqual(result.devDependencies, packageJson.devDependencies); + assert.equal(result.dependencies.keep, '1.0.0'); + for (const [name, version] of Object.entries( + ANGULAR_LANES[major].dependencies + )) { + assert.equal(result.dependencies[name], version, `${major}: ${name}`); + assert.equal(packageJson.dependencies[name], 'old', `${major}: ${name}`); + } + for (const [name, version] of Object.entries( + ANGULAR_LANES[major].devDependencies + )) { + assert.equal(result.devDependencies[name], version, `${major}: ${name}`); + assert.equal( + packageJson.devDependencies[name], + 'old', + `${major}: ${name}` + ); + } + } +}); + +test('forces strict peer resolution while preserving the base environment', () => { + assert.deepEqual( + strictNpmEnv({ EXISTING: 'yes', npm_config_legacy_peer_deps: 'true' }), + { + EXISTING: 'yes', + npm_config_legacy_peer_deps: 'false', + NPM_CONFIG_LEGACY_PEER_DEPS: 'false', + } + ); +}); + +test('parses supported and unsupported Angular majors', () => { + assert.equal(parseArgs([]).angularMajor, '21'); + assert.equal(parseArgs(['--angular-major', '22']).angularMajor, '22'); + assert.throws( + () => parseArgs(['--angular-major']), + /--angular-major requires a value/ + ); + + const options = parseArgs(['--angular-major', '23']); + assert.throws( + () => getAngularLane(options.angularMajor), + /Unsupported Angular major 23/ + ); +}); + +test('executes the CLI when it is invoked through a symlink', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'threadplane-smoke-cli-')); + const linkedCli = join(directory, 'smoke-cli.mjs'); + + try { + try { + await symlink(new URL('./cli.mjs', import.meta.url), linkedCli, 'file'); + } catch (error) { + if (['EACCES', 'EINVAL', 'ENOSYS', 'EPERM'].includes(error.code)) { + t.skip(`Symlink creation is unsupported: ${error.code}`); + return; + } + throw error; + } + + const result = await runNode([linkedCli, '--angular-major', '23']); + + assert.equal(result.code, 1); + assert.match(result.stderr, /Unsupported Angular major 23/); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test('builds the template with the production configuration', async () => { + const packageJson = JSON.parse( + await readFile(new URL('./template/package.json', import.meta.url), 'utf8') + ); + + assert.equal( + packageJson.scripts.build, + 'ng build --configuration production' + ); +}); diff --git a/examples/chat/smoke/project.json b/examples/chat/smoke/project.json index 7d59d5df5..affda56c7 100644 --- a/examples/chat/smoke/project.json +++ b/examples/chat/smoke/project.json @@ -15,11 +15,9 @@ "executor": "nx:run-commands", "options": { "cwd": "examples/chat/smoke", - "command": "node cli.mjs --non-interactive --target ../../../tmp/examples-chat-smoke --fresh --local-dist-root ../../../dist/libs --install --build" + "command": "node cli.mjs --non-interactive --target ../../../tmp/examples-chat-smoke --fresh --angular-major 21 --local-dist-root ../../../dist/libs --install --build" } } }, - "tags": [ - "scope:examples-chat" - ] + "tags": ["scope:examples-chat"] } diff --git a/examples/chat/smoke/template/package.json b/examples/chat/smoke/template/package.json index ab2667dca..48df7326d 100644 --- a/examples/chat/smoke/template/package.json +++ b/examples/chat/smoke/template/package.json @@ -5,15 +5,17 @@ "scripts": { "ng": "ng", "start": "ng serve", - "build": "ng build", + "build": "ng build --configuration production", "watch": "ng build --watch --configuration development" }, "packageManager": "npm@10.9.2", "dependencies": { "@angular/common": "^21.2.0", + "@angular/cdk": "21.2.14", "@angular/compiler": "^21.2.0", "@angular/core": "^21.2.0", "@angular/forms": "^21.2.0", + "@angular/google-maps": "21.2.14", "@angular/platform-browser": "^21.2.0", "@angular/router": "^21.2.0", "@threadplane/a2ui": "*", @@ -26,14 +28,21 @@ "@cacheplane/partial-markdown": "^0.3.0", "@cacheplane/partial-json": "^0.2.0", "@langchain/core": "^1.1.33", + "@langchain/langgraph-sdk": "^1.7.4", + "@ag-ui/client": "^0.0.52", + "@ag-ui/core": "^0.0.52", + "@json-render/core": "^0.16.0", + "@noble/ed25519": "^2.3.0", "marked": "^16.0.0", "rxjs": "~7.8.0", - "tslib": "^2.3.0" + "tslib": "^2.3.0", + "zod": "^3.25.0" }, "devDependencies": { "@angular/build": "^21.2.9", "@angular/cli": "^21.2.9", "@angular/compiler-cli": "^21.2.0", + "@types/google.maps": "^3.58.1", "typescript": "~5.9.2" } } From 417c30fd6ecd9f299ef4bbb42a52f663510442f1 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 30 Aug 2026 19:55:13 -0700 Subject: [PATCH 05/13] test(smoke): add browser compatibility probes --- examples/chat/smoke/README.md | 19 +- examples/chat/smoke/cli.mjs | 19 +- examples/chat/smoke/runtime-smoke.mjs | 516 ++++++++++++++++++ examples/chat/smoke/runtime-smoke.spec.mjs | 416 ++++++++++++++ .../smoke/template/src/compatibility-probe.ts | 82 +++ examples/chat/smoke/template/src/main.ts | 6 +- 6 files changed, 1053 insertions(+), 5 deletions(-) create mode 100644 examples/chat/smoke/runtime-smoke.mjs create mode 100644 examples/chat/smoke/runtime-smoke.spec.mjs create mode 100644 examples/chat/smoke/template/src/compatibility-probe.ts diff --git a/examples/chat/smoke/README.md b/examples/chat/smoke/README.md index e8b1c4dfa..c0eac59d2 100644 --- a/examples/chat/smoke/README.md +++ b/examples/chat/smoke/README.md @@ -13,6 +13,17 @@ npx nx run examples-chat-smoke:run node examples/chat/smoke/cli.mjs ``` +To build a generated consumer and run the backend-free browser compatibility +smoke, include `--runtime`. It requires installation and automatically runs the +production build before launching Chromium: + +```bash +node examples/chat/smoke/cli.mjs --non-interactive --install --runtime +``` + +`--runtime` launches Chromium through the root `@playwright/test` dependency. +Install its browser locally first when needed with `npx playwright install chromium`. + ## Flow 1. Prompts for target directory (default: `~/tmp/threadplane`). @@ -22,8 +33,12 @@ node examples/chat/smoke/cli.mjs 5. Copies `examples/chat/angular/src/app/` into the target's `src/app/`. 6. Selects an exact Angular compatibility lane with `--angular-major 20|21|22` (default: `21`) and rewrites all Angular, Angular CLI, and TypeScript pins from the registry. 7. Pins `@threadplane/*` deps to the resolved version, then runs `npm install` with strict peer resolution (`legacy-peer-deps=false`). -8. Optionally runs `npm start`. -9. Drops `CHECKLIST.md` and `SMOKE_RUN.md` (capture metadata, including the selected Angular lane) in the target. +8. Optionally runs the production build and, with `--runtime`, checks the + generated `/embed` page in Chromium. The runtime smoke stubs only the + cold-start thread search and telemetry ingest API calls, so no backend is + required. +9. Optionally runs `npm start`. +10. Drops `CHECKLIST.md` and `SMOKE_RUN.md` (capture metadata, including the selected Angular lane) in the target. ## What's in `template/` diff --git a/examples/chat/smoke/cli.mjs b/examples/chat/smoke/cli.mjs index fb56a0b0e..09f7def96 100755 --- a/examples/chat/smoke/cli.mjs +++ b/examples/chat/smoke/cli.mjs @@ -1,7 +1,5 @@ #!/usr/bin/env node // SPDX-License-Identifier: MIT -/* eslint-disable no-console */ - /** * Smoke generator for a fresh consumer of the canonical examples/chat demo. * @@ -53,6 +51,7 @@ function parseArgs(argv) { install: undefined, nonInteractive: false, packageSpecs: new Map(), + runtime: false, start: undefined, target: undefined, version: undefined, @@ -80,6 +79,7 @@ function parseArgs(argv) { else if (arg === '--start') options.start = true; else if (arg === '--no-start') options.start = false; else if (arg === '--build') options.build = true; + else if (arg === '--runtime') options.runtime = true; else if (arg === '--local-dist-root') options.localDistRoot = readValue(); else if (arg === '--pack-destination') options.packDestination = readValue(); @@ -160,6 +160,12 @@ async function main() { : undefined; const doInstall = options.install ?? !installAnswer.toLowerCase().startsWith('n'); + if (options.runtime && !doInstall) { + throw new Error( + '--runtime requires installation; omit --no-install or pass --install' + ); + } + if (options.runtime) options.build = true; const doStart = options.start ?? (doInstall && !options.nonInteractive @@ -208,6 +214,15 @@ async function main() { await runChild('npm', ['run', 'build'], { cwd: target }); } + if (options.runtime) { + console.log('\n→ Running backend-free runtime compatibility smoke ...'); + await runChild( + process.execPath, + [join(SCRIPT_DIR, 'runtime-smoke.mjs'), '--target', target], + { cwd: SCRIPT_DIR } + ); + } + console.log(`\n✓ Smoke consumer ready at ${target}`); console.log( ' Backend: cd examples/chat/python && uv run langgraph dev --port 2024' diff --git a/examples/chat/smoke/runtime-smoke.mjs b/examples/chat/smoke/runtime-smoke.mjs new file mode 100644 index 000000000..2cf9476d4 --- /dev/null +++ b/examples/chat/smoke/runtime-smoke.mjs @@ -0,0 +1,516 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: MIT +import { spawn } from 'node:child_process'; +import { realpathSync } from 'node:fs'; +import { createServer } from 'node:net'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { chromium } from '@playwright/test'; + +const DEFAULT_PORT = 4300; +const SERVER_READY_TIMEOUT_MS = 60_000; +const COMPATIBILITY_PACKAGES = [ + 'ag-ui', + 'chat', + 'langgraph', + 'render', + 'telemetry', +]; + +function parseRuntimeArgs(argv) { + const options = { port: DEFAULT_PORT, target: undefined }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const readValue = () => { + const value = argv[index + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`${arg} requires a value`); + } + index += 1; + return value; + }; + + if (arg === '--target') options.target = resolve(readValue()); + else if (arg === '--port') { + const port = Number(readValue()); + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new Error('--port must be an integer between 1 and 65535'); + } + options.port = port; + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + + if (!options.target) throw new Error('--target is required'); + return options; +} + +function getServerArgs(port) { + return [ + 'run', + 'start', + '--', + '--configuration', + 'production', + '--host', + '127.0.0.1', + '--port', + String(port), + ]; +} + +function startServer(target, port) { + const child = spawn('npm', getServerArgs(port), { + cwd: target, + detached: process.platform !== 'win32', + shell: process.platform === 'win32', + stdio: ['ignore', 'pipe', 'pipe'], + }); + let output = ''; + let exitError; + const append = (chunk) => { + output += chunk.toString(); + }; + + child.stdout.on('data', append); + child.stderr.on('data', append); + child.on('error', (error) => { + exitError = error; + }); + child.on('close', (code, signal) => { + exitError ??= new Error( + `Consumer server exited before runtime smoke completed (code ${code}, signal ${ + signal ?? 'none' + }).` + ); + }); + + return { + child, + getError: () => exitError, + getOutput: () => output, + }; +} + +function assertPortAvailable(port, { createServerFn = createServer } = {}) { + return new Promise((resolvePort, rejectPort) => { + const probe = createServerFn(); + probe.once('error', (error) => { + if (error.code === 'EADDRINUSE') { + rejectPort( + new Error( + `Port ${port} is already in use; free port ${port} before retrying runtime smoke.`, + { cause: error } + ) + ); + } else { + rejectPort(error); + } + }); + probe.listen(port, '127.0.0.1', () => { + probe.close((error) => { + if (error) rejectPort(error); + else resolvePort(); + }); + }); + }); +} + +async function waitForServer( + url, + server, + { + fetchImpl = fetch, + timeoutMs = SERVER_READY_TIMEOUT_MS, + requestTimeoutMs = 1_000, + sleep = (delay) => + new Promise((resolveDelay) => setTimeout(resolveDelay, delay)), + } = {} +) { + const deadline = Date.now() + timeoutMs; + let lastError; + + while (Date.now() < deadline) { + if (server.getError()) throw server.getError(); + const controller = new AbortController(); + const remainingMs = deadline - Date.now(); + const abortTimer = setTimeout( + () => controller.abort(), + Math.min(requestTimeoutMs, remainingMs) + ); + let response; + try { + response = await fetchImpl(url, { signal: controller.signal }); + } catch (error) { + lastError = error; + } finally { + clearTimeout(abortTimer); + } + if (server.getError()) throw server.getError(); + if (response?.ok) return; + if (response) + lastError = new Error(`Server responded with ${response.status}`); + await sleep(Math.min(250, Math.max(0, deadline - Date.now()))); + } + + throw new Error( + `Timed out waiting ${timeoutMs / 1000}s for ${url}${ + lastError ? `: ${lastError.message}` : '' + }` + ); +} + +function throwIfServerExited(server) { + const error = server.getError(); + if (error) throw error; +} + +function hasExited(child) { + return child.exitCode != null || child.signalCode != null; +} + +function waitForChildClose(child, { graceMs, setTimeoutFn, clearTimeoutFn }) { + if (hasExited(child)) return Promise.resolve(true); + + return new Promise((resolveClose) => { + const finish = (closed) => { + clearTimeoutFn(timer); + child.removeListener?.('close', onClose); + resolveClose(closed); + }; + const onClose = () => finish(true); + + child.once('close', onClose); + const timer = setTimeoutFn(() => finish(false), graceMs); + }); +} + +function signalProcessGroup(processRef, pid, signal) { + try { + processRef.kill(-pid, signal); + return true; + } catch (error) { + if (error.code === 'ESRCH') return false; + throw error; + } +} + +function isProcessGroupAlive(processRef, pid) { + try { + processRef.kill(-pid, 0); + return true; + } catch (error) { + if (error.code === 'ESRCH') return false; + throw error; + } +} + +async function waitForProcessGroupGone( + pid, + { processRef, graceMs, setTimeoutFn, now = Date.now } +) { + const deadline = now() + graceMs; + + while (isProcessGroupAlive(processRef, pid)) { + const remainingMs = deadline - now(); + if (remainingMs <= 0) return false; + await new Promise((resolvePoll) => + setTimeoutFn(resolvePoll, Math.min(100, remainingMs)) + ); + } + + return true; +} + +function taskkill(pid, spawnFn) { + return new Promise((resolveTaskkill, rejectTaskkill) => { + let child; + try { + child = spawnFn('taskkill', ['/PID', String(pid), '/T', '/F'], { + stdio: 'ignore', + }); + } catch (error) { + rejectTaskkill(error); + return; + } + child.once('error', rejectTaskkill); + child.once('close', (code) => { + if (code === 0) resolveTaskkill(); + else rejectTaskkill(new Error(`taskkill exited ${code}`)); + }); + }); +} + +async function terminateServer( + child, + { + platform = process.platform, + processRef = process, + spawnFn = spawn, + graceMs = 5_000, + setTimeoutFn = setTimeout, + clearTimeoutFn = clearTimeout, + } = {} +) { + if (!child?.pid) return; + const timing = { graceMs, setTimeoutFn, clearTimeoutFn }; + + if (platform === 'win32') { + if (hasExited(child)) return; + await taskkill(child.pid, spawnFn); + if (!(await waitForChildClose(child, timing))) { + throw new Error( + `Server process ${child.pid} did not close after taskkill` + ); + } + return; + } + + const groupTiming = { processRef, graceMs, setTimeoutFn }; + if (!isProcessGroupAlive(processRef, child.pid)) return; + if (!signalProcessGroup(processRef, child.pid, 'SIGTERM')) return; + if (await waitForProcessGroupGone(child.pid, groupTiming)) return; + if (!signalProcessGroup(processRef, child.pid, 'SIGKILL')) return; + if (!(await waitForProcessGroupGone(child.pid, groupTiming))) { + throw new Error( + `Server process group ${child.pid} did not close after SIGKILL` + ); + } +} + +function createBackendRouteController() { + let unexpectedError; + let resolveFailure; + const failure = new Promise((resolveFailurePromise) => { + resolveFailure = resolveFailurePromise; + }); + const recordFailure = (error) => { + unexpectedError ??= error; + resolveFailure(unexpectedError); + }; + const errorMessage = (error) => + error instanceof Error ? error.message : String(error); + const abortSafely = async (route, error) => { + try { + await route.abort('failed'); + } catch (abortError) { + error.message += `\nFallback route abort failed: ${errorMessage( + abortError + )}`; + } + }; + + return { + async handle(route) { + let method = 'unknown'; + let pathname = 'unknown'; + try { + const request = route.request(); + method = request.method(); + pathname = new URL(request.url()).pathname; + if (method === 'POST' && pathname.endsWith('/threads/search')) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: '[]', + }); + return; + } + if (method === 'POST' && pathname.endsWith('/ingest')) { + await route.fulfill({ status: 204, body: '' }); + return; + } + + const error = new Error( + `Unexpected backend request during compatibility smoke: ${method} ${pathname}` + ); + await abortSafely(route, error); + recordFailure(error); + } catch (error) { + const routeError = new Error( + `Failed handling backend route ${method} ${pathname}: ${errorMessage( + error + )}`, + { cause: error } + ); + await abortSafely(route, routeError); + recordFailure(routeError); + } + }, + throwIfRecorded() { + if (unexpectedError) throw unexpectedError; + }, + waitForFailure() { + return failure.then((error) => Promise.reject(error)); + }, + }; +} + +async function assertCompatibilityMarkers( + page, + packages = COMPATIBILITY_PACKAGES +) { + for (const packageName of packages) { + const marker = page.locator( + `[data-threadplane-compatibility="${packageName}"]` + ); + await marker.waitFor(); + const markerText = await marker.innerText(); + if (markerText !== `${packageName} ready`) { + throw new Error( + `${packageName} compatibility probe reported "${markerText}"` + ); + } + } +} + +async function finalizeRuntimeSmoke({ + primaryError, + serverOutput = '', + captureScreenshot, + stopTracing, + closeBrowser, + terminateServer: stopServer, +}) { + const cleanupErrors = []; + const attempt = async (cleanup) => { + if (!cleanup) return; + try { + await cleanup(); + } catch (error) { + cleanupErrors.push(error); + } + }; + + if (primaryError) await attempt(captureScreenshot); + await attempt(stopTracing); + await attempt(closeBrowser); + await attempt(stopServer); + + if (primaryError) { + const cleanupDetails = cleanupErrors.length + ? `\n\nRuntime smoke cleanup errors:\n${cleanupErrors + .map((error) => `- ${error.message}`) + .join('\n')}` + : ''; + throw new Error( + `${primaryError.message}\n\nConsumer server output:\n${serverOutput}${cleanupDetails}`, + { cause: primaryError } + ); + } + + if (cleanupErrors.length > 0) { + throw new AggregateError(cleanupErrors, 'Runtime smoke cleanup failed.'); + } +} + +async function runRuntimeSmoke(options) { + const baseUrl = `http://127.0.0.1:${options.port}`; + const artifactPath = (filename) => join(options.target, filename); + await assertPortAvailable(options.port); + const server = startServer(options.target, options.port); + let browser; + let context; + let page; + let tracing = false; + let primaryError; + + try { + await waitForServer(`${baseUrl}/embed`, server); + browser = await chromium.launch({ headless: true }); + context = await browser.newContext(); + page = await context.newPage(); + await context.tracing.start({ screenshots: true, snapshots: true }); + tracing = true; + + const pageErrors = []; + const consoleErrors = []; + page.on('pageerror', (error) => pageErrors.push(error)); + page.on('console', (message) => { + if (message.type() === 'error') consoleErrors.push(message.text()); + }); + + const routeController = createBackendRouteController(); + await page.route('**/api/**', (route) => routeController.handle(route)); + + await Promise.race([ + (async () => { + await page.goto(`${baseUrl}/embed`, { waitUntil: 'networkidle' }); + await page.getByRole('heading', { name: 'How can I help?' }).waitFor(); + await page.locator('textarea, input').first().waitFor(); + await page.locator('chat-welcome-suggestion').first().waitFor(); + + await assertCompatibilityMarkers(page); + routeController.throwIfRecorded(); + + if (pageErrors.length > 0) { + throw new Error( + `Page errors during compatibility smoke:\n${pageErrors + .map(String) + .join('\n')}` + ); + } + if (consoleErrors.length > 0) { + throw new Error( + `Console errors during compatibility smoke:\n${consoleErrors.join( + '\n' + )}` + ); + } + })(), + routeController.waitForFailure(), + ]); + throwIfServerExited(server); + } catch (error) { + primaryError = error; + } finally { + await finalizeRuntimeSmoke({ + primaryError, + serverOutput: server.getOutput(), + captureScreenshot: + primaryError && page + ? () => page.screenshot({ path: artifactPath('runtime-smoke.png') }) + : undefined, + stopTracing: tracing + ? () => + context.tracing.stop( + primaryError + ? { path: artifactPath('runtime-smoke-trace.zip') } + : undefined + ) + : undefined, + closeBrowser: browser ? () => browser.close() : undefined, + terminateServer: () => terminateServer(server.child), + }); + } +} + +async function main() { + await runRuntimeSmoke(parseRuntimeArgs(process.argv.slice(2))); +} + +export { + assertPortAvailable, + assertCompatibilityMarkers, + COMPATIBILITY_PACKAGES, + createBackendRouteController, + finalizeRuntimeSmoke, + getServerArgs, + parseRuntimeArgs, + terminateServer, + throwIfServerExited, + waitForServer, +}; + +if ( + process.argv[1] && + realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)) +) { + main().catch((error) => { + console.error(`\n✖ Runtime smoke failed: ${error.message}`); + process.exitCode = 1; + }); +} diff --git a/examples/chat/smoke/runtime-smoke.spec.mjs b/examples/chat/smoke/runtime-smoke.spec.mjs new file mode 100644 index 000000000..87d428144 --- /dev/null +++ b/examples/chat/smoke/runtime-smoke.spec.mjs @@ -0,0 +1,416 @@ +// SPDX-License-Identifier: MIT + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { readFile } from 'node:fs/promises'; +import { createServer } from 'node:net'; + +import * as runtimeSmoke from './runtime-smoke.mjs'; + +const { COMPATIBILITY_PACKAGES, parseRuntimeArgs } = runtimeSmoke; + +test('requires a generated consumer target', () => { + assert.throws(() => parseRuntimeArgs([]), /--target is required/); +}); + +test('rejects a missing runtime option value clearly', () => { + assert.throws( + () => parseRuntimeArgs(['--target', '--port', '4300']), + /--target requires a value/ + ); +}); + +test('uses production configuration when starting the generated consumer', () => { + assert.deepEqual(runtimeSmoke.getServerArgs(4300), [ + 'run', + 'start', + '--', + '--configuration', + 'production', + '--host', + '127.0.0.1', + '--port', + '4300', + ]); +}); + +test('defines visible compatibility probes for every public package', async () => { + assert.deepEqual(COMPATIBILITY_PACKAGES, [ + 'ag-ui', + 'chat', + 'langgraph', + 'render', + 'telemetry', + ]); + + const probe = await readFile( + new URL('./template/src/compatibility-probe.ts', import.meta.url), + 'utf8' + ); + + for (const packageName of COMPATIBILITY_PACKAGES) { + assert.match( + probe, + new RegExp(`data-threadplane-compatibility=["']${packageName}["']`) + ); + } +}); + +test('handles allowed API routes and reports unexpected ones through the main flow', async () => { + const controller = runtimeSmoke.createBackendRouteController(); + const fulfilled = []; + const aborted = []; + const route = (method, pathname) => ({ + request: () => ({ + method: () => method, + url: () => `http://127.0.0.1:4300${pathname}`, + }), + fulfill: async (response) => fulfilled.push(response), + abort: async (reason) => aborted.push(reason), + }); + + await controller.handle(route('POST', '/api/threads/search')); + await controller.handle(route('POST', '/api/ingest')); + await controller.handle(route('GET', '/api/ingest')); + + assert.deepEqual(fulfilled, [ + { status: 200, contentType: 'application/json', body: '[]' }, + { status: 204, body: '' }, + ]); + assert.deepEqual(aborted, ['failed']); + await assert.rejects( + controller.waitForFailure(), + /Unexpected backend request during compatibility smoke: GET \/api\/ingest/ + ); +}); + +test('contains allowed-route fulfillment failures in the awaited route signal', async () => { + const controller = runtimeSmoke.createBackendRouteController(); + let aborted = false; + const route = { + request: () => ({ + method: () => 'POST', + url: () => 'http://127.0.0.1:4300/api/threads/search', + }), + fulfill: async () => { + throw new Error('fulfillment failed'); + }, + abort: async () => { + aborted = true; + throw new Error('fallback abort failed'); + }, + }; + + await assert.doesNotReject(controller.handle(route)); + await assert.rejects( + controller.waitForFailure(), + /Failed handling backend route POST \/api\/threads\/search: fulfillment failed.*fallback abort failed/s + ); + assert.equal(aborted, true); +}); + +test('preserves a smoke failure when diagnostic capture and cleanup fail', async () => { + const calls = []; + + const error = await runtimeSmoke + .finalizeRuntimeSmoke({ + primaryError: new Error('page assertion failed'), + serverOutput: 'consumer output', + captureScreenshot: async () => { + calls.push('screenshot'); + throw new Error('screenshot failed'); + }, + stopTracing: async () => { + calls.push('trace'); + throw new Error('trace failed'); + }, + closeBrowser: async () => { + calls.push('browser'); + throw new Error('browser close failed'); + }, + terminateServer: () => calls.push('terminate'), + }) + .then( + () => assert.fail('Expected runtime smoke finalization to reject'), + (failure) => failure + ); + + assert.match(error.message, /page assertion failed/); + assert.match(error.message, /consumer output/); + assert.match(error.message, /screenshot failed/); + assert.match(error.message, /trace failed/); + assert.match(error.message, /browser close failed/); + assert.deepEqual(calls, ['screenshot', 'trace', 'browser', 'terminate']); +}); + +test('terminates the server after successful-smoke cleanup failures', async () => { + const calls = []; + + await assert.rejects( + async () => + runtimeSmoke.finalizeRuntimeSmoke({ + stopTracing: async () => { + calls.push('trace'); + throw new Error('trace failed'); + }, + closeBrowser: async () => { + calls.push('browser'); + throw new Error('browser close failed'); + }, + terminateServer: () => calls.push('terminate'), + }), + AggregateError + ); + + assert.deepEqual(calls, ['trace', 'browser', 'terminate']); +}); + +test('escalates POSIX server process-group termination after its grace period', async () => { + const child = new EventEmitter(); + child.pid = 2468; + child.exitCode = null; + child.signalCode = null; + let groupAlive = true; + const signals = []; + + await runtimeSmoke.terminateServer(child, { + platform: 'linux', + graceMs: 0, + processRef: { + kill(pid, signal) { + signals.push([pid, signal]); + if (signal === 0) { + if (!groupAlive) { + const error = new Error('group is gone'); + error.code = 'ESRCH'; + throw error; + } + return; + } + if (signal === 'SIGKILL') { + groupAlive = false; + child.exitCode = 137; + queueMicrotask(() => child.emit('close', 137, 'SIGKILL')); + } + }, + }, + setTimeoutFn(callback) { + queueMicrotask(callback); + return 1; + }, + clearTimeoutFn() { + return undefined; + }, + }); + + assert.deepEqual( + signals.filter(([, signal]) => signal !== 0), + [ + [-2468, 'SIGTERM'], + [-2468, 'SIGKILL'], + ] + ); +}); + +test('kills a live POSIX process group even after the npm parent has closed', async () => { + const child = new EventEmitter(); + child.pid = 2468; + child.exitCode = 0; + child.signalCode = null; + let groupAlive = true; + const calls = []; + + await runtimeSmoke.terminateServer(child, { + platform: 'linux', + graceMs: 0, + processRef: { + kill(pid, signal) { + calls.push([pid, signal]); + if (signal === 0) { + if (!groupAlive) { + const error = new Error('group is gone'); + error.code = 'ESRCH'; + throw error; + } + return; + } + if (signal === 'SIGKILL') groupAlive = false; + }, + }, + setTimeoutFn(callback) { + queueMicrotask(callback); + return 1; + }, + clearTimeoutFn() { + return undefined; + }, + }); + + assert.deepEqual( + calls.filter(([, signal]) => signal !== 0), + [ + [-2468, 'SIGTERM'], + [-2468, 'SIGKILL'], + ] + ); + assert.ok(calls.filter(([, signal]) => signal === 0).length >= 3); +}); + +test('uses taskkill and waits for a Windows server process tree to close', async () => { + const child = new EventEmitter(); + child.pid = 1357; + child.exitCode = null; + child.signalCode = null; + const spawned = []; + + await runtimeSmoke.terminateServer(child, { + platform: 'win32', + graceMs: 0, + spawnFn(command, args, options) { + spawned.push([command, args, options]); + const taskkill = new EventEmitter(); + queueMicrotask(() => taskkill.emit('close', 0)); + queueMicrotask(() => { + child.exitCode = 0; + child.emit('close', 0, null); + }); + return taskkill; + }, + setTimeoutFn() { + throw new Error('child should already be closed after taskkill'); + }, + clearTimeoutFn() { + return undefined; + }, + }); + + assert.deepEqual(spawned, [ + ['taskkill', ['/PID', '1357', '/T', '/F'], { stdio: 'ignore' }], + ]); +}); + +test('bounds a hung readiness fetch with an abort signal', async () => { + let aborted = false; + + await assert.rejects( + runtimeSmoke.waitForServer( + 'http://127.0.0.1:4300/embed', + { getError: () => undefined }, + { + timeoutMs: 5, + requestTimeoutMs: 1, + fetchImpl: (_url, { signal }) => + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { + aborted = true; + reject(signal.reason); + }); + }), + async sleep() { + return undefined; + }, + } + ), + /Timed out waiting 0.005s/ + ); + + assert.equal(aborted, true); +}); + +test('rejects a port already occupied before starting the consumer server', async () => { + const listener = createServer(); + await new Promise((resolveListen) => + listener.listen(0, '127.0.0.1', resolveListen) + ); + const { port } = listener.address(); + + try { + await assert.rejects( + runtimeSmoke.assertPortAvailable(port), + new RegExp( + `Port ${port} is already in use; free port ${port} before retrying` + ) + ); + } finally { + await new Promise((resolveClose, rejectClose) => + listener.close((error) => (error ? rejectClose(error) : resolveClose())) + ); + } +}); + +test('reports an early server exit before attempting readiness fetches', async () => { + let fetched = false; + + await assert.rejects( + runtimeSmoke.waitForServer( + 'http://127.0.0.1:4300/embed', + { getError: () => new Error('Consumer server exited early') }, + { + fetchImpl: async () => { + fetched = true; + throw new Error('fetch should not run'); + }, + } + ), + /Consumer server exited early/ + ); + + assert.equal(fetched, false); +}); + +test('does not accept readiness when the child exits during the fetch', async () => { + let checks = 0; + + await assert.rejects( + runtimeSmoke.waitForServer( + 'http://127.0.0.1:4300/embed', + { + getError: () => { + checks += 1; + return checks === 1 + ? undefined + : new Error('Consumer server exited during readiness'); + }, + }, + { fetchImpl: async () => ({ ok: true }) } + ), + /Consumer server exited during readiness/ + ); +}); + +test('fails the smoke when the server exits after browser assertions', () => { + assert.throws( + () => + runtimeSmoke.throwIfServerExited({ + getError: () => new Error('Consumer server exited after assertions'), + }), + /Consumer server exited after assertions/ + ); +}); + +test('requires every visible compatibility marker to report exact readiness', async () => { + const markerText = new Map( + COMPATIBILITY_PACKAGES.map((packageName) => [ + packageName, + `${packageName} ready`, + ]) + ); + markerText.set('telemetry', 'telemetry unavailable'); + const waits = []; + const page = { + locator(selector) { + const packageName = selector.match(/="([^"]+)"/)?.[1]; + return { + waitFor: async () => waits.push(packageName), + innerText: async () => markerText.get(packageName), + }; + }, + }; + + await assert.rejects( + runtimeSmoke.assertCompatibilityMarkers(page), + /telemetry compatibility probe reported "telemetry unavailable"/ + ); + assert.deepEqual(waits, COMPATIBILITY_PACKAGES); +}); diff --git a/examples/chat/smoke/template/src/compatibility-probe.ts b/examples/chat/smoke/template/src/compatibility-probe.ts new file mode 100644 index 000000000..0458da30c --- /dev/null +++ b/examples/chat/smoke/template/src/compatibility-probe.ts @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MIT + +import { + ChangeDetectionStrategy, + Component, + provideZonelessChangeDetection, +} from '@angular/core'; +import { bootstrapApplication } from '@angular/platform-browser'; +import { + injectAgent as injectAgUiAgent, + provideFakeAgent, +} from '@threadplane/ag-ui'; +import { ChatComponent } from '@threadplane/chat'; +import { provideAgent as provideLangGraphAgent } from '@threadplane/langgraph'; +import { RenderSpecComponent } from '@threadplane/render'; +import { provideThreadplaneTelemetry } from '@threadplane/telemetry/browser'; + +const PACKAGE_REFS = [ + ['chat', ChatComponent], + ['langgraph', provideLangGraphAgent], + ['render', RenderSpecComponent], + ['telemetry', provideThreadplaneTelemetry], +] as const; + +@Component({ + selector: 'threadplane-compatibility-probe', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ``, + styles: [ + ` + :host { + display: block; + font: 10px/1.2 monospace; + padding: 2px 4px; + } + span + span { + margin-left: 4px; + } + `, + ], +}) +class CompatibilityProbeComponent { + private readonly agUiAgent = injectAgUiAgent(); + protected readonly agUiReady = Boolean(this.agUiAgent); + private readonly packageRefs = Object.fromEntries(PACKAGE_REFS) as Record< + string, + unknown + >; + + protected packageReady(name: string) { + return Boolean(this.packageRefs[name]); + } +} + +export function bootstrapCompatibilityProbe() { + const host = document.createElement('threadplane-compatibility-probe'); + document.body.append(host); + + return bootstrapApplication(CompatibilityProbeComponent, { + providers: [ + provideZonelessChangeDetection(), + ...provideFakeAgent({ tokens: ['compatibility'] }), + ], + }); +} diff --git a/examples/chat/smoke/template/src/main.ts b/examples/chat/smoke/template/src/main.ts index 190f3418d..b7371a7ba 100644 --- a/examples/chat/smoke/template/src/main.ts +++ b/examples/chat/smoke/template/src/main.ts @@ -1,5 +1,9 @@ import { bootstrapApplication } from '@angular/platform-browser'; import { appConfig } from './app/app.config'; import { App } from './app/app'; +import { bootstrapCompatibilityProbe } from './compatibility-probe'; -bootstrapApplication(App, appConfig).catch((err) => console.error(err)); +Promise.all([ + bootstrapApplication(App, appConfig), + bootstrapCompatibilityProbe(), +]).catch((err) => console.error(err)); From 103dd90d670ec88dd16480cd2711ac033020147b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 30 Aug 2026 20:57:26 -0700 Subject: [PATCH 06/13] fix(chat): preserve A2UI change detection across Angular versions --- .../a2ui/catalog/audio-player.component.ts | 3 +- .../src/lib/a2ui/catalog/card.component.ts | 3 +- .../lib/a2ui/catalog/change-detection.spec.ts | 50 +++++++++++++++++++ .../src/lib/a2ui/catalog/column.component.ts | 3 +- .../src/lib/a2ui/catalog/divider.component.ts | 3 +- .../src/lib/a2ui/catalog/icon.component.ts | 3 +- .../src/lib/a2ui/catalog/image.component.ts | 3 +- .../src/lib/a2ui/catalog/list.component.ts | 3 +- .../src/lib/a2ui/catalog/modal.component.ts | 3 +- .../src/lib/a2ui/catalog/row.component.ts | 3 +- .../src/lib/a2ui/catalog/tabs.component.ts | 3 +- .../src/lib/a2ui/catalog/text.component.ts | 3 +- .../src/lib/a2ui/catalog/video.component.ts | 3 +- 13 files changed, 74 insertions(+), 12 deletions(-) create mode 100644 libs/chat/src/lib/a2ui/catalog/change-detection.spec.ts diff --git a/libs/chat/src/lib/a2ui/catalog/audio-player.component.ts b/libs/chat/src/lib/a2ui/catalog/audio-player.component.ts index 152109699..67abee942 100644 --- a/libs/chat/src/lib/a2ui/catalog/audio-player.component.ts +++ b/libs/chat/src/lib/a2ui/catalog/audio-player.component.ts @@ -1,10 +1,11 @@ // SPDX-License-Identifier: MIT -import { Component, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; import type { Spec } from '@json-render/core'; @Component({ selector: 'a2ui-audio-player', standalone: true, + changeDetection: ChangeDetectionStrategy.Default, template: `
@if (description()) { diff --git a/libs/chat/src/lib/a2ui/catalog/card.component.ts b/libs/chat/src/lib/a2ui/catalog/card.component.ts index 1cbc1c8fc..81dbc2b29 100644 --- a/libs/chat/src/lib/a2ui/catalog/card.component.ts +++ b/libs/chat/src/lib/a2ui/catalog/card.component.ts @@ -1,11 +1,12 @@ // SPDX-License-Identifier: MIT -import { Component, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; import type { Spec } from '@json-render/core'; import { RenderElementComponent } from '@threadplane/render'; @Component({ selector: 'a2ui-card', standalone: true, + changeDetection: ChangeDetectionStrategy.Default, imports: [RenderElementComponent], template: `
diff --git a/libs/chat/src/lib/a2ui/catalog/change-detection.spec.ts b/libs/chat/src/lib/a2ui/catalog/change-detection.spec.ts new file mode 100644 index 000000000..b8ba14b3e --- /dev/null +++ b/libs/chat/src/lib/a2ui/catalog/change-detection.spec.ts @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { A2uiAudioPlayerComponent } from './audio-player.component'; +import { A2uiCardComponent } from './card.component'; +import { A2uiColumnComponent } from './column.component'; +import { A2uiDividerComponent } from './divider.component'; +import { A2uiIconComponent } from './icon.component'; +import { A2uiImageComponent } from './image.component'; +import { A2uiListComponent } from './list.component'; +import { A2uiModalComponent } from './modal.component'; +import { A2uiRowComponent } from './row.component'; +import { A2uiTabsComponent } from './tabs.component'; +import { A2uiTextComponent } from './text.component'; +import { A2uiVideoComponent } from './video.component'; + +const components = [ + ['audio-player.component.ts', A2uiAudioPlayerComponent], + ['card.component.ts', A2uiCardComponent], + ['column.component.ts', A2uiColumnComponent], + ['divider.component.ts', A2uiDividerComponent], + ['icon.component.ts', A2uiIconComponent], + ['image.component.ts', A2uiImageComponent], + ['list.component.ts', A2uiListComponent], + ['modal.component.ts', A2uiModalComponent], + ['row.component.ts', A2uiRowComponent], + ['tabs.component.ts', A2uiTabsComponent], + ['text.component.ts', A2uiTextComponent], + ['video.component.ts', A2uiVideoComponent], +] as const; + +describe('A2UI catalog change detection', () => { + const catalogDirectory = dirname(fileURLToPath(import.meta.url)); + + for (const row of components) { + const file = row[0]; + const component = row[1]; + const source = readFileSync(join(catalogDirectory, file), 'utf8'); + const onPush = (component as unknown as { ɵcmp: { onPush: boolean } }).ɵcmp + .onPush; + it(`${file} explicitly preserves default change detection`, () => { + expect(source).toContain( + 'changeDetection: ChangeDetectionStrategy.Default' + ); + expect(onPush).toBe(false); + }); + } +}); diff --git a/libs/chat/src/lib/a2ui/catalog/column.component.ts b/libs/chat/src/lib/a2ui/catalog/column.component.ts index 2718630d4..3ada800dd 100644 --- a/libs/chat/src/lib/a2ui/catalog/column.component.ts +++ b/libs/chat/src/lib/a2ui/catalog/column.component.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -import { Component, computed, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; import type { Spec } from '@json-render/core'; import { RenderElementComponent } from '@threadplane/render'; @@ -21,6 +21,7 @@ const JUSTIFY_MAP: Record = { @Component({ selector: 'a2ui-column', standalone: true, + changeDetection: ChangeDetectionStrategy.Default, imports: [RenderElementComponent], template: `
diff --git a/libs/chat/src/lib/a2ui/catalog/icon.component.ts b/libs/chat/src/lib/a2ui/catalog/icon.component.ts index fd68494fb..e5d5a6d5f 100644 --- a/libs/chat/src/lib/a2ui/catalog/icon.component.ts +++ b/libs/chat/src/lib/a2ui/catalog/icon.component.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -import { Component, computed, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; import type { Spec } from '@json-render/core'; /** @@ -19,6 +19,7 @@ export function toMaterialSymbolName(name: string): string { @Component({ selector: 'a2ui-icon', standalone: true, + changeDetection: ChangeDetectionStrategy.Default, template: ` @if (svgPath(); as path) { = { @Component({ selector: 'a2ui-image', standalone: true, + changeDetection: ChangeDetectionStrategy.Default, template: ` diff --git a/libs/chat/src/lib/a2ui/catalog/modal.component.ts b/libs/chat/src/lib/a2ui/catalog/modal.component.ts index 1810b7bb6..ef776c2bb 100644 --- a/libs/chat/src/lib/a2ui/catalog/modal.component.ts +++ b/libs/chat/src/lib/a2ui/catalog/modal.component.ts @@ -1,11 +1,12 @@ // SPDX-License-Identifier: MIT -import { Component, computed, input, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, input, signal } from '@angular/core'; import type { Spec } from '@json-render/core'; import { RenderElementComponent } from '@threadplane/render'; @Component({ selector: 'a2ui-modal', standalone: true, + changeDetection: ChangeDetectionStrategy.Default, imports: [RenderElementComponent], template: ` diff --git a/libs/chat/src/lib/a2ui/catalog/row.component.ts b/libs/chat/src/lib/a2ui/catalog/row.component.ts index 3f9bceea1..b26b00001 100644 --- a/libs/chat/src/lib/a2ui/catalog/row.component.ts +++ b/libs/chat/src/lib/a2ui/catalog/row.component.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -import { Component, computed, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; import type { Spec } from '@json-render/core'; import { RenderElementComponent } from '@threadplane/render'; @@ -21,6 +21,7 @@ const JUSTIFY_MAP: Record = { @Component({ selector: 'a2ui-row', standalone: true, + changeDetection: ChangeDetectionStrategy.Default, imports: [RenderElementComponent], template: `
diff --git a/libs/chat/src/lib/a2ui/catalog/text.component.ts b/libs/chat/src/lib/a2ui/catalog/text.component.ts index 7680b6e60..e1ec86443 100644 --- a/libs/chat/src/lib/a2ui/catalog/text.component.ts +++ b/libs/chat/src/lib/a2ui/catalog/text.component.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -import { Component, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; import type { Spec } from '@json-render/core'; type TextVariant = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'caption' | 'body'; @@ -7,6 +7,7 @@ type TextVariant = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'caption' | 'body'; @Component({ selector: 'a2ui-text', standalone: true, + changeDetection: ChangeDetectionStrategy.Default, template: `{{ text() }}`, styles: [` .a2ui-text-h1 { diff --git a/libs/chat/src/lib/a2ui/catalog/video.component.ts b/libs/chat/src/lib/a2ui/catalog/video.component.ts index eb07fd7c9..94b79d877 100644 --- a/libs/chat/src/lib/a2ui/catalog/video.component.ts +++ b/libs/chat/src/lib/a2ui/catalog/video.component.ts @@ -1,10 +1,11 @@ // SPDX-License-Identifier: MIT -import { Component, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; import type { Spec } from '@json-render/core'; @Component({ selector: 'a2ui-video', standalone: true, + changeDetection: ChangeDetectionStrategy.Default, template: `