Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba05fe88b3 | ||
|
|
cc3664454a | ||
|
|
c70339972a | ||
|
|
711c9e00c3 | ||
|
|
f56b66e10c | ||
|
|
5ee95fe9ca | ||
|
|
389f6eb7e2 | ||
|
|
880efe4e90 | ||
|
|
c59d75e526 | ||
|
|
b086f1ffc8 | ||
|
|
813d9b4d7f | ||
|
|
9158966982 | ||
|
|
d9c7f72461 | ||
|
|
1636355920 | ||
|
|
85fc4561fd | ||
|
|
282d9c6787 | ||
|
|
dfe6d28d2b | ||
|
|
ac69a41fc8 | ||
|
|
f74d744f9b | ||
|
|
94d57add2c | ||
|
|
558c70aa24 | ||
|
|
c949480e49 | ||
|
|
752c8b7a99 |
No files matched your search
+7
-6
@@ -3,16 +3,17 @@
|
|||||||
All AI agents (including Cline, Antigravity, Cursor, and Copilot) must adhere to the following rules before declaring a task complete:
|
All AI agents (including Cline, Antigravity, Cursor, and Copilot) must adhere to the following rules before declaring a task complete:
|
||||||
|
|
||||||
1. **Test Coverage (>= 80%)**:
|
1. **Test Coverage (>= 80%)**:
|
||||||
- The overall project code coverage (Statements, Lines, and Functions) must be maintained at **80% or higher**.
|
- The overall project line coverage must be maintained at **80% or higher**. Bun reports line data only; statement, branch and function coverage are no longer measured.
|
||||||
- Verify coverage by running:
|
- Verify coverage by running:
|
||||||
```bash
|
```bash
|
||||||
bun scripts/generate-langs.js
|
bun scripts/generate-langs.js
|
||||||
NODE_OPTIONS=--max-old-space-size=2048 bunx vitest run --coverage --maxWorkers=1 --exclude='src/app/benchmark/**'
|
bun test --config=bunfig.coverage.toml --isolate --path-ignore-patterns='**/benchmark/**'
|
||||||
|
bun scripts/coverage-reports.mjs
|
||||||
```
|
```
|
||||||
- If your changes introduce new code or modify existing code in a way that drops the coverage below 80%, you must write appropriate unit tests in `.spec.ts` files to bring it back up.
|
- If your changes introduce new code or modify existing code in a way that drops the coverage below 80%, you must write appropriate unit tests in `.spec.ts` files to bring it back up.
|
||||||
|
|
||||||
2. **Code Formatting (Prettier)**:
|
2. **Code Formatting (Prettier)**:
|
||||||
- All code files must be correctly formatted according to the project's Prettier config before finishing.
|
- All code files, the CI scripts and the workflow must be correctly formatted according to the project's Prettier config before finishing.
|
||||||
- Run the formatter with:
|
- Run the formatter with:
|
||||||
```bash
|
```bash
|
||||||
bun run format
|
bun run format
|
||||||
@@ -29,8 +30,8 @@ All AI agents (including Cline, Antigravity, Cursor, and Copilot) must adhere to
|
|||||||
- You must execute the local validation commands before completing your task. The Gitea workflow is `.gitea/workflows/ci.yml`; owner activation is tracked in issue #268, and local checks do not satisfy the migration gate. All of the following commands must pass:
|
- You must execute the local validation commands before completing your task. The Gitea workflow is `.gitea/workflows/ci.yml`; owner activation is tracked in issue #268, and local checks do not satisfy the migration gate. All of the following commands must pass:
|
||||||
- **Linting check**: `bun run lint` (or use `bun run lint:fix` to resolve auto-fixable issues)
|
- **Linting check**: `bun run lint` (or use `bun run lint:fix` to resolve auto-fixable issues)
|
||||||
- **Formatting check**: `bun run format:check`
|
- **Formatting check**: `bun run format:check`
|
||||||
- **TypeScript Type check**: `bunx tsc --noEmit -p tsconfig.app.json` and `bunx tsc --noEmit -p tsconfig.spec.json`
|
- **TypeScript Type check**: `bun run typecheck` (`bun check` for application and test code; it does not replace the production build, which alone checks Angular templates)
|
||||||
- **Unit Tests and Coverage**: generate the language registry, then run the coverage command in rule 1, matching the Gitea functional-test job.
|
- **Unit Tests and Coverage**: generate the language registry, then run the coverage command in rule 1, matching the Gitea functional-test job.
|
||||||
- **CI policy and publication**: `node scripts/ci-policy.test.mjs` and `node scripts/ci-image.test.mjs`
|
- **CI policy and publication**: `bun scripts/ci-policy.test.mjs` and `bun scripts/ci-image.test.mjs`
|
||||||
- **Production Build**: `bun run build -- --configuration production`
|
- **Production Build**: `bun run build -- --configuration production`
|
||||||
- The separate performance benchmark is a required CI job with a relative gate: each scenario's budget is a multiple of the frozen engine's two-year calibration measured in the same run. Run it outside coverage with `NODE_OPTIONS=--max-old-space-size=2048 bunx vitest run src/app/benchmark/ --maxWorkers=1` when verifying performance; see `docs/benchmark/README.md`.
|
- The separate performance benchmark is a required CI job with a relative gate: each scenario's budget is a multiple of the frozen engine's two-year calibration measured in the same run. Run it outside coverage with `timeout 600s bun test src/app/benchmark --timeout=600000` when verifying performance; see `docs/benchmark/README.md`.
|
||||||
+66
-37
@@ -9,6 +9,11 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
|
# Gitea's checkout and upload-artifact actions are JavaScript actions that the runner
|
||||||
|
# executes with the job container's own `node`, so those containers keep Node. Project
|
||||||
|
# commands never use it: the jobs that run them install the pinned Bun with
|
||||||
|
# scripts/install-bun.sh and run `run:` steps under a shell whose PATH has Bun but no Node.
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ci-${{ github.event_name }}-${{ github.ref }}
|
group: ci-${{ github.event_name }}-${{ github.ref }}
|
||||||
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||||
@@ -17,55 +22,67 @@ jobs:
|
|||||||
policy:
|
policy:
|
||||||
name: policy
|
name: policy
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: node:24.21.0
|
container: node:24.22.0
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: env PATH=/opt/bun/bin:/usr/bin:/bin bash -e {0}
|
||||||
outputs:
|
outputs:
|
||||||
slug: ${{ steps.policy.outputs.slug }}
|
slug: ${{ steps.policy.outputs.slug }}
|
||||||
tag: ${{ steps.policy.outputs.tag }}
|
tag: ${{ steps.policy.outputs.tag }}
|
||||||
steps:
|
steps:
|
||||||
- uses: https://gitea.com/actions/checkout@v4
|
- uses: https://gitea.com/actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
- name: Install Bun
|
||||||
|
run: sh scripts/install-bun.sh
|
||||||
- name: Policy regression tests
|
- name: Policy regression tests
|
||||||
run: node scripts/ci-policy.test.mjs && node scripts/ci-image.test.mjs
|
run: bun scripts/ci-policy.test.mjs && bun scripts/ci-image.test.mjs
|
||||||
- name: Validate event and commit
|
- name: Validate event and commit
|
||||||
id: policy
|
id: policy
|
||||||
run: node scripts/ci-policy.mjs
|
run: bun scripts/ci-policy.mjs
|
||||||
|
|
||||||
validate:
|
validate:
|
||||||
name: validate
|
name: validate
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: node:24.21.0
|
container: node:24.22.0
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: env PATH=/opt/bun/bin:/usr/bin:/bin bash -e {0}
|
||||||
steps:
|
steps:
|
||||||
- uses: https://gitea.com/actions/checkout@v4
|
- uses: https://gitea.com/actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- run: npm install --global bun@1.4.2
|
- name: Install Bun
|
||||||
|
run: sh scripts/install-bun.sh
|
||||||
- run: bun install --frozen-lockfile
|
- run: bun install --frozen-lockfile
|
||||||
- run: bun run lint
|
- run: bun run lint
|
||||||
- run: bun run format:check
|
- run: bun run format:check
|
||||||
- run: bunx prettier --check 'scripts/ci-*.mjs' '.gitea/workflows/*.yml'
|
- run: bun run typecheck
|
||||||
- run: bunx tsc --noEmit -p tsconfig.app.json
|
|
||||||
- run: bunx tsc --noEmit -p tsconfig.spec.json
|
|
||||||
|
|
||||||
test:
|
test:
|
||||||
name: test
|
name: test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: node:24.21.0
|
container: node:24.22.0
|
||||||
env:
|
defaults:
|
||||||
NODE_OPTIONS: --max-old-space-size=2048
|
run:
|
||||||
|
shell: env PATH=/opt/bun/bin:/usr/bin:/bin bash -e {0}
|
||||||
steps:
|
steps:
|
||||||
- uses: https://gitea.com/actions/checkout@v4
|
- uses: https://gitea.com/actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- run: npm install --global bun@1.4.2
|
- name: Install Bun
|
||||||
|
run: sh scripts/install-bun.sh
|
||||||
- run: bun install --frozen-lockfile
|
- run: bun install --frozen-lockfile
|
||||||
- run: bun scripts/generate-langs.js
|
- run: bun scripts/generate-langs.js
|
||||||
- name: Functional coverage
|
- name: Functional coverage
|
||||||
run: bunx vitest run --coverage --maxWorkers=1 --exclude='src/app/benchmark/**'
|
run: bun test --config=bunfig.coverage.toml --isolate --path-ignore-patterns='**/benchmark/**'
|
||||||
- name: Coverage report
|
- name: Coverage report
|
||||||
if: ${{ always() }}
|
if: ${{ always() }}
|
||||||
uses: https://gitea.com/actions/upload-artifact@v3
|
run: bun scripts/coverage-reports.mjs
|
||||||
|
- name: Upload coverage
|
||||||
|
if: ${{ always() }}
|
||||||
|
uses: https://gitea.com/actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: coverage
|
name: coverage
|
||||||
path: coverage/
|
path: coverage/
|
||||||
@@ -75,31 +92,39 @@ jobs:
|
|||||||
benchmark:
|
benchmark:
|
||||||
name: benchmark
|
name: benchmark
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: node:24.21.0
|
container:
|
||||||
env:
|
image: node:24.22.0
|
||||||
NODE_OPTIONS: --max-old-space-size=2048
|
options: --memory=3g
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: env PATH=/opt/bun/bin:/usr/bin:/bin bash -e {0}
|
||||||
steps:
|
steps:
|
||||||
- uses: https://gitea.com/actions/checkout@v4
|
- uses: https://gitea.com/actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- run: npm install --global bun@1.4.2
|
- name: Install Bun
|
||||||
|
run: sh scripts/install-bun.sh
|
||||||
- run: bun install --frozen-lockfile
|
- run: bun install --frozen-lockfile
|
||||||
- run: bun scripts/generate-langs.js
|
- run: bun scripts/generate-langs.js
|
||||||
- name: Benchmark protocol with relative gate
|
- name: Benchmark protocol with relative gate
|
||||||
run: timeout 600s bunx vitest run src/app/benchmark/ --maxWorkers=1
|
run: timeout 600s bun test src/app/benchmark --timeout=600000
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: build
|
name: build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: node:24.21.0
|
container: node:24.22.0
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: env PATH=/opt/bun/bin:/usr/bin:/bin bash -e {0}
|
||||||
steps:
|
steps:
|
||||||
- uses: https://gitea.com/actions/checkout@v4
|
- uses: https://gitea.com/actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- run: npm install --global bun@1.4.2
|
- name: Install Bun
|
||||||
|
run: sh scripts/install-bun.sh
|
||||||
- run: bun install --frozen-lockfile
|
- run: bun install --frozen-lockfile
|
||||||
- run: bun run build -- --configuration production
|
- run: bun run build -- --configuration production
|
||||||
- uses: https://gitea.com/actions/upload-artifact@v3
|
- uses: https://gitea.com/actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: dist
|
name: dist
|
||||||
path: dist/
|
path: dist/
|
||||||
@@ -109,9 +134,9 @@ jobs:
|
|||||||
security:
|
security:
|
||||||
name: security
|
name: security
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: node:24.21.0
|
container: node:24.22.0
|
||||||
steps:
|
steps:
|
||||||
- uses: https://gitea.com/actions/checkout@v4
|
- uses: https://gitea.com/actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install scanners
|
- name: Install scanners
|
||||||
@@ -137,7 +162,7 @@ jobs:
|
|||||||
run: trivy fs --db-repository public.ecr.aws/aquasecurity/trivy-db:2 --format sarif --output trivy-results.sarif --skip-dirs node_modules,dist,coverage .
|
run: trivy fs --db-repository public.ecr.aws/aquasecurity/trivy-db:2 --format sarif --output trivy-results.sarif --skip-dirs node_modules,dist,coverage .
|
||||||
- name: Security reports
|
- name: Security reports
|
||||||
if: ${{ always() }}
|
if: ${{ always() }}
|
||||||
uses: https://gitea.com/actions/upload-artifact@v3
|
uses: https://gitea.com/actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: security
|
name: security
|
||||||
path: '*-results.sarif'
|
path: '*-results.sarif'
|
||||||
@@ -153,13 +178,13 @@ jobs:
|
|||||||
(github.event_name == 'pull_request' &&
|
(github.event_name == 'pull_request' &&
|
||||||
github.event.pull_request.head.repo.full_name == github.repository))
|
github.event.pull_request.head.repo.full_name == github.repository))
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: docker:29.8.2
|
container: docker:29.9.1
|
||||||
concurrency:
|
concurrency:
|
||||||
group: image-${{ needs.policy.outputs.tag != '' && 'stable' || needs.policy.outputs.slug }}
|
group: image-${{ needs.policy.outputs.tag != '' && 'stable' || needs.policy.outputs.slug }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
services:
|
services:
|
||||||
docker:
|
docker:
|
||||||
image: docker:29.8.2-dind
|
image: docker:29.9.1-dind
|
||||||
env:
|
env:
|
||||||
DOCKER_TLS_CERTDIR: ''
|
DOCKER_TLS_CERTDIR: ''
|
||||||
options: >-
|
options: >-
|
||||||
@@ -175,24 +200,28 @@ jobs:
|
|||||||
run:
|
run:
|
||||||
shell: sh
|
shell: sh
|
||||||
steps:
|
steps:
|
||||||
- name: Install checkout and policy runtimes
|
# Alpine's nodejs package is for the checkout action only (it shares /usr/bin
|
||||||
run: apk add --no-cache nodejs git
|
# with git); Bun needs libgcc and libstdc++ and the installer needs curl.
|
||||||
- uses: https://gitea.com/actions/checkout@v4
|
- name: Install checkout runtime and tools
|
||||||
|
run: apk add --no-cache nodejs git curl libgcc libstdc++
|
||||||
|
- uses: https://gitea.com/actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
- name: Install Bun
|
||||||
|
run: sh scripts/install-bun.sh
|
||||||
- name: Build branch images or promote a release
|
- name: Build branch images or promote a release
|
||||||
env:
|
env:
|
||||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
run: node scripts/ci-image.mjs
|
run: /opt/bun/bin/bun scripts/ci-image.mjs
|
||||||
|
|
||||||
required:
|
required:
|
||||||
name: required
|
name: required
|
||||||
if: ${{ always() }}
|
if: ${{ always() }}
|
||||||
needs: [policy, validate, test, benchmark, build, security, images]
|
needs: [policy, validate, test, benchmark, build, security, images]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: node:24.21.0
|
container: node:24.22.0
|
||||||
env:
|
env:
|
||||||
CHECK_RESULTS: ${{ needs.policy.result }} ${{ needs.validate.result }} ${{ needs.test.result }} ${{ needs.benchmark.result }} ${{ needs.build.result }} ${{ needs.security.result }}
|
CHECK_RESULTS: ${{ needs.policy.result }} ${{ needs.validate.result }} ${{ needs.test.result }} ${{ needs.benchmark.result }} ${{ needs.build.result }} ${{ needs.security.result }}
|
||||||
IMAGE_RESULT: ${{ needs.images.result }}
|
IMAGE_RESULT: ${{ needs.images.result }}
|
||||||
|
|||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"npm.packageManager": "bun"
|
||||||
|
}
|
||||||
+10
-28
@@ -1,48 +1,30 @@
|
|||||||
# Stage 1: Build the Angular application
|
# Stage 1: Build the Angular application and the Bun static server
|
||||||
FROM oven/bun:alpine AS builder
|
FROM oven/bun:1.4.3-alpine@sha256:629e17411f1f129dbec3af78d5af9c9f2a937435c80349437206c6b0b7422373 AS builder
|
||||||
|
|
||||||
# Install nodejs to support Angular prerendering worker threads
|
|
||||||
RUN apk add --no-cache nodejs
|
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
COPY package.json bun.lock ./
|
||||||
COPY package.json bun.lock* ./
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
RUN bun install
|
|
||||||
|
|
||||||
# Copy source code
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build the application for production
|
# bunfig.toml makes the Node-shebang Angular CLI run on Bun; the image has no Node
|
||||||
RUN bun run build
|
RUN bun run build
|
||||||
|
|
||||||
# Stage 2: Production image
|
# Stage 2: Production image
|
||||||
FROM oven/bun:alpine AS production
|
FROM oven/bun:1.4.3-alpine@sha256:629e17411f1f129dbec3af78d5af9c9f2a937435c80349437206c6b0b7422373 AS production
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
# The server is a self-contained Bun bundle that serves the browser build, so
|
||||||
COPY package.json bun.lock* ./
|
# the runtime has no dependencies to install.
|
||||||
|
|
||||||
# Install only production dependencies
|
|
||||||
RUN bun install --production
|
|
||||||
|
|
||||||
# Copy built application from builder stage
|
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
# Set environment to production
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
# Expose port (default Express port)
|
|
||||||
EXPOSE 4000
|
EXPOSE 4000
|
||||||
|
|
||||||
# Health check
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||||
CMD bun -e "require('http').get('http://localhost:4000', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
CMD bun -e "fetch('http://localhost:' + (process.env.PORT || 4000) + '/').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))"
|
||||||
|
|
||||||
# Start the application
|
CMD ["bun", "dist/vac-optimizer/server/server.js"]
|
||||||
CMD ["bun", "run", "dist/vac-optimizer/server/server.mjs"]
|
|
||||||
@@ -17,7 +17,7 @@ The planner answers these questions:
|
|||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
Host["Express production host<br/>static and client assets"] -. serves .-> UI
|
Host["Bun static server<br/>client assets"] -. serves .-> UI
|
||||||
|
|
||||||
subgraph Browser
|
subgraph Browser
|
||||||
UI["Angular standalone components"]
|
UI["Angular standalone components"]
|
||||||
@@ -42,7 +42,7 @@ flowchart LR
|
|||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
Leave calculation stays in the browser. The production Express process serves the generated Angular application; it does not participate in planning. Browser calculations run cooperatively in a dedicated worker. The resumable core is emitted as a separate worker/lazy chunk and retains a synchronous adapter for direct core callers. The original decision trace and historical analysis were removed in issue #76; immutable checkpoints and final state remain available for cancellation recovery and displayed-placement alternatives.
|
Leave calculation stays in the browser. The production Bun process serves the generated client-only Angular application; it does not participate in planning. Browser calculations run cooperatively in a dedicated worker. The resumable core is emitted as a separate worker/lazy chunk and retains a synchronous adapter for direct core callers. The original decision trace and historical analysis were removed in issue #76; immutable checkpoints and final state remain available for cancellation recovery and displayed-placement alternatives.
|
||||||
|
|
||||||
### Main execution path
|
### Main execution path
|
||||||
|
|
||||||
@@ -162,9 +162,9 @@ Incomplete outcomes explain the retained plan and unfinished phases; conflicts i
|
|||||||
- Web Workers for planner isolation
|
- Web Workers for planner isolation
|
||||||
- `date-holidays` for the French public calendar
|
- `date-holidays` for the French public calendar
|
||||||
- Bun for dependency management and project scripts
|
- Bun for dependency management and project scripts
|
||||||
- Vitest through the Angular test builder
|
- `bun test` with jsdom and Angular TestBed (JIT)
|
||||||
- ESLint and Prettier
|
- ESLint and Prettier, run by Bun, and `bun check` for standalone type checking
|
||||||
- Angular server build hosted by Express
|
- Client-only Angular build served by a small `Bun.serve` static server (`src/server.ts`)
|
||||||
- Gitea repository hosting and a multi-stage Docker image
|
- Gitea repository hosting and a multi-stage Docker image
|
||||||
|
|
||||||
The `/benchmark` route and planner core are lazy-loaded, so the normal application does not execute the benchmark or bundle the planner core into its main entry point.
|
The `/benchmark` route and planner core are lazy-loaded, so the normal application does not execute the benchmark or bundle the planner core into its main entry point.
|
||||||
@@ -198,9 +198,10 @@ CONTEXT.md # Canonical domain language and rules
|
|||||||
|
|
||||||
## Local development
|
## Local development
|
||||||
|
|
||||||
Prerequisite: [Bun](https://bun.sh/). Dependency versions are locked in `bun.lock`.
|
Prerequisite: [Bun](https://bun.sh/) 1.4.3, the exact version pinned by `packageManager` in `package.json` and used by CI and the Docker image. Node.js is not needed. Dependency versions are locked in `bun.lock`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
curl -fsSL https://bun.sh/install | bash -s "bun-v1.4.3"
|
||||||
bun install --frozen-lockfile
|
bun install --frozen-lockfile
|
||||||
bun start
|
bun start
|
||||||
```
|
```
|
||||||
@@ -210,21 +211,28 @@ Open <http://localhost:4200>. The development server reloads when source files c
|
|||||||
### Useful commands
|
### Useful commands
|
||||||
|
|
||||||
| Command | Purpose |
|
| Command | Purpose |
|
||||||
| ----------------------------------------- | ------------------------------------------------------------ |
|
| ------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `bun start` | Generate the i18n registry and start the development server. |
|
| `bun start` | Generate the i18n registry and start the development server. |
|
||||||
| `bun run test --watch=false` | Run the unit and integration suite once. |
|
| `bun run watch` | Rebuild the development bundle when source files change. |
|
||||||
| `bun run lint` | Run ESLint. |
|
| `bun run test` | Run the unit, integration and benchmark suites once. |
|
||||||
| `bun run format:check` | Check Prettier formatting. |
|
| `bun test --isolate --path-ignore-patterns='**/benchmark/**'` | Run the functional suite only. |
|
||||||
| `bunx tsc --noEmit -p tsconfig.app.json` | Type-check application code. |
|
| `bun test src/app/benchmark --timeout=600000` | Run the planning benchmark (no coverage). |
|
||||||
| `bunx tsc --noEmit -p tsconfig.spec.json` | Type-check tests. |
|
| `bun test --config=bunfig.coverage.toml --isolate --path-ignore-patterns='**/benchmark/**' && bun scripts/coverage-reports.mjs` | Functional coverage: text, `lcov.info`, HTML and Cobertura in `coverage/`. Line coverage only; frozen engine excluded. |
|
||||||
| `bun run build` | Create the optimized browser and server bundles in `dist/`. |
|
| `bun run lint` | Run ESLint (`bun run lint:fix` applies its fixes). |
|
||||||
| `bun run serve:ssr:vac-optimizer` | Serve a completed production build on port 4000. |
|
| `bun run format:check` | Check Prettier formatting of the application, the CI scripts and the workflow (`bun run format` writes it). |
|
||||||
|
| `bun run typecheck` | Type-check application and test code with `bun check`. It does not check Angular templates; only the build does. |
|
||||||
|
| `bun run build` | Create the optimized browser bundle and the static server bundle in `dist/vac-optimizer/`. |
|
||||||
|
| `bun run serve` | Serve a completed production build on `PORT` (default 4000). |
|
||||||
|
|
||||||
|
Project scripts run Node-shebang tools (`ng`, `eslint`, `prettier`) on Bun even when Node is installed: `bunfig.toml` sets `[run] bun = true`, which is the `--bun` flag for every `bun run`. Start them through the scripts above. A bare `bunx` or `bun x` uses Node when it is on `PATH`; for a one-off tool, use `bun --bun x <tool>`.
|
||||||
|
|
||||||
|
Angular compiles with TypeScript 6. `bun check` is Bun's TypeScript 7 checker for standalone type errors in application and test code. Neither replaces the other: only `bun run build` (Angular's compiler) checks templates, so a passing `typecheck` does not replace the production build. ESLint and Prettier stay until a stable Bun release ships its native linter and formatter ([bun-adoption](docs/bun-adoption.md#status)).
|
||||||
|
|
||||||
## Performance benchmark
|
## Performance benchmark
|
||||||
|
|
||||||
Build or start the application, then open `/benchmark`. The isolated harness covers a representative year, typed obligations, chained earned entitlements, a deliberate conflict, a loaded year, and two- and three-year horizons with dated annual balances. It reports preparation time, planning latency, planning-work time, total wall time through explanation delivery, UI timer delay, available main-thread heap measurements, outcome, last completed stage, placement count, and determinism.
|
Build or start the application, then open `/benchmark`. The isolated harness covers a representative year, typed obligations, chained earned entitlements, a deliberate conflict, a loaded year, and two- and three-year horizons with dated annual balances. It reports preparation time, planning latency, planning-work time, total wall time through explanation delivery, UI timer delay, available main-thread heap measurements, outcome, last completed stage, placement count, and determinism.
|
||||||
|
|
||||||
The release protocol, recorded desktop measurements, and hardware limitations are documented in [docs/benchmark/README.md](docs/benchmark/README.md). No reference-phone run or individual multi-year duration ceiling is established. CI requires the complete seven-scenario Node protocol to finish within 300 seconds; individual scenario timings remain diagnostic evidence.
|
The release protocol, recorded desktop measurements, and hardware limitations are documented in [docs/benchmark/README.md](docs/benchmark/README.md). No reference-phone run or individual multi-year duration ceiling is established. CI runs the complete seven-scenario protocol with `bun test` and requires each scenario to stay within its budget relative to a frozen-engine calibration, with a 600-second hang guard. Bun measurements do not establish browser or reference-phone speedups.
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
@@ -233,15 +241,19 @@ docker build -t vac-optimizer .
|
|||||||
docker run --rm -p 4000:4000 vac-optimizer
|
docker run --rm -p 4000:4000 vac-optimizer
|
||||||
```
|
```
|
||||||
|
|
||||||
The image builds the Angular application with Bun and Node, then runs the generated Express server with Bun. Set `PORT` to override the default port `4000`.
|
The image builds the Angular application and the server bundle with Bun alone (no Node), then runs `dist/vac-optimizer/server/server.js` with Bun. The bundle has no runtime dependencies, so the production stage only copies `dist/` and installs nothing. Set `PORT` to override the default port `4000`; the health check requests `/`.
|
||||||
|
|
||||||
|
The server answers `GET` and `HEAD` only (other methods get 405). It serves files from `dist/vac-optimizer/browser` with `Cache-Control: public, max-age=31536000` (content-hashed assets), and returns `index.html` with `Cache-Control: no-cache` for `/index.html` and for every extension-less path, so direct navigation to client routes such as `/benchmark` works. A missing path with a file extension is a 404 rather than HTML, malformed or escaping paths are a 400, and unexpected failures are logged and answered with a plain 500.
|
||||||
|
|
||||||
## Continuous integration
|
## Continuous integration
|
||||||
|
|
||||||
[`.gitea/workflows/ci.yml`](.gitea/workflows/ci.yml) defines Gitea Actions validation, functional coverage, production builds, security scans, and Docker image publication. [Issue #268](https://gitea.nessar.fr/nessar/vac-optimizer/issues/268) tracks activation. Actions is enabled with owner approval; publisher credentials and live workflow validation are still pending. The [activation guide](docs/gitea-actions.md) records runner, credential, permission, and merge-gate requirements.
|
[`.gitea/workflows/ci.yml`](.gitea/workflows/ci.yml) defines Gitea Actions validation, functional coverage, production builds, security scans, and Docker image publication. [Issue #268](https://gitea.nessar.fr/nessar/vac-optimizer/issues/268) tracks activation. Actions is enabled with owner approval; publisher credentials and live workflow validation are still pending. The [activation guide](docs/gitea-actions.md) records runner, credential, permission, and merge-gate requirements.
|
||||||
|
|
||||||
The workflow runs on branch/tag pushes, PR opening/reopening/synchronization/edits, and manual dispatch. Manual runs validate without publishing images. Required jobs cover policy regression tests, GitFlow and Conventional Commits, ESLint, Prettier, application/test type checks, Vitest coverage, the production build, Semgrep SAST, and Trivy dependency/secret scans. Coverage, build output, and SARIF reports are retained for seven days.
|
The workflow runs on branch/tag pushes, PR opening/reopening/synchronization/edits, and manual dispatch. Manual runs validate without publishing images. Required jobs cover policy regression tests, GitFlow and Conventional Commits, ESLint, Prettier, `bun check` type checks, Bun coverage, the production build, Semgrep SAST, and Trivy dependency/secret scans. Coverage, build output, and SARIF reports are retained for seven days.
|
||||||
|
|
||||||
Functional tests use one worker and a 2 GiB Node heap. The separate required benchmark job runs without coverage and keeps its five-minute protocol timeout, one warm-up and two measured repetitions of all seven scenarios. [#78](https://gitea.nessar.fr/nessar/vac-optimizer/issues/78) restores this gate after a complete local pass in 298.6 seconds; see [the measurements and hardware limits](docs/benchmark/issue-78.md). Benchmark success is required for image publication and the aggregate `required` job. The narrow local timing margin still needs confirmation on the CI runner.
|
Jobs that run project commands install Bun 1.4.3 with `scripts/install-bun.sh` (a checksum-verified download, no npm) and run their steps with a `PATH` that holds Bun but no Node. The job containers keep Node only for Gitea's checkout and artifact actions, which are JavaScript actions the runner executes itself; see the [activation guide](docs/gitea-actions.md#runners-and-permissions).
|
||||||
|
|
||||||
|
Functional tests run with `bun test --isolate`; coverage is line-level only (Bun reports no branch or function data). The separate required benchmark job runs without coverage in a container limited to 3 GiB (JavaScriptCore has no heap-size flag, so this replaces the former 2 GiB Node heap cap) and keeps its 600-second hang guard, one warm-up and two measured repetitions of all seven scenarios. [#78](https://gitea.nessar.fr/nessar/vac-optimizer/issues/78) restores this gate after a complete local pass in 298.6 seconds; see [the measurements and hardware limits](docs/benchmark/issue-78.md). Benchmark success is required for image publication and the aggregate `required` job. The narrow local timing margin still needs confirmation on the CI runner.
|
||||||
|
|
||||||
The `required` job aggregates successful validation and expected image publication, accepting the image job's intentional skip for fork PRs and manual runs. Image jobs require successful validation and are limited to pushes and same-repository PRs. Registry credentials are confined to the publishing step.
|
The `required` job aggregates successful validation and expected image publication, accepting the image job's intentional skip for fork PRs and manual runs. Image jobs require successful validation and are limited to pushes and same-repository PRs. Registry credentials are confined to the publishing step.
|
||||||
|
|
||||||
@@ -278,7 +290,7 @@ The Gitea repository policy requires `develop` as the default branch and **Merge
|
|||||||
|
|
||||||
Only `release/*` and `hotfix/*` PRs may target `main`; working PRs target `develop`. CI validates these rules and checks working-branch rebases against the fetched integration head. If `develop` advances after validation, rebase and rerun the PR checks before merging.
|
Only `release/*` and `hotfix/*` PRs may target `main`; working PRs target `develop`. CI validates these rules and checks working-branch rebases against the fetched integration head. If `develop` advances after validation, rebase and rerun the PR checks before merging.
|
||||||
|
|
||||||
Run `node scripts/ci-policy.test.mjs` and `node scripts/ci-image.test.mjs` locally to verify GitFlow event handling and image publication without writing to a registry. The adapter reads Gitea webhook data through the runner's `GITHUB_*` compatibility variables. Local checks do not satisfy the migration gate.
|
Run `bun scripts/ci-policy.test.mjs` and `bun scripts/ci-image.test.mjs` locally to verify GitFlow event handling and image publication without writing to a registry. The adapter reads Gitea webhook data through the runner's `GITHUB_*` compatibility variables. Local checks do not satisfy the migration gate.
|
||||||
|
|
||||||
## Domain and design references
|
## Domain and design references
|
||||||
|
|
||||||
|
|||||||
@@ -29,12 +29,6 @@
|
|||||||
"styles": [
|
"styles": [
|
||||||
"src/styles.scss"
|
"src/styles.scss"
|
||||||
],
|
],
|
||||||
"server": "src/main.server.ts",
|
|
||||||
"outputMode": "server",
|
|
||||||
"ssr": {
|
|
||||||
"entry": "src/server.ts"
|
|
||||||
},
|
|
||||||
"prerender": false,
|
|
||||||
"allowedCommonJsDependencies": [
|
"allowedCommonJsDependencies": [
|
||||||
"deepmerge",
|
"deepmerge",
|
||||||
"moment-timezone",
|
"moment-timezone",
|
||||||
@@ -85,9 +79,6 @@
|
|||||||
},
|
},
|
||||||
"extract-i18n": {
|
"extract-i18n": {
|
||||||
"builder": "@angular/build:extract-i18n"
|
"builder": "@angular/build:extract-i18n"
|
||||||
},
|
|
||||||
"test": {
|
|
||||||
"builder": "@analogjs/vitest-angular:test"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[test]
|
||||||
|
preload = ["./src/test-setup.ts"]
|
||||||
|
root = "src"
|
||||||
|
coverage = true
|
||||||
|
coverageReporter = ["text", "lcov"]
|
||||||
|
coverageDir = "coverage"
|
||||||
|
coverageSkipTestFiles = true
|
||||||
|
coveragePathIgnorePatterns = ["**/frozen-engine/**", "**/testing/**", "**/test-setup.ts"]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[run]
|
||||||
|
# Node-shebang tools (ng, eslint, prettier) run on Bun even when Node is on PATH.
|
||||||
|
bun = true
|
||||||
|
|
||||||
|
[test]
|
||||||
|
preload = ["./src/test-setup.ts"]
|
||||||
|
root = "src"
|
||||||
+1
-1
@@ -17,7 +17,7 @@ services:
|
|||||||
'CMD',
|
'CMD',
|
||||||
'bun',
|
'bun',
|
||||||
'-e',
|
'-e',
|
||||||
"require('http').get('http://localhost:4000', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})",
|
"fetch('http://localhost:' + (process.env.PORT || 4000) + '/').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))",
|
||||||
]
|
]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ services:
|
|||||||
'CMD',
|
'CMD',
|
||||||
'bun',
|
'bun',
|
||||||
'-e',
|
'-e',
|
||||||
"require('http').get('http://localhost:4000', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})",
|
"fetch('http://localhost:' + (process.env.PORT || 4000) + '/').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))",
|
||||||
]
|
]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
status: proposed
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bun Runtime and Native Tooling
|
||||||
|
|
||||||
|
VacOptimizer will use Bun as the runtime for project-owned JavaScript in
|
||||||
|
development, CI, and production, adopting its native test runner, linter,
|
||||||
|
formatter, type checker, and HTTP server even without measured performance gains.
|
||||||
|
This deliberately accepts Bun-specific tooling and migration work while retaining
|
||||||
|
Angular's standard compiler and build pipeline, including TypeScript 6 for
|
||||||
|
Angular compilation alongside `bun check` for standalone TypeScript checking.
|
||||||
|
|
||||||
|
Verification on stable Bun 1.4.3 narrowed this: ESLint and Prettier stay,
|
||||||
|
run by Bun, until a stable release ships the native linter and formatter;
|
||||||
|
Angular builds a client-only bundle, because its server build relies on Node
|
||||||
|
loader hooks Bun does not apply, and a small `Bun.serve` server delivers it,
|
||||||
|
giving up `@angular/ssr`; coverage is line-level, converted from lcov to the
|
||||||
|
existing HTML and Cobertura reports; and the benchmark's heap cap becomes a
|
||||||
|
container memory limit.
|
||||||
|
|
||||||
|
Existing behavior, tests, lint rules, coverage reporting, and benchmark budgets
|
||||||
|
must be preserved; incompatibilities must be fixed or reported as blockers before
|
||||||
|
declaring completion, and a Node.js fallback does not meet the runtime requirement.
|
||||||
|
The [development spec](../bun-adoption.md) records the agreed boundaries,
|
||||||
|
acceptance criteria, and remaining verification.
|
||||||
@@ -68,6 +68,18 @@ the loaded year, while every completed scenario was within budget. The CI
|
|||||||
only hang guards and do not judge speed; #286 lowers them once the speed
|
only hang guards and do not judge speed; #286 lowers them once the speed
|
||||||
target is met.
|
target is met.
|
||||||
|
|
||||||
|
The protocol now runs on Bun's test runner, outside coverage:
|
||||||
|
`timeout 600s bun test src/app/benchmark --timeout=600000`. JavaScriptCore has
|
||||||
|
no equivalent of Node's `--max-old-space-size`, so the CI benchmark job enforces
|
||||||
|
memory with a 3 GiB container limit instead of the former 2 GiB heap cap.
|
||||||
|
Scenarios, calibration, warm-up, repetitions, determinism checks and
|
||||||
|
multipliers are unchanged. One local run (Bun 1.4.3, Node unavailable) took
|
||||||
|
268 seconds and passed every scenario; the frozen-engine calibration was
|
||||||
|
3.79 s and the ratios were 0.14 (representative year), 1.43 (typed
|
||||||
|
obligations), 0.01 (chained earned entitlements), 0.00 (deliberate conflict),
|
||||||
|
17.50 (loaded year), 0.64 (two years) and 2.18 (three years). These are Bun
|
||||||
|
measurements; they do not establish browser or reference-phone speedups.
|
||||||
|
|
||||||
The post-removal functional integration checks, browser QA and unchanged-input
|
The post-removal functional integration checks, browser QA and unchanged-input
|
||||||
handoff for #78 are recorded in [the #77 verification report](../trace-free-planning-verification.md).
|
handoff for #78 are recorded in [the #77 verification report](../trace-free-planning-verification.md).
|
||||||
|
|
||||||
@@ -136,12 +148,12 @@ bun build src/app/services/leave-planner.core.ts --target=node --external date-h
|
|||||||
A failure prints the seed and the full request as JSON. To run only that seed:
|
A failure prints the seed and the full request as JSON. To run only that seed:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
FROZEN_ENGINE_SEED=<seed> bunx vitest run src/app/services/frozen-engine.spec.ts
|
FROZEN_ENGINE_SEED=<seed> bun test --isolate src/app/services/frozen-engine.spec.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
The check runs in the functional suite. On the development laptop it takes
|
The check runs in the functional suite. On the development laptop with Bun
|
||||||
about 32 seconds without coverage and 38 seconds with coverage
|
1.4.3 it takes about 54 seconds with or without coverage
|
||||||
(`NODE_OPTIONS=--max-old-space-size=2048 bunx vitest run src/app/services/frozen-engine.spec.ts --maxWorkers=1`).
|
(`bun test --config=bunfig.coverage.toml --isolate src/app/services/frozen-engine.spec.ts`; coverage is line-level only).
|
||||||
A flipped objective direction in `compareObjectiveValues` made 26 of its tests
|
A flipped objective direction in `compareObjectiveValues` made 26 of its tests
|
||||||
fail, starting at seed 280002; the change was reverted.
|
fail, starting at seed 280002; the change was reverted.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
# Bun Runtime and Native Tooling — Development Spec
|
||||||
|
|
||||||
|
Status: the agreed decisions are amended by the verification results in #289,
|
||||||
|
recorded below. ADR 0009 remains proposed until the migration passes its final
|
||||||
|
gate.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Implementation status after #290 and the local parts of #293. "Verified locally"
|
||||||
|
means a command ran with Node unavailable (or a tripwire `node` that logs every
|
||||||
|
start); it is not a result from the live Gitea workflow.
|
||||||
|
|
||||||
|
**Done, verified locally:**
|
||||||
|
|
||||||
|
- **One pinned release.** Bun 1.4.3 is pinned by `packageManager`, the
|
||||||
|
checksum-verified `scripts/install-bun.sh` that every CI job running project
|
||||||
|
commands uses (no npm), the
|
||||||
|
`@types/bun` version and both Docker stages. The installer refuses a
|
||||||
|
`package.json` that names another version. Frozen installs leave `bun.lock`
|
||||||
|
unchanged.
|
||||||
|
- **Node-shebang tools on Bun.** `bunfig.toml` sets `[run] bun = true`, so
|
||||||
|
`start`, `build`, `watch`, `lint`, `lint:fix`, `format` and `format:check` run
|
||||||
|
`ng`, `eslint` and `prettier` on Bun even when Node is on `PATH`. CI also runs
|
||||||
|
project steps with a `PATH` that has Bun and no Node.
|
||||||
|
- **Standalone type checking.** `bun run typecheck` runs `bun check` on the
|
||||||
|
application and test projects. The production build remains the only check of
|
||||||
|
Angular templates.
|
||||||
|
- **Tests, coverage, benchmark, server, container** (#291, #292), and the policy
|
||||||
|
and image-publisher scripts with their regression tests, under Bun. The fake
|
||||||
|
`docker` fixture uses a `bun` shebang.
|
||||||
|
|
||||||
|
**Still open:**
|
||||||
|
|
||||||
|
- **Native lint and format (#290, user story 5).** Replace ESLint and Prettier in
|
||||||
|
one change when a stable Bun ships `bun lint` and `bun fmt`; today they exist
|
||||||
|
only in canary builds. Until then user story 5 and the matching #290 criteria
|
||||||
|
stay open.
|
||||||
|
- **Live Gitea validation (#293).** The custom job `shell` template, the Bun
|
||||||
|
installer, JavaScript actions beside Node-free project steps, the benchmark's
|
||||||
|
timing margin and the required checks on the final PR revision have not run on
|
||||||
|
the live runner. The `images` job still has Alpine's `nodejs` on `PATH` for the
|
||||||
|
checkout action, which the project command does not use. These and the Gitea
|
||||||
|
activation steps remain unverified until a live pull-request run.
|
||||||
|
- **ADR 0009** stays `proposed` until the final gate.
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
The maintainer wants VacOptimizer to use Bun as its actual JavaScript runtime and
|
||||||
|
adopt its native tooling wherever agreed, even without measurable performance
|
||||||
|
gains. Today Bun installs dependencies, generates the language registry, and runs
|
||||||
|
the deployed server, but development commands can launch Node-based CLIs, CI uses
|
||||||
|
Node containers, and the Docker build explicitly installs Node.
|
||||||
|
|
||||||
|
The toolchain is also inconsistent: the local Bun installation is 1.3.14, CI pins
|
||||||
|
1.4.2, and Docker uses a floating Bun image. Merely prefixing commands with Bun
|
||||||
|
does not establish which runtime executes their JavaScript.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Use Bun for project-owned JavaScript in development, CI, and production. Adopt
|
||||||
|
Bun's native test runner, linter, formatter, standalone type checker, and HTTP
|
||||||
|
server. Retain Angular and its standard compiler and build pipeline, running the
|
||||||
|
JavaScript tooling under Bun.
|
||||||
|
|
||||||
|
Preserve the application's existing behavior and verification standards. The
|
||||||
|
planner remains client-side: planning horizons, planning rules, leave placements,
|
||||||
|
planning outcomes, plan statistics, and plan explanations retain their current
|
||||||
|
meaning. Browser code and Web Workers continue to run in the user's browser.
|
||||||
|
|
||||||
|
## User Stories
|
||||||
|
|
||||||
|
The following acceptance criteria define completion:
|
||||||
|
|
||||||
|
1. **Actual Bun execution.** Development serving and watch mode, production
|
||||||
|
builds, language generation, repository CI scripts, tests, and the production
|
||||||
|
server execute their project-owned JavaScript under Bun, including spawned
|
||||||
|
tools and test fixtures. A Node.js fallback does not satisfy this criterion.
|
||||||
|
2. **Reproducible setup.** Development instructions, CI, and both Docker stages
|
||||||
|
specify the same exact, verified Bun version. Frozen dependency installation
|
||||||
|
succeeds from a clean checkout without changing the lockfile, and the
|
||||||
|
production image installs only production dependencies.
|
||||||
|
3. **Native tests with preserved behavior.** All existing functional and Angular
|
||||||
|
component tests execute under `bun test`, preserving assertions, parameterized
|
||||||
|
cases, clock control, global mocks, cleanup, isolation, and real worker
|
||||||
|
integration. Tests cannot be dropped or weakened to make the migration pass.
|
||||||
|
4. **Preserved coverage.** Functional tests retain coverage reporting and CI
|
||||||
|
artifacts, including useful output when tests fail. Preserve the current
|
||||||
|
production-code coverage scope and exclusion of the frozen engine baseline;
|
||||||
|
report unsupported coverage capabilities or report formats as compatibility
|
||||||
|
gaps rather than silently removing them.
|
||||||
|
5. **Native linting and formatting.** Bun's native linter and formatter replace
|
||||||
|
ESLint and Prettier, preserving the current rule severities, type-aware checks,
|
||||||
|
unused-name exceptions, formatting options, and Angular-template formatting.
|
||||||
|
CI continues to reject violations. Checks cover the same application,
|
||||||
|
repository-script, and workflow content as before.
|
||||||
|
6. **Type checking alongside Angular.** `bun check` checks application and test
|
||||||
|
TypeScript. Angular's compiler retains its supported TypeScript 6 dependency
|
||||||
|
and continues to check templates during builds. Type errors still fail the
|
||||||
|
appropriate checks; successful standalone checking does not replace a
|
||||||
|
successful Angular build.
|
||||||
|
7. **Native HTTP serving.** `Bun.serve` replaces Express, preserving Angular's
|
||||||
|
request-handler integration, configured rendering modes, static assets and
|
||||||
|
cache behavior, configurable port, error handling, and container health check.
|
||||||
|
Development and production routes continue to work, including direct visits
|
||||||
|
to the benchmark page. The current catch-all route uses client rendering;
|
||||||
|
this migration does not enable server rendering for that route.
|
||||||
|
8. **Existing CI guarantees.** Policy and image-publication regression checks,
|
||||||
|
security scans, production builds, artifact retention, and required checks
|
||||||
|
continue to work. The separate required seven-scenario benchmark preserves
|
||||||
|
its calibration protocol, warm-up, measured repetitions, determinism checks,
|
||||||
|
timeouts, and performance budgets. A runtime change cannot justify weakening
|
||||||
|
these gates.
|
||||||
|
9. **Honest completion.** Fix incompatible required tooling before declaring the
|
||||||
|
migration complete. If a fix proves infeasible, report the failing behavior,
|
||||||
|
evidence, and remaining blocker. Local success must be distinguished from
|
||||||
|
successful live Gitea checks.
|
||||||
|
|
||||||
|
## Implementation Decisions
|
||||||
|
|
||||||
|
- **Agreed: runtime and native tools.** Bun adoption is a requirement independent
|
||||||
|
of speed gains. Use its native test runner, linter, formatter, standalone type
|
||||||
|
checker, and HTTP server. Compatible Node API imports may remain when executed
|
||||||
|
by Bun; rewriting every import is not required to satisfy the runtime goal.
|
||||||
|
- **Agreed: Angular boundary.** Keep Angular's standard compiler, bundler,
|
||||||
|
development server, and framework build support. Preserve the existing
|
||||||
|
rendering configuration and browser-based planner, consistent with ADR 0002.
|
||||||
|
- **Agreed: TypeScript coexistence.** Keep TypeScript 6 for Angular compilation
|
||||||
|
and use Bun's TypeScript 7-based checker for standalone checking. An Angular
|
||||||
|
upgrade is not required for this arrangement.
|
||||||
|
- **Agreed: tests and server integration.** Adapt the Angular test compilation
|
||||||
|
and setup, mocks, DOM environment, worker harnesses, and subprocess fixtures to
|
||||||
|
the chosen runtime and runner. Use Angular's web request API with Bun's HTTP
|
||||||
|
server, preserving its development/build handler contract and static-file
|
||||||
|
protections.
|
||||||
|
- **Migration reach.** Update package commands, test configuration and typings,
|
||||||
|
generated executable fixtures, server entry and exports, container build and
|
||||||
|
runtime stages, project-owned CI invocations, and development documentation.
|
||||||
|
Remove superseded dependencies and configuration after checking their callers.
|
||||||
|
- **Proposed implementation order.** Verify the Bun release, an Angular
|
||||||
|
production build, representative Angular component tests, and native
|
||||||
|
lint/format/type checks first. Extend that verified setup to the full suite,
|
||||||
|
server, containers, and CI before removing the replaced tools.
|
||||||
|
- **Proposed version.** Bun 1.4.3 is the initial candidate based on the researched
|
||||||
|
native-tool documentation. Select the exact version only after verifying the
|
||||||
|
required capabilities together.
|
||||||
|
|
||||||
|
## Verification Results and Decisions (#289)
|
||||||
|
|
||||||
|
Probes ran on 2026-10-17 against the stable Bun `1.4.3` release (`1.4.3+c6da4a4d3`)
|
||||||
|
from a clean checkout. "Node unavailable" means a `PATH` holding only a `bun`
|
||||||
|
executable plus `/usr/bin:/bin`, with `command -v node` failing.
|
||||||
|
|
||||||
|
**Verified:**
|
||||||
|
|
||||||
|
- **Install.** `bun install --frozen-lockfile` succeeds and leaves `bun.lock`
|
||||||
|
unchanged.
|
||||||
|
- **Runtime.** Package binaries with a `#!/usr/bin/env node` shebang (`ng`,
|
||||||
|
`eslint`, `prettier`, `tsc`) run on Bun only with `--bun`. Without it they use
|
||||||
|
Node when Node is on `PATH`, so every such command must pass `--bun`
|
||||||
|
explicitly. #290 sets it once for every `bun run` script with `[run] bun = true`
|
||||||
|
in `bunfig.toml`. Spawned build children are native executables (esbuild,
|
||||||
|
dart-sass); Angular's workers are threads of the Bun process.
|
||||||
|
- **Development server.** `bun --bun x ng serve` serves the application and
|
||||||
|
direct navigation to `/benchmark` with Node unavailable.
|
||||||
|
- **Type checking.** `bun check -p tsconfig.app.json` and
|
||||||
|
`-p tsconfig.spec.json` match `tsc` 6 on the clean tree and catch injected
|
||||||
|
application and test type errors. Only the Angular build catches template
|
||||||
|
errors, as expected.
|
||||||
|
- **Tests.** `bun test` runs the whole functional suite with a preload that
|
||||||
|
compiles components through Angular's JIT transform with inlined templates,
|
||||||
|
sets up jsdom, TestBed and zoneless change detection, plus a small shim for
|
||||||
|
the Vitest APIs Bun lacks (`stubGlobal`, `unstubAllGlobals`, `waitFor`,
|
||||||
|
`advanceTimersByTimeAsync`, `expect.poll`, `toHaveBeenCalledExactlyOnceWith`,
|
||||||
|
`setSystemTime` ordering and Date-only fake timers). Real-worker specs work
|
||||||
|
with Node unavailable. `--isolate` or `--parallel` is required for per-file
|
||||||
|
isolation. No spec uses snapshots.
|
||||||
|
- **Server.** `Bun.serve` passes route, asset, cache-header, missing-file and
|
||||||
|
path-traversal checks.
|
||||||
|
- **CI scripts.** The policy regression script passes under Bun. The image
|
||||||
|
regression script needs its fake `docker` shebang changed from `node` to
|
||||||
|
`bun`.
|
||||||
|
|
||||||
|
**Decisions taken after the probes:**
|
||||||
|
|
||||||
|
- **Linter and formatter.** Native `bun lint` and `bun fmt` exist only in canary
|
||||||
|
builds (`1.4.4-canary.1`), not in stable `1.4.3`. Pin stable `1.4.3`
|
||||||
|
everywhere and keep ESLint and Prettier, executed by Bun, until a stable
|
||||||
|
release ships them. Then replace them in one change. Until then, user story 5
|
||||||
|
stays open.
|
||||||
|
- **Client-only Angular build and a Bun static server.** Angular's production
|
||||||
|
build fails under Bun whenever a server or SSR entry is configured: route
|
||||||
|
extraction loads the server bundle through Node `module.register` loader
|
||||||
|
hooks, which Bun accepts but never applies. Every route is client-rendered
|
||||||
|
today, so the Angular build drops its server and SSR entries and a small
|
||||||
|
`Bun.serve` server delivers static assets with the same cache behavior and
|
||||||
|
falls back to `index.html`. This replaces the agreed Angular web request
|
||||||
|
handler integration and gives up `@angular/ssr`. Adding server rendering later
|
||||||
|
requires revisiting this decision.
|
||||||
|
- **Coverage.** `bun test` reports text and lcov with line data only. HTML and
|
||||||
|
Cobertura reports are generated from the lcov with the istanbul report
|
||||||
|
libraries. Branch and function coverage are no longer reported; this
|
||||||
|
reduction is accepted.
|
||||||
|
- **Benchmark memory guard.** JavaScriptCore has no `--max-old-space-size`
|
||||||
|
equivalent. The CI benchmark job enforces the limit with a container memory
|
||||||
|
limit instead of a heap-only cap.
|
||||||
|
|
||||||
|
**Platform constraints, not project commands:**
|
||||||
|
|
||||||
|
- **Gitea actions.** `actions/checkout@v4` (`node20`) and
|
||||||
|
`actions/upload-artifact@v3` (`node16`) are JavaScript actions, so the job
|
||||||
|
container must still provide `node` for the runner. Project commands must not
|
||||||
|
use it.
|
||||||
|
- **Allowed hosts.** Found while probing: the current Express server answers 400
|
||||||
|
unless `NG_ALLOWED_HOSTS` is set, and the compose files only set `PORT`. The
|
||||||
|
static Bun server removes this dependency.
|
||||||
|
|
||||||
|
## Testing Decisions
|
||||||
|
|
||||||
|
- Test observable behavior and failure outcomes, rather than configuration text
|
||||||
|
or internal adapter shapes. Reuse the existing behavior-level tests and change
|
||||||
|
harnesses only where the runtime or runner requires it.
|
||||||
|
- **Runtime and installation:** exercise clean frozen installs, development and
|
||||||
|
production commands, and project-owned subprocesses with Node unavailable to
|
||||||
|
those commands. Verify actual execution by Bun, rather than relying on a
|
||||||
|
command's name. Keep any CI-platform Node requirement separate from this check.
|
||||||
|
- **Angular and planning:** use the existing TestBed-based component and service
|
||||||
|
suites as prior art for compilation and DOM behavior. Retain the planning
|
||||||
|
integration, property-based, migration, date-sensitive, and cancellation tests
|
||||||
|
to check planning outcomes, saved inputs, and plan explanations.
|
||||||
|
- **Workers and subprocesses:** preserve the existing real-worker tests for
|
||||||
|
submitted-configuration isolation, outcome and explanation delivery, and worker
|
||||||
|
cleanup. Run policy and publisher regression scripts with Bun, keeping their
|
||||||
|
temporary Git repositories and fake Docker executable so verification remains
|
||||||
|
independent of real registry writes.
|
||||||
|
- **Lint, format, and types:** compare the current checks with the native tools.
|
||||||
|
Use small representative invalid inputs to establish that unused variables,
|
||||||
|
deprecated APIs, formatting violations, application/test type errors, and
|
||||||
|
Angular template errors still fail the intended command. Verify formatting
|
||||||
|
options and representative Angular templates without an unrelated reformat.
|
||||||
|
- **HTTP and containers:** add focused HTTP behavior checks around the native
|
||||||
|
server, since the explored test suite provides no equivalent server coverage.
|
||||||
|
Exercise configured routes, direct navigation, static JavaScript/CSS assets,
|
||||||
|
cache and content-type headers, missing assets, error responses, and traversal
|
||||||
|
protection. Smoke-test the production container and its health check.
|
||||||
|
- **Coverage and benchmark:** verify functional coverage artifacts, including
|
||||||
|
failure-path uploads, and run the existing benchmark outside coverage. Keep the
|
||||||
|
frozen-engine calibration and current budget assertions. Record Bun results as
|
||||||
|
new measurements rather than claiming browser or phone speedups from them.
|
||||||
|
- **CI:** use the existing policy/publisher regression scenarios as prior art for
|
||||||
|
push, pull-request, fork, manual, and release-event behavior. Obtain successful
|
||||||
|
required checks on the final PR revision; live publication and activation
|
||||||
|
validation remain subject to the repository's existing Gitea process.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Replacing Angular's build pipeline with Bun's bundler, rewriting the framework,
|
||||||
|
or bypassing Angular's TypeScript support checks.
|
||||||
|
- Moving leave planning to the server, changing planning rules or objectives,
|
||||||
|
altering saved data, or adding accounts, synchronization, or backend services.
|
||||||
|
- Changing configured rendering modes or treating Bun as the browser runtime.
|
||||||
|
- Dropping tests, weakening lint rules or benchmark budgets, or silently using
|
||||||
|
Node to complete otherwise incompatible project commands.
|
||||||
|
- Replacing security scanners, changing registry publication policy, provisioning
|
||||||
|
CI credentials, modifying branch protections or activation settings, creating
|
||||||
|
releases, publishing this spec, or deploying the application.
|
||||||
|
- Rewriting compatible Node API imports solely because their names mention Node.
|
||||||
|
|
||||||
|
## Further Notes
|
||||||
|
|
||||||
|
This spec records the decisions made in the interview. The architecture record
|
||||||
|
remains proposed; the Status section lists what is implemented.
|
||||||
|
The glossary receives no new terms because the choices concern general tooling,
|
||||||
|
rather than new leave-planning concepts.
|
||||||
|
|
||||||
|
Remaining verification (everything else is recorded as verified under Status):
|
||||||
|
|
||||||
|
- Native lint and format wait for a stable Bun release that ships `bun lint` and
|
||||||
|
`bun fmt` (#290).
|
||||||
|
- Live Gitea validation (#293): the custom job `shell` template, the Bun download
|
||||||
|
in each job, the `images` job with publisher credentials, and the benchmark's
|
||||||
|
timing margin on the runners.
|
||||||
|
- The `images` job keeps Alpine's `nodejs` on `PATH` because the checkout action
|
||||||
|
needs it (a runner requirement); project commands do not use it and no Node
|
||||||
|
fallback for them is agreed.
|
||||||
|
- `src/test-setup.ts` imports Analog's internal `jitTransform` by path; replace it
|
||||||
|
when Angular or Analog exports a JIT transform.
|
||||||
|
|
||||||
|
Research checked on 2026-10-17:
|
||||||
|
|
||||||
|
- [Bun runtime selection](https://bun.sh/docs/runtime#--bun) explains why a Bun
|
||||||
|
command can still launch a Node executable.
|
||||||
|
- [Bun 1.4.3](https://bun.sh/blog/bun-v1.4.3),
|
||||||
|
[native linting](https://bun.sh/docs/lint),
|
||||||
|
[formatting](https://bun.sh/docs/format), and
|
||||||
|
[type checking](https://bun.sh/docs/runtime/check) describe the candidate tools.
|
||||||
|
- [Angular build guidance](https://angular.dev/ecosystem/custom-build-pipeline)
|
||||||
|
recommends retaining its standard CLI pipeline.
|
||||||
|
- [Angular's web request engine](https://angular.dev/api/ssr/AngularAppEngine) and
|
||||||
|
[Bun's HTTP server](https://bun.sh/docs/runtime/http/server) describe the original
|
||||||
|
server integration plan, replaced by the `Bun.serve` static server above.
|
||||||
|
- [Angular version compatibility](https://angular.dev/reference/versions) and
|
||||||
|
[compiler roadmap](https://angular.dev/roadmap) establish the current Angular
|
||||||
|
compiler boundary. [Microsoft's coexistence guidance](https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/#typescript-and-embedded-languages)
|
||||||
|
describes standalone TypeScript 7 checks alongside Angular-specific tooling.
|
||||||
+48
-12
@@ -17,15 +17,15 @@ publishing images. There is no schedule or deployment job in the preserved
|
|||||||
configuration; the Compose files remain manual deployment options.
|
configuration; the Compose files remain manual deployment options.
|
||||||
|
|
||||||
| Job | Behavior |
|
| Job | Behavior |
|
||||||
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `policy` | Tests the policy and publisher, then validates the checked-out commit, GitFlow, PR title, commits, and stable release version. |
|
| `policy` | Tests the policy and publisher with Bun, then validates the checked-out commit, GitFlow, PR title, commits, and stable release version. |
|
||||||
| `validate` | Runs lint, formatting, and application/test type checks. |
|
| `validate` | Runs ESLint, Prettier (application, `scripts/ci-*.mjs` and workflow files) and `bun check` type checks of application and test code, all on Bun with the frozen lockfile. |
|
||||||
| `test` | Runs functional coverage with one worker and a 2 GiB Node heap; uploads coverage for seven days even on failure. |
|
| `test` | Runs functional coverage with `bun test --isolate` (line coverage only), converts it to HTML and Cobertura, and uploads coverage for seven days even on failure. |
|
||||||
| `benchmark` | Required seven-scenario protocol (one warm-up, two measured repetitions) outside coverage with one worker and a 2 GiB Node heap. It first times the frozen engine on `two years` (best of three) and fails any scenario whose mean planning time exceeds its multiplier times that calibration; the 600-second timeout is only a hang guard. |
|
| `benchmark` | Required seven-scenario protocol (one warm-up, two measured repetitions) outside coverage in a container limited to 3 GiB. It first times the frozen engine on `two years` (best of three) and fails any scenario whose mean planning time exceeds its multiplier times that calibration; the 600-second timeout is only a hang guard. |
|
||||||
| `build` | Builds browser/server production bundles and retains `dist` for seven days. |
|
| `build` | Builds the client-only Angular production bundle (the only check of Angular templates; `bun check` does not replace it) and the Bun static-server bundle, and retains `dist` for seven days. |
|
||||||
| `security` | Runs Semgrep JavaScript/TypeScript SAST and Trivy dependency/secret scans; retains SARIF reports for seven days. Scanner execution must succeed; findings remain reports rather than a new severity gate. |
|
| `security` | Runs Semgrep JavaScript/TypeScript SAST and Trivy dependency/secret scans; retains SARIF reports for seven days. Scanner execution must succeed; findings remain reports rather than a new severity gate. The scanners are not project JavaScript, so this job installs no Bun. |
|
||||||
| `images` | Publishes branch/SHA images after required validation, including the benchmark, or promotes an existing SHA image for a stable tag. Runs only for pushes and same-repository PRs. |
|
| `images` | Publishes branch/SHA images after required validation, including the benchmark, or promotes an existing SHA image for a stable tag, running the publisher with Bun. Runs only for pushes and same-repository PRs. |
|
||||||
| `required` | Fails on failed, cancelled, or skipped required validation, including the benchmark. Requires image publication when expected; accepts its intentional skip for fork PRs and manual dispatch. |
|
| `required` | Fails on failed, cancelled, or skipped required validation, including the benchmark. Requires image publication when expected; accepts its intentional skip for fork PRs and manual dispatch. It is shell only and needs no Bun. |
|
||||||
|
|
||||||
The benchmark runs on both push and pull_request events: the push run gates image
|
The benchmark runs on both push and pull_request events: the push run gates image
|
||||||
publication (`images.needs` includes `benchmark`) and the `required` aggregate.
|
publication (`images.needs` includes `benchmark`) and the `required` aggregate.
|
||||||
@@ -41,13 +41,13 @@ branch slug; stable promotions share one group and are not cancelled in progress
|
|||||||
|
|
||||||
## Runners and permissions
|
## Runners and permissions
|
||||||
|
|
||||||
Validation uses Node 24.21.0 and Bun 1.4.2 with the frozen lockfile. The image
|
Every job that runs project commands uses Bun 1.4.3 with the frozen lockfile (see [Bun toolchain and Node](#bun-toolchain-and-node)). Job containers stay on Node 24.22.0. The image
|
||||||
job uses Docker 29.8.2 and its matching Docker-in-Docker service. Scanner
|
job uses Docker 29.9.1 and its matching Docker-in-Docker service. Scanner
|
||||||
versions are Semgrep 1.180.0 and Trivy 0.75.0; Trivy downloads are checksum-checked.
|
versions are Semgrep 1.180.0 and Trivy 0.75.0; Trivy downloads are checksum-checked.
|
||||||
Trivy downloads its vulnerability database from Aqua's public ECR mirror because
|
Trivy downloads its vulnerability database from Aqua's public ECR mirror because
|
||||||
the default registries returned a missing database blob and a download timeout.
|
the default registries returned a missing database blob and a download timeout.
|
||||||
Jobs select the `ubuntu-latest` label and require Linux container-job support.
|
Jobs select the `ubuntu-latest` label and require Linux container-job support.
|
||||||
Allow at least the preserved 2 GiB Node heap plus runner and container overhead.
|
Allow at least the benchmark job's 3 GiB container memory limit plus runner overhead.
|
||||||
|
|
||||||
The instance currently has an online inherited runner named `srvhome`, labelled
|
The instance currently has an online inherited runner named `srvhome`, labelled
|
||||||
`ubuntu-latest` and `ubuntu-24.04`. Repository runners, secrets, and variables
|
`ubuntu-latest` and `ubuntu-24.04`. Repository runners, secrets, and variables
|
||||||
@@ -72,6 +72,42 @@ Keep secret values in Gitea's Actions secret store. The configured tea account's
|
|||||||
administrator token is not a CI publishing credential. No SSH or deployment
|
administrator token is not a CI publishing credential. No SSH or deployment
|
||||||
secret is required by this workflow.
|
secret is required by this workflow.
|
||||||
|
|
||||||
|
## Bun toolchain and Node
|
||||||
|
|
||||||
|
Project commands run on Bun 1.4.3 only. `package.json` (`packageManager`),
|
||||||
|
`@types/bun`, both Docker stages and `scripts/install-bun.sh` carry the same
|
||||||
|
version. To upgrade, change all of them together and replace the installer's
|
||||||
|
four SHA-256 values with those of the new release's `SHASUMS256.txt`; the
|
||||||
|
installer refuses a `package.json` that names a different version, so a bump of
|
||||||
|
only one place fails CI instead of drifting.
|
||||||
|
|
||||||
|
- **Install.** `policy`, `validate`, `test`, `benchmark`, `build` and `images`
|
||||||
|
run `sh scripts/install-bun.sh`. It downloads the release zip for the
|
||||||
|
runner's architecture (x64 or aarch64) and libc (glibc, or musl on Alpine),
|
||||||
|
verifies its pinned SHA-256, and installs `/opt/bun/bin/bun`. It uses no npm.
|
||||||
|
- **Node-free project steps.** The five Debian jobs set
|
||||||
|
`defaults.run.shell: env PATH=/opt/bun/bin:/usr/bin:/bin bash -e {0}`. Every
|
||||||
|
`run:` step then sees Bun but not `/usr/local/bin`, where the `node` image keeps
|
||||||
|
`node`, `npm` and `yarn`. `bun run` additionally runs Node-shebang tools on Bun
|
||||||
|
through `bunfig.toml`. The security scanners are not project JavaScript, so
|
||||||
|
`security` has no Bun and no custom shell.
|
||||||
|
- **Why the containers keep Node.** `actions/checkout@v4` (`node20`) and
|
||||||
|
`actions/upload-artifact@v3` (`node16`) are JavaScript actions. The runner
|
||||||
|
starts them with the container's `node`; the custom shell does not affect
|
||||||
|
`uses:` steps. This is a platform requirement, not permission to run project
|
||||||
|
commands on Node.
|
||||||
|
- **`images` exception.** Alpine's `nodejs` package installs `node` into
|
||||||
|
`/usr/bin`, beside `git`, and checkout's post step needs it, so `PATH` cannot
|
||||||
|
hide it there. The project command is `/opt/bun/bin/bun scripts/ci-image.mjs`;
|
||||||
|
it starts only `process.execPath`, `docker` and `git`. Hiding Node there needs
|
||||||
|
an infrastructure decision (for example a runner image that ships Node outside
|
||||||
|
`PATH`), which this conversion does not make.
|
||||||
|
- **Needs live validation.** The custom shell template is a GitHub Actions
|
||||||
|
feature that Gitea's runner implements. A local run of the real engine
|
||||||
|
(`gitea-runner exec` v5.1.0, with the job containers above) executed it, but
|
||||||
|
only a live Gitea run confirms the instance's runner version, the checkout
|
||||||
|
action and the privileged Docker-in-Docker `images` job.
|
||||||
|
|
||||||
## Images and releases
|
## Images and releases
|
||||||
|
|
||||||
Images use the [Gitea container registry](https://docs.gitea.com/usage/packages/container/)
|
Images use the [Gitea container registry](https://docs.gitea.com/usage/packages/container/)
|
||||||
|
|||||||
@@ -32,19 +32,18 @@ If Cancel is accepted just before a queued natural result reaches the facade, th
|
|||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
Run the focused checks with the project's installed Bun and Node:
|
Run the focused checks with the pinned Bun (Node is not needed):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
bunx vitest run src/app/services/leave-planner-cancellation.spec.ts
|
bun test --isolate src/app/services/leave-planner-cancellation.spec.ts
|
||||||
bunx vitest run src/app/services/leave-planner.worker.spec.ts
|
bun test --isolate src/app/services/leave-planner.worker.spec.ts
|
||||||
bunx vitest run src/app/components/calendar-year/calendar-year-planning.spec.ts
|
bun test --isolate src/app/components/calendar-year/calendar-year-planning.spec.ts
|
||||||
bunx tsc --noEmit -p tsconfig.app.json
|
bun run typecheck
|
||||||
bunx tsc --noEmit -p tsconfig.spec.json
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The public cancellation tests cover immediate cancellation, a stable partial outcome despite late completion or artifact error, distinct worker failure and replacement (including non-browser explanation metadata), and cancellation after settlement. Core regressions verify retained validity, provenance, earned chronology and the best available partial plan through the existing checkpoint seam. The CalendarYear tests cover keyboard activation, translated statuses with and without an incumbent, focus, no implicit recalculation, explicit recalculation, and obsolete explanation delivery.
|
The public cancellation tests cover immediate cancellation, a stable partial outcome despite late completion or artifact error, distinct worker failure and replacement (including non-browser explanation metadata), and cancellation after settlement. Core regressions verify retained validity, provenance, earned chronology and the best available partial plan through the existing checkpoint seam. The CalendarYear tests cover keyboard activation, translated statuses with and without an incumbent, focus, no implicit recalculation, explicit recalculation, and obsolete explanation delivery.
|
||||||
|
|
||||||
The real-worker suite bundles the production worker source with Bun and runs it on a separate Node worker thread. Its small host adapter maps browser worker messages to the thread's message port; the planner, CPU work, cancellation handling, and outcome and explanation delivery protocol are real. It checks interruption after preparation has started, before the first obligation solution, during active obligation CPU work after a solution exists, and after a completed checkpoint. It also compares an uncancelled outcome with the synchronous adapter's deterministic result. These checks complement the controlled worker boundary tests.
|
The real-worker suite bundles the production worker source with Bun and runs it on a separate `node:worker_threads` thread of the Bun process. Its small host adapter maps browser worker messages to the thread's message port; the planner, CPU work, cancellation handling, and outcome and explanation delivery protocol are real. It checks interruption after preparation has started, before the first obligation solution, during active obligation CPU work after a solution exists, and after a completed checkpoint. It also compares an uncancelled outcome with the synchronous adapter's deterministic result. These checks complement the controlled worker boundary tests.
|
||||||
|
|
||||||
For a browser check, serve the production browser build and use native Chromium Web Workers with a saved annual horizon. Check cancellation during calendar preparation, a broad obligation search, and a quality pass. Confirm one planning request per Calculate, no new request on Cancel, the appropriate translated status, joint rule satisfaction and balance accounting for an incumbent, and stable explanation delivery after the outcome. Also check the Cancel control with the keyboard, at 375px width, and in light and dark themes.
|
For a browser check, serve the production browser build and use native Chromium Web Workers with a saved annual horizon. Check cancellation during calendar preparation, a broad obligation search, and a quality pass. Confirm one planning request per Calculate, no new request on Cancel, the appropriate translated status, joint rule satisfaction and balance accounting for an incumbent, and stable explanation delivery after the outcome. Also check the Cancel control with the keyboard, at 375px width, and in light and dark themes.
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ benchmark inputs, CI settings or performance limits.
|
|||||||
| Benchmark callers | Supported calculation outcome, explanation and timings; no trace promises, waits, counts or replacement trace metrics. |
|
| Benchmark callers | Supported calculation outcome, explanation and timings; no trace promises, waits, counts or replacement trace metrics. |
|
||||||
|
|
||||||
Production-source searches found no decision-trace, historical analysis,
|
Production-source searches found no decision-trace, historical analysis,
|
||||||
acknowledgement or replay implementation. Angular hydration's `withEventReplay`
|
acknowledgement or replay implementation. `PlanningArtifacts` carries actual explanation,
|
||||||
is unrelated to planning history. `PlanningArtifacts` carries actual explanation,
|
|
||||||
final-state and planning-work timing data, rather than a compatibility shell.
|
final-state and planning-work timing data, rather than a compatibility shell.
|
||||||
|
|
||||||
## Existing deterministic fixtures
|
## Existing deterministic fixtures
|
||||||
|
|||||||
+24
-33
@@ -1,17 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "vac-optimizer",
|
"name": "vac-optimizer",
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
|
"packageManager": "bun@1.4.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "bun scripts/generate-langs.js && ng serve",
|
"start": "bun scripts/generate-langs.js && ng serve",
|
||||||
"build": "bun scripts/generate-langs.js && ng build",
|
"build": "bun scripts/generate-langs.js && ng build",
|
||||||
|
"postbuild": "bun build src/server.ts --target=bun --outfile dist/vac-optimizer/server/server.js",
|
||||||
"watch": "bun scripts/generate-langs.js && ng build --watch --configuration development",
|
"watch": "bun scripts/generate-langs.js && ng build --watch --configuration development",
|
||||||
"test": "bun scripts/generate-langs.js && ng test",
|
"test": "bun scripts/generate-langs.js && bun test --isolate",
|
||||||
"serve:ssr:vac-optimizer": "bun dist/vac-optimizer/server/server.mjs",
|
"serve": "bun dist/vac-optimizer/server/server.js",
|
||||||
|
"typecheck": "bun scripts/generate-langs.js && bun check -p tsconfig.app.json && bun check -p tsconfig.spec.json",
|
||||||
"lint": "bun scripts/generate-langs.js && eslint \"src/**/*.ts\"",
|
"lint": "bun scripts/generate-langs.js && eslint \"src/**/*.ts\"",
|
||||||
"lint:fix": "bun scripts/generate-langs.js && eslint \"src/**/*.ts\" --fix",
|
"lint:fix": "bun scripts/generate-langs.js && eslint \"src/**/*.ts\" --fix",
|
||||||
"format": "bun scripts/generate-langs.js && prettier --write \"src/**/*.{ts,html,scss,css,json}\"",
|
"format": "bun scripts/generate-langs.js && prettier --write \"src/**/*.{ts,html,scss,css,json}\" \"scripts/ci-*.mjs\" \".gitea/workflows/*.yml\"",
|
||||||
"format:check": "bun scripts/generate-langs.js && prettier --check \"src/**/*.{ts,html,scss,css,json}\""
|
"format:check": "bun scripts/generate-langs.js && prettier --check \"src/**/*.{ts,html,scss,css,json}\" \"scripts/ci-*.mjs\" \".gitea/workflows/*.yml\""
|
||||||
},
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"printWidth": 100,
|
"printWidth": 100,
|
||||||
@@ -27,52 +30,40 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "22.2.1",
|
"@angular/animations": "22.2.3",
|
||||||
"@angular/cdk": "^22.0.0",
|
"@angular/cdk": "^22.0.0",
|
||||||
"@angular/common": "22.2.1",
|
"@angular/common": "22.2.3",
|
||||||
"@angular/compiler": "22.2.1",
|
"@angular/compiler": "22.2.3",
|
||||||
"@angular/core": "22.2.1",
|
"@angular/core": "22.2.3",
|
||||||
"@angular/forms": "22.2.1",
|
"@angular/forms": "22.2.3",
|
||||||
"@angular/material": "^22.0.0",
|
"@angular/material": "^22.0.0",
|
||||||
"@angular/platform-browser": "22.2.1",
|
"@angular/platform-browser": "22.2.3",
|
||||||
"@angular/platform-browser-dynamic": "22.2.1",
|
"@angular/platform-browser-dynamic": "22.2.3",
|
||||||
"@angular/platform-server": "22.2.1",
|
"@angular/router": "22.2.3",
|
||||||
"@angular/router": "22.2.1",
|
|
||||||
"@angular/ssr": "22.2.2",
|
|
||||||
"date-holidays": "^3.30.2",
|
"date-holidays": "^3.30.2",
|
||||||
"dayjs": "^1.11.21",
|
"dayjs": "^1.11.21",
|
||||||
"express": "^5.2.1",
|
|
||||||
"rxjs": "~7.8.2",
|
"rxjs": "~7.8.2",
|
||||||
"tslib": "^2.8.1"
|
"tslib": "^2.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@analogjs/platform": "^2.7.5",
|
|
||||||
"@analogjs/vite-plugin-angular": "^2.7.5",
|
"@analogjs/vite-plugin-angular": "^2.7.5",
|
||||||
"@analogjs/vitest-angular": "^2.7.5",
|
"@angular/build": "22.2.3",
|
||||||
"@angular/build": "22.2.2",
|
"@angular/cli": "22.2.3",
|
||||||
"@angular/cli": "22.2.2",
|
"@angular/compiler-cli": "22.2.3",
|
||||||
"@angular/compiler-cli": "22.2.1",
|
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@nx/devkit": "^23.0.0",
|
"@types/bun": "1.4.3",
|
||||||
"@nx/vite": "^23.0.0",
|
"@types/jsdom": "^21.1.7",
|
||||||
"@types/express": "^5.0.6",
|
|
||||||
"@types/jest": "^30.0.0",
|
|
||||||
"@types/node": "^26.0.0",
|
"@types/node": "^26.0.0",
|
||||||
"@vitest/coverage-istanbul": "^5.0.0",
|
|
||||||
"@vitest/coverage-v8": "^5.0.0",
|
|
||||||
"@vitest/ui": "^5.0.0",
|
|
||||||
"eslint": "^10.4.1",
|
"eslint": "^10.4.1",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^5.5.6",
|
"eslint-plugin-prettier": "^5.5.6",
|
||||||
"fast-check": "4.10.2",
|
"fast-check": "4.10.2",
|
||||||
"jest": "^30.4.2",
|
"istanbul-lib-coverage": "^3.2.2",
|
||||||
"jest-preset-angular": "^17.0.0",
|
"istanbul-lib-report": "^3.0.1",
|
||||||
|
"istanbul-reports": "^3.2.0",
|
||||||
"jsdom": "^30.0.0",
|
"jsdom": "^30.0.0",
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
"typescript": "~6.0.3",
|
"typescript": "~6.0.3",
|
||||||
"typescript-eslint": "^8.60.0",
|
"typescript-eslint": "^8.60.0"
|
||||||
"vite": "^8.0.0",
|
|
||||||
"vite-tsconfig-paths": "^6.0.0",
|
|
||||||
"vitest": "^5.0.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,7 +70,7 @@ try {
|
|||||||
const docker = join(bin, 'docker');
|
const docker = join(bin, 'docker');
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
docker,
|
docker,
|
||||||
`#!/usr/bin/env node
|
`#!/usr/bin/env bun
|
||||||
import { appendFileSync, readFileSync } from 'node:fs';
|
import { appendFileSync, readFileSync } from 'node:fs';
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const input = args[0] === 'login' ? readFileSync(0, 'utf8') : '';
|
const input = args[0] === 'login' ? readFileSync(0, 'utf8') : '';
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Converts coverage/lcov.info (Bun) to HTML and Cobertura. Run: bun scripts/coverage-reports.mjs
|
||||||
|
// shortcut: line coverage only, Bun's lcov has no usable branch/function data; revisit if Bun adds it.
|
||||||
|
// Totals here sum lines (8351/8578); Bun's text table averages per-file percentages, so it reads lower.
|
||||||
|
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
import libCoverage from 'istanbul-lib-coverage';
|
||||||
|
import libReport from 'istanbul-lib-report';
|
||||||
|
import reports from 'istanbul-reports';
|
||||||
|
|
||||||
|
const map = libCoverage.createCoverageMap({});
|
||||||
|
const loc = (line) => ({ start: { line, column: 0 }, end: { line, column: 1 } });
|
||||||
|
for (const record of readFileSync('coverage/lcov.info', 'utf8').split('end_of_record')) {
|
||||||
|
const file = /^SF:(.+)$/m.exec(record)?.[1];
|
||||||
|
if (!file) continue;
|
||||||
|
const statementMap = {};
|
||||||
|
const s = {};
|
||||||
|
for (const [, line, hits] of record.matchAll(/^DA:(\d+),(\d+)/gm)) {
|
||||||
|
statementMap[line] = loc(+line);
|
||||||
|
s[line] = +hits;
|
||||||
|
}
|
||||||
|
map.addFileCoverage({
|
||||||
|
path: resolve(file),
|
||||||
|
statementMap,
|
||||||
|
s,
|
||||||
|
fnMap: {},
|
||||||
|
f: {},
|
||||||
|
branchMap: {},
|
||||||
|
b: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const context = libReport.createContext({ dir: 'coverage', coverageMap: map });
|
||||||
|
for (const name of ['html', 'cobertura']) reports.create(name).execute(context);
|
||||||
|
|
||||||
|
// Istanbul reports 0 branches/functions as 100%; relabel them instead of claiming coverage.
|
||||||
|
const rewrite = (file, edit) => writeFileSync(file, edit(readFileSync(file, 'utf8')));
|
||||||
|
for (const page of readdirSync('coverage', { recursive: true }).filter((f) => f.endsWith('.html')))
|
||||||
|
rewrite(join('coverage', page), (html) =>
|
||||||
|
html
|
||||||
|
.replace(
|
||||||
|
/<span class="strong">100% <\/span>(\s*<span class="quiet">(?:Branches|Functions)<\/span>\s*<span class='fraction'>)0\/0</g,
|
||||||
|
'<span class="strong">n/a </span>$1not collected<',
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
/<td data-value="100" class="pct \w+">100%<\/td>(\s*)<td data-value="0" class="abs \w+">0\/0<\/td>/g,
|
||||||
|
'<td data-value="0" class="pct">n/a</td>$1<td data-value="0" class="abs">not collected</td>',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
rewrite('coverage/cobertura-coverage.xml', (xml) =>
|
||||||
|
xml
|
||||||
|
.replaceAll('branch-rate="1"', 'branch-rate="0"')
|
||||||
|
.replace(
|
||||||
|
'<coverage ',
|
||||||
|
'<!-- Line coverage only: bun test does not collect branch or function coverage. -->\n<coverage ',
|
||||||
|
),
|
||||||
|
);
|
||||||
Executable
+41
@@ -0,0 +1,41 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Installs the pinned Bun release to /opt/bun/bin without npm, after verifying its SHA-256.
|
||||||
|
# Needs curl, unzip and sha256sum. Bun upgrades change the version, package.json and every checksum together.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
version=1.4.3
|
||||||
|
dest=${BUN_INSTALL_DIR:-/opt/bun}
|
||||||
|
|
||||||
|
grep -q "\"packageManager\": \"bun@${version}\"" package.json || {
|
||||||
|
echo "package.json packageManager must be bun@${version}." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$(uname -m)" in
|
||||||
|
x86_64) arch=x64 ;;
|
||||||
|
aarch64 | arm64) arch=aarch64 ;;
|
||||||
|
*)
|
||||||
|
echo "Unsupported Bun architecture: $(uname -m)" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
libc=
|
||||||
|
if [ -e /etc/alpine-release ]; then libc=-musl; fi
|
||||||
|
asset="bun-linux-${arch}${libc}"
|
||||||
|
case "$asset" in
|
||||||
|
bun-linux-x64) sha256=3e730528d89775f03f87f05c06787c4df4099d0a37f3f252e7b829cb23816e18 ;;
|
||||||
|
bun-linux-x64-musl) sha256=6e9ea98554ea0d05efda5840ae292618aaa3adefd66b0853520c5c86a37eac9c ;;
|
||||||
|
bun-linux-aarch64) sha256=efa9813da5ed72423bf847f916e8d2c47c0d776add972354026a75e10da9aa21 ;;
|
||||||
|
bun-linux-aarch64-musl) sha256=8de8a0003cc2707bb555887e3d450e24e1edf3f605f491393e89ed61a8853420 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
work=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
curl --fail --location --silent --show-error \
|
||||||
|
"https://github.com/oven-sh/bun/releases/download/bun-v${version}/${asset}.zip" \
|
||||||
|
--output "$work/bun.zip"
|
||||||
|
echo "${sha256} ${work}/bun.zip" | sha256sum -c -
|
||||||
|
unzip -q "$work/bun.zip" -d "$work"
|
||||||
|
mkdir -p "${dest}/bin"
|
||||||
|
install -m 0755 "${work}/${asset}/bun" "${dest}/bin/bun"
|
||||||
|
"${dest}/bin/bun" --version
|
||||||
@@ -7,7 +7,7 @@ import { MatOptionHarness } from '@angular/material/core/testing';
|
|||||||
import { MatDialogHarness } from '@angular/material/dialog/testing';
|
import { MatDialogHarness } from '@angular/material/dialog/testing';
|
||||||
import { MatSelectHarness } from '@angular/material/select/testing';
|
import { MatSelectHarness } from '@angular/material/select/testing';
|
||||||
import { MatSlideToggleHarness } from '@angular/material/slide-toggle/testing';
|
import { MatSlideToggleHarness } from '@angular/material/slide-toggle/testing';
|
||||||
import { type MockInstance, vi } from 'vitest';
|
import { type MockInstance, vi } from '../testing/vitest-compat';
|
||||||
import { App } from './app';
|
import { App } from './app';
|
||||||
import { CalendarService } from './services/calendar-service';
|
import { CalendarService } from './services/calendar-service';
|
||||||
import { calculatePlanningInternal } from './services/leave-planner.core';
|
import { calculatePlanningInternal } from './services/leave-planner.core';
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
|
|
||||||
import { provideServerRendering, withRoutes } from '@angular/ssr';
|
|
||||||
import { appConfig } from './app.config';
|
|
||||||
import { serverRoutes } from './app.routes.server';
|
|
||||||
|
|
||||||
const serverConfig: ApplicationConfig = {
|
|
||||||
providers: [provideServerRendering(withRoutes(serverRoutes))],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const config = mergeApplicationConfig(appConfig, serverConfig);
|
|
||||||
@@ -6,13 +6,11 @@ import {
|
|||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
|
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
|
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideBrowserGlobalErrorListeners(),
|
provideBrowserGlobalErrorListeners(),
|
||||||
provideZonelessChangeDetection(),
|
provideZonelessChangeDetection(),
|
||||||
provideRouter(routes),
|
provideRouter(routes),
|
||||||
provideClientHydration(withEventReplay()),
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { RenderMode, ServerRoute } from '@angular/ssr';
|
|
||||||
|
|
||||||
export const serverRoutes: ServerRoute[] = [
|
|
||||||
{
|
|
||||||
path: '**',
|
|
||||||
renderMode: RenderMode.Client,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { vi } from 'vitest';
|
import { vi } from '../../testing/vitest-compat';
|
||||||
import { calculatePlanningAsyncInternal } from '../services/frozen-engine/leave-planner.core.frozen.mjs';
|
import { calculatePlanningAsyncInternal } from '../services/frozen-engine/leave-planner.core.frozen.mjs';
|
||||||
import { createPlanningCalculation } from '../services/leave-planner.service';
|
import { createPlanningCalculation } from '../services/leave-planner.service';
|
||||||
import {
|
import {
|
||||||
@@ -78,7 +78,7 @@ describe('leave planner benchmark protocol', () => {
|
|||||||
await calculatePlanningAsyncInternal({ ...request, calculationDate: '2024-01-01' });
|
await calculatePlanningAsyncInternal({ ...request, calculationDate: '2024-01-01' });
|
||||||
calibrationMs = Math.min(calibrationMs, performance.now() - start);
|
calibrationMs = Math.min(calibrationMs, performance.now() - start);
|
||||||
}
|
}
|
||||||
// Direct writes skip Vitest's per-call "stdout |" headers and appear immediately.
|
// Direct stdout writes show each row as soon as its scenario finishes.
|
||||||
const print = (text: string) => process.stdout.write(`${text}\n`);
|
const print = (text: string) => process.stdout.write(`${text}\n`);
|
||||||
print(
|
print(
|
||||||
`\nCalibration: frozen engine on "two years", best of 3 = ${calibrationMs.toFixed(1)} ms` +
|
`\nCalibration: frozen engine on "two years", best of 3 = ${calibrationMs.toFixed(1)} ms` +
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createOptimizationPresetRules } from '../../services/rule-service';
|
import { createOptimizationPresetRules } from '../../services/rule-service';
|
||||||
import { provideZonelessChangeDetection } from '@angular/core';
|
import { provideZonelessChangeDetection } from '@angular/core';
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
import { vi } from 'vitest';
|
import { vi } from '../../../testing/vitest-compat';
|
||||||
import { CalendarYear } from './calendar-year';
|
import { CalendarYear } from './calendar-year';
|
||||||
import { UserInput } from '../user-input/user-input';
|
import { UserInput } from '../user-input/user-input';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { TranslationService } from '../../services/translation-service';
|
|||||||
import { By } from '@angular/platform-browser';
|
import { By } from '@angular/platform-browser';
|
||||||
import { CdkDragDrop, CdkDropList } from '@angular/cdk/drag-drop';
|
import { CdkDragDrop, CdkDropList } from '@angular/cdk/drag-drop';
|
||||||
import { LiveAnnouncer } from '@angular/cdk/a11y';
|
import { LiveAnnouncer } from '@angular/cdk/a11y';
|
||||||
import { vi } from 'vitest';
|
import { vi } from '../../../testing/vitest-compat';
|
||||||
import { OPTIMIZATION_PRESETS } from '../../services/planning-objectives';
|
import { OPTIMIZATION_PRESETS } from '../../services/planning-objectives';
|
||||||
import { MatExpansionPanel } from '@angular/material/expansion';
|
import { MatExpansionPanel } from '@angular/material/expansion';
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { provideZonelessChangeDetection } from '@angular/core';
|
|||||||
import { TestBed, type ComponentFixture } from '@angular/core/testing';
|
import { TestBed, type ComponentFixture } from '@angular/core/testing';
|
||||||
import { By } from '@angular/platform-browser';
|
import { By } from '@angular/platform-browser';
|
||||||
import { MatExpansionPanel } from '@angular/material/expansion';
|
import { MatExpansionPanel } from '@angular/material/expansion';
|
||||||
import { vi } from 'vitest';
|
import { vi } from '../../../testing/vitest-compat';
|
||||||
import { RuleService, RuleType } from '../../services/rule-service';
|
import { RuleService, RuleType } from '../../services/rule-service';
|
||||||
import { TranslationService } from '../../services/translation-service';
|
import { TranslationService } from '../../services/translation-service';
|
||||||
import { calculatePlanningInternal } from '../../services/leave-planner.core';
|
import { calculatePlanningInternal } from '../../services/leave-planner.core';
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { MatDialog } from '@angular/material/dialog';
|
|||||||
import { MatDialogHarness } from '@angular/material/dialog/testing';
|
import { MatDialogHarness } from '@angular/material/dialog/testing';
|
||||||
import { MatSelectHarness } from '@angular/material/select/testing';
|
import { MatSelectHarness } from '@angular/material/select/testing';
|
||||||
import { MatSlideToggleHarness } from '@angular/material/slide-toggle/testing';
|
import { MatSlideToggleHarness } from '@angular/material/slide-toggle/testing';
|
||||||
import { vi, type MockInstance } from 'vitest';
|
import { type MockInstance, vi } from '../../../testing/vitest-compat';
|
||||||
import { UserInput } from './user-input';
|
import { UserInput } from './user-input';
|
||||||
import { UserInputService } from '../../services/user-input-service';
|
import { UserInputService } from '../../services/user-input-service';
|
||||||
import { CalendarService } from '../../services/calendar-service';
|
import { CalendarService } from '../../services/calendar-service';
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
|
|||||||
import { join, resolve } from 'node:path';
|
import { join, resolve } from 'node:path';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
import { Worker as NodeWorker } from 'node:worker_threads';
|
import { Worker as NodeWorker } from 'node:worker_threads';
|
||||||
import { vi } from 'vitest';
|
import { vi } from '../../testing/vitest-compat';
|
||||||
import { createPlanningCalculation } from './leave-planner.service';
|
import { createPlanningCalculation } from './leave-planner.service';
|
||||||
import type { OptimizationPlanningRule } from './leave-planner.core';
|
import type { OptimizationPlanningRule } from './leave-planner.core';
|
||||||
import { OPTIMIZATION_PRESETS } from './planning-objectives';
|
import { OPTIMIZATION_PRESETS } from './planning-objectives';
|
||||||
@@ -28,7 +28,13 @@ await import(${JSON.stringify(resolve('src/app/services/leave-planner.worker.ts'
|
|||||||
parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||||
`,
|
`,
|
||||||
);
|
);
|
||||||
await promisify(execFile)('bun', ['build', entry, '--target=node', '--outfile', bundle]);
|
await promisify(execFile)(process.execPath, [
|
||||||
|
'build',
|
||||||
|
entry,
|
||||||
|
'--target=node',
|
||||||
|
'--outfile',
|
||||||
|
bundle,
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { provideZonelessChangeDetection } from '@angular/core';
|
import { provideZonelessChangeDetection } from '@angular/core';
|
||||||
import { CalendarSettingsService } from './calendar-settings-service';
|
import { CalendarSettingsService } from './calendar-settings-service';
|
||||||
import { vi } from 'vitest';
|
import { vi } from '../../testing/vitest-compat';
|
||||||
|
|
||||||
describe('CalendarSettingsService', () => {
|
describe('CalendarSettingsService', () => {
|
||||||
let service: CalendarSettingsService;
|
let service: CalendarSettingsService;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { afterAll, describe, expect, it } from 'vitest';
|
import { afterAll, describe, expect, it } from 'bun:test';
|
||||||
import { createOptimizationPresetRules } from './rule-service';
|
import { createOptimizationPresetRules } from './rule-service';
|
||||||
import { frenchPublicHolidaysForYear, isWorkingPlanningDate } from './declared-non-working-days';
|
import { frenchPublicHolidaysForYear, isWorkingPlanningDate } from './declared-non-working-days';
|
||||||
import { OPTIMIZATION_CATALOG } from './planning-objectives';
|
import { OPTIMIZATION_CATALOG } from './planning-objectives';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'bun:test';
|
||||||
import {
|
import {
|
||||||
analyzeCounterfactualInternal,
|
analyzeCounterfactualInternal,
|
||||||
calculatePlanningInternal,
|
calculatePlanningInternal,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createOptimizationPresetRules } from './rule-service';
|
import { createOptimizationPresetRules } from './rule-service';
|
||||||
import { vi } from 'vitest';
|
import { vi } from '../../testing/vitest-compat';
|
||||||
import { createPlanningCalculation, type PlanningIncompleteOutcome } from './leave-planner.service';
|
import { createPlanningCalculation, type PlanningIncompleteOutcome } from './leave-planner.service';
|
||||||
|
|
||||||
const request = {
|
const request = {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createOptimizationPresetRules } from './rule-service';
|
import { createOptimizationPresetRules } from './rule-service';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||||
|
import { vi } from '../../testing/vitest-compat';
|
||||||
import { createPlanningCalculation } from './leave-planner.service';
|
import { createPlanningCalculation } from './leave-planner.service';
|
||||||
|
|
||||||
const balance = (
|
const balance = (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createOptimizationPresetRules } from './rule-service';
|
import { createOptimizationPresetRules } from './rule-service';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'bun:test';
|
||||||
import { calculatePlanningInternal, type PlanningArtifacts } from './leave-planner.core';
|
import { calculatePlanningInternal, type PlanningArtifacts } from './leave-planner.core';
|
||||||
import {
|
import {
|
||||||
evaluateOptimization,
|
evaluateOptimization,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { vi } from 'vitest';
|
import { vi } from '../../testing/vitest-compat';
|
||||||
import {
|
import {
|
||||||
calculatePlanningAsyncInternal,
|
calculatePlanningAsyncInternal,
|
||||||
calculatePlanningInternal,
|
calculatePlanningInternal,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { vi } from 'vitest';
|
import { vi } from '../../testing/vitest-compat';
|
||||||
import {
|
import {
|
||||||
calculatePlanningInternal,
|
calculatePlanningInternal,
|
||||||
type OptimizationPlanningRule,
|
type OptimizationPlanningRule,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { vi } from 'vitest';
|
import { vi } from '../../testing/vitest-compat';
|
||||||
import {
|
import {
|
||||||
createPlanningCalculation,
|
createPlanningCalculation,
|
||||||
type LeavePlacement,
|
type LeavePlacement,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createOptimizationPresetRules } from './rule-service';
|
import { createOptimizationPresetRules } from './rule-service';
|
||||||
import { vi } from 'vitest';
|
import { vi } from '../../testing/vitest-compat';
|
||||||
import {
|
import {
|
||||||
PlanningOutcome,
|
PlanningOutcome,
|
||||||
PlanningRequest,
|
PlanningRequest,
|
||||||
@@ -1562,7 +1562,11 @@ describe('planLeave', () => {
|
|||||||
const { isWorkingPlanningDate } = await import('./declared-non-working-days');
|
const { isWorkingPlanningDate } = await import('./declared-non-working-days');
|
||||||
const { addPlanningDays } = await import('./planning-date');
|
const { addPlanningDays } = await import('./planning-date');
|
||||||
const declaredDays: NonNullable<PlanningRequest['declaredDays']>[number][] = [];
|
const declaredDays: NonNullable<PlanningRequest['declaredDays']>[number][] = [];
|
||||||
for (let date = declaredStart; date < declaredEnd; date = addPlanningDays(date, 1))
|
for (
|
||||||
|
let date: string = declaredStart;
|
||||||
|
date < declaredEnd;
|
||||||
|
date = addPlanningDays(date, 1)
|
||||||
|
)
|
||||||
if (isWorkingPlanningDate(date)) declaredDays.push({ date, type: 'other' });
|
if (isWorkingPlanningDate(date)) declaredDays.push({ date, type: 'other' });
|
||||||
const result = isPlan(
|
const result = isPlan(
|
||||||
await runInNonBrowser({ horizon, balances: [], rules: [], declaredDays }),
|
await runInNonBrowser({ horizon, balances: [], rules: [], declaredDays }),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
|
|||||||
import { join, resolve } from 'node:path';
|
import { join, resolve } from 'node:path';
|
||||||
import { Worker as NodeWorker } from 'node:worker_threads';
|
import { Worker as NodeWorker } from 'node:worker_threads';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
import { vi } from 'vitest';
|
import { vi } from '../../testing/vitest-compat';
|
||||||
import {
|
import {
|
||||||
createPlanningCalculation,
|
createPlanningCalculation,
|
||||||
type PlanningCalculationContext,
|
type PlanningCalculationContext,
|
||||||
@@ -31,6 +31,7 @@ describe('real planning worker cancellation', () => {
|
|||||||
let holdArtifacts = false;
|
let holdArtifacts = false;
|
||||||
const heldArtifactMessages: (() => void)[] = [];
|
const heldArtifactMessages: (() => void)[] = [];
|
||||||
let simulateLongCalculation = false;
|
let simulateLongCalculation = false;
|
||||||
|
let holdForCancelAtStage: string | undefined;
|
||||||
let simulatedElapsed = 0;
|
let simulatedElapsed = 0;
|
||||||
const threads = new Set<NodeWorker>();
|
const threads = new Set<NodeWorker>();
|
||||||
const sentTypes: string[] = [];
|
const sentTypes: string[] = [];
|
||||||
@@ -43,8 +44,9 @@ describe('real planning worker cancellation', () => {
|
|||||||
const entry = join(directory, 'entry.ts');
|
const entry = join(directory, 'entry.ts');
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
entry,
|
entry,
|
||||||
`import { parentPort, workerData } from 'node:worker_threads';
|
`import { parentPort, receiveMessageOnPort, workerData } from 'node:worker_threads';
|
||||||
let elapsed = 0;
|
let elapsed = 0;
|
||||||
|
const pause = new Int32Array(new SharedArrayBuffer(4));
|
||||||
const originalNow = performance.now.bind(performance);
|
const originalNow = performance.now.bind(performance);
|
||||||
if (workerData.simulateLongCalculation)
|
if (workerData.simulateLongCalculation)
|
||||||
Object.defineProperty(performance, 'now', { value: () => originalNow() + elapsed });
|
Object.defineProperty(performance, 'now', { value: () => originalNow() + elapsed });
|
||||||
@@ -52,12 +54,28 @@ globalThis.self = { postMessage: (message) => {
|
|||||||
if (workerData.simulateLongCalculation && message.type === 'progress' && message.stage === 'optimization:default-unplaced-at-risk')
|
if (workerData.simulateLongCalculation && message.type === 'progress' && message.stage === 'optimization:default-unplaced-at-risk')
|
||||||
setTimeout(() => { elapsed = 20_000; parentPort.postMessage({ type: 'test-clock', elapsed }); }, 0);
|
setTimeout(() => { elapsed = 20_000; parentPort.postMessage({ type: 'test-clock', elapsed }); }, 0);
|
||||||
parentPort.postMessage(message);
|
parentPort.postMessage(message);
|
||||||
|
// The worker reads its queue only between event-loop turns, so a stage that is shorter than the
|
||||||
|
// host round trip finishes first. Hold the stage until the host's cancel arrives, then deliver it.
|
||||||
|
if (message.type === 'progress' && message.stage === workerData.holdForCancelAtStage) {
|
||||||
|
let next;
|
||||||
|
for (let waits = 0; !(next = receiveMessageOnPort(parentPort)); waits++) {
|
||||||
|
if (waits > 5000) throw new Error('The host never sent the cancel request.');
|
||||||
|
Atomics.wait(pause, 0, 0, 1);
|
||||||
|
}
|
||||||
|
globalThis.self.onmessage({ data: next.message });
|
||||||
|
}
|
||||||
} };
|
} };
|
||||||
await import(${JSON.stringify(resolve('src/app/services/leave-planner.worker.ts'))});
|
await import(${JSON.stringify(resolve('src/app/services/leave-planner.worker.ts'))});
|
||||||
parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||||
`,
|
`,
|
||||||
);
|
);
|
||||||
await promisify(execFile)('bun', ['build', entry, '--target=node', '--outfile', bundle]);
|
await promisify(execFile)(process.execPath, [
|
||||||
|
'build',
|
||||||
|
entry,
|
||||||
|
'--target=node',
|
||||||
|
'--outfile',
|
||||||
|
bundle,
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -67,6 +85,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
|||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
heartbeats = 0;
|
heartbeats = 0;
|
||||||
simulateLongCalculation = false;
|
simulateLongCalculation = false;
|
||||||
|
holdForCancelAtStage = undefined;
|
||||||
simulatedElapsed = 0;
|
simulatedElapsed = 0;
|
||||||
sentTypes.length = 0;
|
sentTypes.length = 0;
|
||||||
receivedTypes.length = 0;
|
receivedTypes.length = 0;
|
||||||
@@ -74,7 +93,9 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
|||||||
class RealWorker {
|
class RealWorker {
|
||||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||||
onerror: (() => void) | null = null;
|
onerror: (() => void) | null = null;
|
||||||
private thread = new NodeWorker(bundle, { workerData: { simulateLongCalculation } });
|
private thread = new NodeWorker(bundle, {
|
||||||
|
workerData: { simulateLongCalculation, holdForCancelAtStage },
|
||||||
|
});
|
||||||
constructor() {
|
constructor() {
|
||||||
threads.add(this.thread);
|
threads.add(this.thread);
|
||||||
this.thread.on('message', (data) => {
|
this.thread.on('message', (data) => {
|
||||||
@@ -671,6 +692,8 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('preserves balances and provenance after a valid checkpoint', async () => {
|
it('preserves balances and provenance after a valid checkpoint', async () => {
|
||||||
|
const cancelStage = 'optimization:default-non-working-day-gain';
|
||||||
|
holdForCancelAtStage = cancelStage;
|
||||||
const calculation = createPlanningCalculation({
|
const calculation = createPlanningCalculation({
|
||||||
horizon: { start: '2024-01-01', end: '2025-01-01' },
|
horizon: { start: '2024-01-01', end: '2025-01-01' },
|
||||||
balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2024-12-31' }],
|
balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2024-12-31' }],
|
||||||
@@ -690,7 +713,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
|||||||
...presets.slice(1),
|
...presets.slice(1),
|
||||||
],
|
],
|
||||||
progress: (stage) => {
|
progress: (stage) => {
|
||||||
if (stage === 'optimization:default-non-working-day-gain') calculation.cancel();
|
if (stage === cancelStage) calculation.cancel();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const outcome = await calculation.outcome;
|
const outcome = await calculation.outcome;
|
||||||
@@ -698,7 +721,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
|||||||
if (outcome.kind !== 'PlanningIncomplete') return;
|
if (outcome.kind !== 'PlanningIncomplete') return;
|
||||||
expect(outcome.reasons[0].code).toBe('user-cancelled');
|
expect(outcome.reasons[0].code).toBe('user-cancelled');
|
||||||
expect(outcome.completedStages).toContain('optimization:default-holiday-week-value');
|
expect(outcome.completedStages).toContain('optimization:default-holiday-week-value');
|
||||||
expect(outcome.completedStages).not.toContain('optimization:default-non-working-day-gain');
|
expect(outcome.completedStages).not.toContain(cancelStage);
|
||||||
const incumbent = outcome.incumbent!;
|
const incumbent = outcome.incumbent!;
|
||||||
expect(incumbent.placements).toHaveLength(3);
|
expect(incumbent.placements).toHaveLength(3);
|
||||||
expect(incumbent.placements).toContainEqual({
|
expect(incumbent.placements).toContainEqual({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'bun:test';
|
||||||
import {
|
import {
|
||||||
calculatePlanningInternal,
|
calculatePlanningInternal,
|
||||||
type PlanningArtifacts,
|
type PlanningArtifacts,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { vi } from 'vitest';
|
import { vi } from '../../testing/vitest-compat';
|
||||||
import { calculatePlanningInternal } from './leave-planner.core';
|
import { calculatePlanningInternal } from './leave-planner.core';
|
||||||
import { createPlanningCalculation } from './leave-planner.service';
|
import { createPlanningCalculation } from './leave-planner.service';
|
||||||
import type { PlanningRequest } from './leave-planner.service';
|
import type { PlanningRequest } from './leave-planner.service';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import fc from 'fast-check';
|
import fc from 'fast-check';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'bun:test';
|
||||||
import { frenchPublicHolidaysForYear } from './declared-non-working-days';
|
import { frenchPublicHolidaysForYear } from './declared-non-working-days';
|
||||||
import {
|
import {
|
||||||
addDays,
|
addDays,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { provideZonelessChangeDetection } from '@angular/core';
|
import { provideZonelessChangeDetection } from '@angular/core';
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { vi } from 'vitest';
|
import { vi } from '../../testing/vitest-compat';
|
||||||
import { CalendarService } from './calendar-service';
|
import { CalendarService } from './calendar-service';
|
||||||
import { CalendarSettingsService } from './calendar-settings-service';
|
import { CalendarSettingsService } from './calendar-settings-service';
|
||||||
import type {
|
import type {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'bun:test';
|
||||||
import {
|
import {
|
||||||
compareObjectiveLists,
|
compareObjectiveLists,
|
||||||
compareObjectiveValues,
|
compareObjectiveValues,
|
||||||
@@ -174,7 +174,8 @@ describe('composable planning objectives', () => {
|
|||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
null,
|
null,
|
||||||
[],
|
[undefined],
|
||||||
|
[[]],
|
||||||
{ ...OPTIMIZATION_PRESETS.shortSequenceDeficit, conditions: ['internal', 'internal'] },
|
{ ...OPTIMIZATION_PRESETS.shortSequenceDeficit, conditions: ['internal', 'internal'] },
|
||||||
{ ...OPTIMIZATION_PRESETS.longestWorkSequence, direction: 'up' },
|
{ ...OPTIMIZATION_PRESETS.longestWorkSequence, direction: 'up' },
|
||||||
{ ...OPTIMIZATION_PRESETS.unplacedAtRisk, conditions: new Array(1) },
|
{ ...OPTIMIZATION_PRESETS.unplacedAtRisk, conditions: new Array(1) },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { RuleService, RuleType } from './rule-service';
|
import { RuleService, RuleType } from './rule-service';
|
||||||
import { vi } from 'vitest';
|
import { vi } from '../../testing/vitest-compat';
|
||||||
import { CalendarSettingsService } from './calendar-settings-service';
|
import { CalendarSettingsService } from './calendar-settings-service';
|
||||||
import { PlanningCalculationService } from './planning-calculation.service';
|
import { PlanningCalculationService } from './planning-calculation.service';
|
||||||
import { UserInputService } from './user-input-service';
|
import { UserInputService } from './user-input-service';
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from './rule-service';
|
} from './rule-service';
|
||||||
import { SelectableDayType } from './calendar-service';
|
import { SelectableDayType } from './calendar-service';
|
||||||
import { OPTIMIZATION_PRESETS } from './planning-objectives';
|
import { OPTIMIZATION_PRESETS } from './planning-objectives';
|
||||||
import { vi } from 'vitest';
|
import { vi } from '../../testing/vitest-compat';
|
||||||
|
|
||||||
describe('RuleService', () => {
|
describe('RuleService', () => {
|
||||||
let service: RuleService;
|
let service: RuleService;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/// <reference types="vite/client" />
|
|
||||||
import { Injectable, inject, Injector, computed } from '@angular/core';
|
import { Injectable, inject, Injector, computed } from '@angular/core';
|
||||||
import { storedSignal } from '../shared/utils/stored-signal';
|
import { storedSignal } from '../shared/utils/stored-signal';
|
||||||
import type { PlanOutcome, RemainingBalance } from './leave-planner.service';
|
import type { PlanOutcome, RemainingBalance } from './leave-planner.service';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { TestBed } from '@angular/core/testing';
|
|||||||
import { provideZonelessChangeDetection } from '@angular/core';
|
import { provideZonelessChangeDetection } from '@angular/core';
|
||||||
import { UserInputService } from './user-input-service';
|
import { UserInputService } from './user-input-service';
|
||||||
import { SelectableDayType, VacDay } from './calendar-service';
|
import { SelectableDayType, VacDay } from './calendar-service';
|
||||||
import { vi } from 'vitest';
|
import { vi } from '../../testing/vitest-compat';
|
||||||
|
|
||||||
describe('UserInputService', () => {
|
describe('UserInputService', () => {
|
||||||
const savedBalance = {
|
const savedBalance = {
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import { BootstrapContext, bootstrapApplication } from '@angular/platform-browser';
|
|
||||||
import { App } from './app/app';
|
|
||||||
import { config } from './app/app.config.server';
|
|
||||||
|
|
||||||
const bootstrap = (context: BootstrapContext) => bootstrapApplication(App, config, context);
|
|
||||||
|
|
||||||
export default bootstrap;
|
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it, spyOn } from 'bun:test';
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { connect } from 'node:net';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { createServer } from './server';
|
||||||
|
|
||||||
|
const INDEX = '<!doctype html><title>fixture app</title>';
|
||||||
|
const SECRET = 'top-secret-outside-the-root';
|
||||||
|
const JS = 'main-ABC123.js';
|
||||||
|
const IMMUTABLE = 'public, max-age=31536000';
|
||||||
|
|
||||||
|
let workdir: string;
|
||||||
|
let server: ReturnType<typeof createServer>;
|
||||||
|
let emptyServer: ReturnType<typeof createServer>;
|
||||||
|
|
||||||
|
// fetch() normalizes dot segments, so traversal needs an unnormalized request line.
|
||||||
|
function rawGet(port: number, target: string): Promise<{ status: number; text: string }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let text = '';
|
||||||
|
const socket = connect(port, '127.0.0.1', () =>
|
||||||
|
socket.write(`GET ${target} HTTP/1.1\r\nHost: test\r\nConnection: close\r\n\r\n`),
|
||||||
|
);
|
||||||
|
socket.on('data', (chunk) => (text += chunk));
|
||||||
|
socket.on('end', () => resolve({ status: Number(text.split(' ')[1]), text }));
|
||||||
|
socket.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
workdir = mkdtempSync(join(tmpdir(), 'vac-server-'));
|
||||||
|
const root = join(workdir, 'browser');
|
||||||
|
mkdirSync(join(root, 'assets'), { recursive: true });
|
||||||
|
writeFileSync(join(root, 'index.html'), INDEX);
|
||||||
|
writeFileSync(join(root, JS), 'console.log("app");');
|
||||||
|
writeFileSync(join(root, 'styles-DEF456.css'), 'body{margin:0}');
|
||||||
|
writeFileSync(join(root, 'assets', 'data.json'), '{"nested":true}');
|
||||||
|
writeFileSync(join(workdir, 'secret.txt'), SECRET);
|
||||||
|
mkdirSync(join(workdir, 'empty'));
|
||||||
|
server = createServer({ root, port: 0 });
|
||||||
|
emptyServer = createServer({ root: join(workdir, 'empty'), port: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await server.stop(true);
|
||||||
|
await emptyServer.stop(true);
|
||||||
|
rmSync(workdir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('direct navigation', () => {
|
||||||
|
it.each(['/', '/benchmark', '/benchmark/', '/unknown/client/route', '/assets'])(
|
||||||
|
'serves the application shell for %s',
|
||||||
|
async (path) => {
|
||||||
|
const response = await fetch(new URL(path, server.url));
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get('content-type')).toStartWith('text/html');
|
||||||
|
expect(response.headers.get('cache-control')).toBe('no-cache');
|
||||||
|
expect(await response.text()).toBe(INDEX);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('never gives the shell the long asset cache when requested by name', async () => {
|
||||||
|
const response = await fetch(new URL('/index.html', server.url));
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get('cache-control')).toBe('no-cache');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('static assets', () => {
|
||||||
|
it.each([
|
||||||
|
[JS, 'javascript', 'console.log("app");'],
|
||||||
|
['styles-DEF456.css', 'css', 'body{margin:0}'],
|
||||||
|
['assets/data.json', 'json', '{"nested":true}'],
|
||||||
|
])('serves /%s with its content type and the long cache', async (file, type, body) => {
|
||||||
|
const response = await fetch(new URL(`/${file}`, server.url));
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get('content-type')).toContain(type);
|
||||||
|
expect(response.headers.get('cache-control')).toBe(IMMUTABLE);
|
||||||
|
expect(await response.text()).toBe(body);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers HEAD with the GET headers and no body', async () => {
|
||||||
|
const get = await fetch(new URL(`/${JS}`, server.url));
|
||||||
|
const head = await fetch(new URL(`/${JS}`, server.url), { method: 'HEAD' });
|
||||||
|
|
||||||
|
expect(head.status).toBe(200);
|
||||||
|
expect(head.headers.get('content-type')).toBe(get.headers.get('content-type'));
|
||||||
|
expect(head.headers.get('content-length')).toBe(get.headers.get('content-length'));
|
||||||
|
expect(head.headers.get('cache-control')).toBe(IMMUTABLE);
|
||||||
|
expect(await head.text()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers HEAD for a client route with no body', async () => {
|
||||||
|
const head = await fetch(new URL('/benchmark', server.url), { method: 'HEAD' });
|
||||||
|
|
||||||
|
expect(head.status).toBe(200);
|
||||||
|
expect(await head.text()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['/missing.js', '/assets/missing.css', '/nope.js/', '/favicon.ico'])(
|
||||||
|
'answers 404, not the shell, for missing asset %s',
|
||||||
|
async (path) => {
|
||||||
|
const response = await fetch(new URL(path, server.url));
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.headers.get('content-type')).toStartWith('text/plain');
|
||||||
|
expect(await response.text()).not.toContain('fixture app');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('methods', () => {
|
||||||
|
it.each([`/${JS}`, '/benchmark'])('rejects POST to %s with 405', async (path) => {
|
||||||
|
const response = await fetch(new URL(path, server.url), { method: 'POST', body: 'x' });
|
||||||
|
|
||||||
|
expect(response.status).toBe(405);
|
||||||
|
expect(response.headers.get('allow')).toBe('GET, HEAD');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('path protection', () => {
|
||||||
|
it.each([
|
||||||
|
'/../secret.txt',
|
||||||
|
'/%2e%2e/secret.txt',
|
||||||
|
'/..%2fsecret.txt',
|
||||||
|
'/%2e%2e%2f%2e%2e%2fetc/passwd',
|
||||||
|
'/..%5csecret.txt',
|
||||||
|
'/assets/..%2f..%2fsecret.txt',
|
||||||
|
'/%2e%2e%2fsecret.txt',
|
||||||
|
'//secret.txt',
|
||||||
|
'/%00',
|
||||||
|
'/assets%00/data.json',
|
||||||
|
])('does not leak files outside the root through %s', async (target) => {
|
||||||
|
const response = await rawGet(server.port!, target);
|
||||||
|
|
||||||
|
expect([400, 404]).toContain(response.status);
|
||||||
|
expect(response.text).not.toContain(SECRET);
|
||||||
|
expect(response.text).not.toContain('root:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['/..%2fsecret.txt', '/%2e%2e%2f%2e%2e%2fetc/passwd', '/%00'])(
|
||||||
|
'rejects %s as a bad request',
|
||||||
|
async (target) => {
|
||||||
|
expect((await rawGet(server.port!, target)).status).toBe(400);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('rejects malformed percent-encoding with 400 instead of failing', async () => {
|
||||||
|
for (const target of ['/%ZZ', '/%E0%A4%A', '/assets/%']) {
|
||||||
|
expect((await rawGet(server.port!, target)).status).toBe(400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('errors', () => {
|
||||||
|
it('answers 500 as plain text and logs it when the build has no index.html', async () => {
|
||||||
|
const log = spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
const response = await fetch(new URL('/benchmark', emptyServer.url));
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.headers.get('content-type')).toStartWith('text/plain');
|
||||||
|
expect(await response.text()).toBe('Internal Server Error');
|
||||||
|
expect(log).toHaveBeenCalledTimes(1);
|
||||||
|
log.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still answers a missing asset with 404 when index.html is absent', async () => {
|
||||||
|
expect((await fetch(new URL('/missing.js', emptyServer.url))).status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
+61
-56
@@ -1,66 +1,71 @@
|
|||||||
import {
|
import { extname, join, resolve, sep } from 'node:path';
|
||||||
AngularNodeAppEngine,
|
|
||||||
createNodeRequestHandler,
|
|
||||||
isMainModule,
|
|
||||||
writeResponseToNodeResponse,
|
|
||||||
} from '@angular/ssr/node';
|
|
||||||
import express from 'express';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
|
|
||||||
const browserDistFolder = join(import.meta.dirname, '../browser');
|
const IMMUTABLE = 'public, max-age=31536000';
|
||||||
|
|
||||||
const app = express();
|
|
||||||
const angularApp = new AngularNodeAppEngine();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Example Express Rest API endpoints can be defined here.
|
* Fetch handler for a client-only Angular build in `root`: existing files are served as-is,
|
||||||
* Uncomment and define endpoints as necessary.
|
* extension-less paths fall back to `index.html` (client routes) and missing assets get a 404.
|
||||||
*
|
|
||||||
* Example:
|
|
||||||
* ```ts
|
|
||||||
* app.get('/api/{*splat}', (req, res) => {
|
|
||||||
* // Handle API request
|
|
||||||
* });
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
|
export function createHandler(root: string) {
|
||||||
/**
|
const base = resolve(root);
|
||||||
* Serve static files from /browser
|
const index = join(base, 'index.html');
|
||||||
*/
|
const isFile = (path: string) =>
|
||||||
app.use(
|
Bun.file(path)
|
||||||
express.static(browserDistFolder, {
|
.stat()
|
||||||
maxAge: '1y',
|
.then(
|
||||||
index: false,
|
(stats) => stats.isFile(),
|
||||||
redirect: false,
|
() => false,
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
// index.html is not content-hashed, so it must be revalidated on every deployment.
|
||||||
/**
|
const send = (path: string) =>
|
||||||
* Handle all other requests by rendering the Angular application.
|
new Response(Bun.file(path), {
|
||||||
*/
|
headers: { 'Cache-Control': path === index ? 'no-cache' : IMMUTABLE },
|
||||||
app.use((req, res, next) => {
|
|
||||||
angularApp
|
|
||||||
.handle(req)
|
|
||||||
.then((response) => (response ? writeResponseToNodeResponse(response, res) : next()))
|
|
||||||
.catch(next);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
return async (request: Request): Promise<Response> => {
|
||||||
* Start the server if this module is the main entry point, or it is ran via PM2.
|
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
||||||
* The server listens on the port defined by the `PORT` environment variable, or defaults to 4000.
|
return new Response('Method Not Allowed', {
|
||||||
*/
|
status: 405,
|
||||||
if (isMainModule(import.meta.url) || process.env['pm_id']) {
|
headers: { Allow: 'GET, HEAD' },
|
||||||
const port = process.env['PORT'] || 4000;
|
|
||||||
app.listen(port, (error) => {
|
|
||||||
if (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Node Express server listening on http://localhost:${port}`);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
let path: string;
|
||||||
* Request handler used by the Angular CLI (for dev-server and during build) or Firebase Cloud Functions.
|
try {
|
||||||
*/
|
path = decodeURIComponent(new URL(request.url).pathname);
|
||||||
export const reqHandler = createNodeRequestHandler(app);
|
} catch {
|
||||||
|
return new Response('Bad Request', { status: 400 });
|
||||||
|
}
|
||||||
|
const file = resolve(base, `.${path}`);
|
||||||
|
if (path.includes('\0') || (file !== base && !file.startsWith(base + sep))) {
|
||||||
|
return new Response('Bad Request', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// shortcut: symlinks inside the build are followed; fine while the build output is trusted.
|
||||||
|
if (await isFile(file)) return send(file);
|
||||||
|
// A path with an extension names an asset: a missing one must not turn into HTML.
|
||||||
|
if (extname(file)) return new Response('Not Found', { status: 404 });
|
||||||
|
// A missing index.html is a broken deployment: fail loudly (500) instead of a silent 404.
|
||||||
|
if (!(await isFile(index))) throw new Error(`Missing ${index}`);
|
||||||
|
return send(index);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createServer({ root, port }: { root: string; port: number }) {
|
||||||
|
return Bun.serve({
|
||||||
|
port,
|
||||||
|
fetch: createHandler(root),
|
||||||
|
error(error) {
|
||||||
|
console.error(error);
|
||||||
|
return new Response('Internal Server Error', { status: 500 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.main) {
|
||||||
|
const server = createServer({
|
||||||
|
root: join(import.meta.dir, '../browser'),
|
||||||
|
port: Number(process.env['PORT'] || 4000),
|
||||||
|
});
|
||||||
|
console.log(`Server listening on ${server.url}`);
|
||||||
|
}
|
||||||
+110
-7
@@ -1,13 +1,116 @@
|
|||||||
import '@angular/compiler';
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { vi } from 'vitest';
|
import { plugin } from 'bun';
|
||||||
import '@analogjs/vitest-angular/setup-snapshots';
|
import { readFileSync } from 'node:fs';
|
||||||
import '@analogjs/vitest-angular/setup-serializers';
|
import { dirname, resolve } from 'node:path';
|
||||||
import { setupTestBed } from '@analogjs/vitest-angular/setup-testbed';
|
import * as bt from 'bun:test';
|
||||||
|
// shortcut: imports Analog's internal jitTransform by path; replace when Angular or Analog exports a JIT transform.
|
||||||
|
import { jitTransform } from '../node_modules/@analogjs/vite-plugin-angular/src/lib/compiler/jit-transform.js';
|
||||||
|
import { vi } from './testing/vitest-compat';
|
||||||
|
|
||||||
setupTestBed();
|
// Inline templateUrl/styleUrl(s) before Bun transpiles: JIT has no resource loader.
|
||||||
|
plugin({
|
||||||
|
name: 'angular-jit',
|
||||||
|
setup(build) {
|
||||||
|
build.onLoad({ filter: /src\/app\/(?!.*\.spec\.ts$).*\.ts$/ }, ({ path }) => {
|
||||||
|
let src = readFileSync(path, 'utf8');
|
||||||
|
if (!/@(Component|Directive|Injectable|Pipe|NgModule)\(/.test(src)) {
|
||||||
|
return { contents: src, loader: 'ts' };
|
||||||
|
}
|
||||||
|
const read = (p: string) =>
|
||||||
|
JSON.stringify(readFileSync(resolve(dirname(path), p), 'utf8'));
|
||||||
|
src = src.replace(/templateUrl:\s*'([^']+)'/, (_, p) => `template: ${read(p)}`);
|
||||||
|
// Styles are not compiled; they do not matter to the tests.
|
||||||
|
src = src.replace(/styleUrl:\s*'([^']+)'/, 'styles: []');
|
||||||
|
src = src.replace(/styleUrls:\s*\[[^\]]*\]/, 'styles: []');
|
||||||
|
// Signal input()/output()/viewChild() become decorators and @Component becomes ɵcompileComponent.
|
||||||
|
return { contents: jitTransform(src, path).code, loader: 'ts' };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { JSDOM } = await import('jsdom');
|
||||||
|
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
|
||||||
|
url: 'http://localhost:3000/',
|
||||||
|
pretendToBeVisual: true,
|
||||||
|
});
|
||||||
|
const win = dom.window as any;
|
||||||
|
const keep = new Set([
|
||||||
|
'console',
|
||||||
|
'globalThis',
|
||||||
|
'undefined',
|
||||||
|
'Infinity',
|
||||||
|
'NaN',
|
||||||
|
'Object',
|
||||||
|
'Function',
|
||||||
|
'Array',
|
||||||
|
'Error',
|
||||||
|
'Promise',
|
||||||
|
'Symbol',
|
||||||
|
'Map',
|
||||||
|
'Set',
|
||||||
|
'JSON',
|
||||||
|
'Math',
|
||||||
|
'Date',
|
||||||
|
'process',
|
||||||
|
'window',
|
||||||
|
'self',
|
||||||
|
]);
|
||||||
|
const force = new Set([
|
||||||
|
'document',
|
||||||
|
'navigator',
|
||||||
|
'location',
|
||||||
|
'localStorage',
|
||||||
|
'sessionStorage',
|
||||||
|
'history',
|
||||||
|
'getComputedStyle',
|
||||||
|
]);
|
||||||
|
const domNames =
|
||||||
|
/^(HTML|SVG|DOM|Mutation|Node|Element|Event|Text|Comment|Document|Range|Selection|CSS|Shadow|Custom|Keyboard|Mouse|Focus|Input|UI|Wheel|Pointer|Touch|Animation|File|Blob|FormData|Window|Storage|Headers|Abort)/;
|
||||||
|
for (const k of Object.getOwnPropertyNames(win)) {
|
||||||
|
if (keep.has(k) || (k in globalThis && !force.has(k) && !domNames.test(k))) continue;
|
||||||
|
// Live accessors: spying on window.x also affects bare x (jsdom window !== globalThis here).
|
||||||
|
Object.defineProperty(globalThis, k, {
|
||||||
|
configurable: true,
|
||||||
|
get: () => {
|
||||||
|
const v = win[k];
|
||||||
|
return typeof v === 'function' && /^[a-z]/.test(k) ? v.bind(win) : v;
|
||||||
|
},
|
||||||
|
set: (v) => {
|
||||||
|
win[k] = v;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const k of ['window', 'self']) {
|
||||||
|
Object.defineProperty(globalThis, k, { configurable: true, writable: true, value: win });
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(globalThis, {
|
||||||
|
vi,
|
||||||
|
describe: bt.describe,
|
||||||
|
it: bt.it,
|
||||||
|
test: bt.test,
|
||||||
|
expect: bt.expect,
|
||||||
|
beforeEach: bt.beforeEach,
|
||||||
|
afterEach: bt.afterEach,
|
||||||
|
beforeAll: bt.beforeAll,
|
||||||
|
afterAll: bt.afterAll,
|
||||||
|
});
|
||||||
|
|
||||||
|
await import('@angular/compiler');
|
||||||
|
const { getTestBed, ɵgetCleanupHook } = await import('@angular/core/testing');
|
||||||
|
const { BrowserTestingModule, platformBrowserTesting } =
|
||||||
|
await import('@angular/platform-browser/testing');
|
||||||
|
const { provideZonelessChangeDetection } = await import('@angular/core');
|
||||||
|
getTestBed().initTestEnvironment([BrowserTestingModule], platformBrowserTesting(), {
|
||||||
|
teardown: { destroyAfterEach: true },
|
||||||
|
});
|
||||||
|
getTestBed().configureTestingModule({ providers: [provideZonelessChangeDetection()] });
|
||||||
|
bt.beforeEach(ɵgetCleanupHook(false));
|
||||||
|
bt.afterEach(ɵgetCleanupHook(true));
|
||||||
|
|
||||||
|
// Bun has a native Worker; the app must see none, as under jsdom.
|
||||||
|
delete (globalThis as any).Worker;
|
||||||
vi.stubGlobal('Worker', undefined);
|
vi.stubGlobal('Worker', undefined);
|
||||||
|
|
||||||
Object.defineProperty(window, 'matchMedia', {
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
writable: true,
|
writable: true,
|
||||||
value: vi.fn().mockImplementation((query: string) => ({
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
|||||||
Vendored
+18
@@ -0,0 +1,18 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars */
|
||||||
|
import 'bun:test';
|
||||||
|
|
||||||
|
// Vitest APIs that the compat shim adds to bun:test.
|
||||||
|
declare module 'bun:test' {
|
||||||
|
interface Expect {
|
||||||
|
// Vitest-style loose matcher arguments: Bun's strict ones reject readonly and widened values.
|
||||||
|
(actual?: unknown): Matchers<any>;
|
||||||
|
poll<T>(
|
||||||
|
fn: () => T | Promise<T>,
|
||||||
|
options?: { timeout?: number; interval?: number },
|
||||||
|
): Matchers<Awaited<T>> & { not: Matchers<Awaited<T>> } & Record<string, unknown>;
|
||||||
|
}
|
||||||
|
interface Matchers<T> {
|
||||||
|
toHaveBeenCalledOnce(): void;
|
||||||
|
toHaveBeenCalledExactlyOnceWith(...args: unknown[]): void;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import * as bt from 'bun:test';
|
||||||
|
|
||||||
|
const stubbed = new Map<string, PropertyDescriptor | undefined>();
|
||||||
|
const realUseFake = bt.vi.useFakeTimers.bind(bt.vi);
|
||||||
|
const flush = () => new Promise<void>((r) => setImmediate(r));
|
||||||
|
type AnyFn = (...args: any[]) => any;
|
||||||
|
type VitestCompat = Omit<typeof bt.vi, 'spyOn' | 'useFakeTimers'> & {
|
||||||
|
setSystemTime(now?: Date | number): VitestCompat;
|
||||||
|
spyOn<T extends object, K extends keyof T>(
|
||||||
|
obj: T,
|
||||||
|
method: K,
|
||||||
|
): bt.Mock<T[K] extends AnyFn ? T[K] : AnyFn>;
|
||||||
|
useFakeTimers(options?: { toFake?: string[] }): VitestCompat;
|
||||||
|
stubGlobal(name: string, value: unknown): VitestCompat;
|
||||||
|
unstubAllGlobals(): VitestCompat;
|
||||||
|
advanceTimersByTimeAsync(ms: number): Promise<void>;
|
||||||
|
runAllTicks(): void;
|
||||||
|
waitFor<T>(
|
||||||
|
fn: () => T | Promise<T>,
|
||||||
|
options?: { timeout?: number; interval?: number },
|
||||||
|
): Promise<T>;
|
||||||
|
};
|
||||||
|
export const vi = Object.assign(bt.vi, {
|
||||||
|
setSystemTime: (d?: Date | number) => bt.setSystemTime(d as any),
|
||||||
|
// Bun ignores toFake and fakes every timer; Date-only is emulated by setSystemTime alone.
|
||||||
|
useFakeTimers(o?: { toFake?: string[] }) {
|
||||||
|
if (o?.toFake?.length === 1 && o.toFake[0] === 'Date') return vi;
|
||||||
|
const now = Date.now();
|
||||||
|
realUseFake();
|
||||||
|
bt.setSystemTime(now);
|
||||||
|
return vi;
|
||||||
|
},
|
||||||
|
stubGlobal(name: string, value: unknown) {
|
||||||
|
if (!stubbed.has(name))
|
||||||
|
stubbed.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||||
|
Object.defineProperty(globalThis, name, { value, configurable: true, writable: true });
|
||||||
|
return vi;
|
||||||
|
},
|
||||||
|
unstubAllGlobals() {
|
||||||
|
for (const [n, d] of stubbed) {
|
||||||
|
if (d) Object.defineProperty(globalThis, n, d);
|
||||||
|
else delete (globalThis as any)[n];
|
||||||
|
}
|
||||||
|
stubbed.clear();
|
||||||
|
return vi;
|
||||||
|
},
|
||||||
|
async advanceTimersByTimeAsync(ms: number) {
|
||||||
|
await flush();
|
||||||
|
bt.vi.advanceTimersByTime(ms);
|
||||||
|
await flush();
|
||||||
|
},
|
||||||
|
runAllTicks() {},
|
||||||
|
async waitFor<T>(fn: () => T | Promise<T>, { timeout = 1000, interval = 50 } = {}): Promise<T> {
|
||||||
|
const end = performance.now() + timeout;
|
||||||
|
let last: unknown;
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (e) {
|
||||||
|
last = e;
|
||||||
|
if (performance.now() > end) throw last;
|
||||||
|
await new Promise((r) => setTimeout(r, interval));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}) as unknown as VitestCompat;
|
||||||
|
export const { describe, it, test, expect, beforeEach, afterEach, beforeAll, afterAll } = bt;
|
||||||
|
export type MockInstance<T extends AnyFn = AnyFn> = bt.Mock<T>;
|
||||||
|
|
||||||
|
// expect.poll: retry `expect(await fn())<.not>.matcher(...)` until it passes or times out.
|
||||||
|
(bt.expect as any).poll = (fn: () => unknown, { timeout = 1000, interval = 50 } = {}) => {
|
||||||
|
const chain = (negate: boolean): any =>
|
||||||
|
new Proxy(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
get: (_, m: string) =>
|
||||||
|
m === 'not'
|
||||||
|
? chain(!negate)
|
||||||
|
: async (...args: unknown[]) => {
|
||||||
|
const end = performance.now() + timeout;
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
const e: any = bt.expect(await fn());
|
||||||
|
return await (negate ? e.not : e)[m](...args);
|
||||||
|
} catch (err) {
|
||||||
|
if (performance.now() > end) throw err;
|
||||||
|
await new Promise((r) => setTimeout(r, interval));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return chain(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
bt.expect.extend({
|
||||||
|
toHaveBeenCalledExactlyOnceWith(fn: any, ...args: unknown[]) {
|
||||||
|
const calls = fn.mock.calls as unknown[][];
|
||||||
|
const pass = calls.length === 1 && this.equals(calls[0], args);
|
||||||
|
return {
|
||||||
|
pass,
|
||||||
|
message: () =>
|
||||||
|
`expected exactly one call with ${JSON.stringify(args)}, got ${JSON.stringify(calls)}`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
+1
-1
@@ -7,5 +7,5 @@
|
|||||||
"types": ["node"]
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": ["src/**/*.spec.ts"]
|
"exclude": ["src/**/*.spec.ts", "src/server.ts"]
|
||||||
}
|
}
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./out-tsc/spec",
|
"outDir": "./out-tsc/spec",
|
||||||
"types": ["vitest/globals"],
|
"types": ["bun", "bun-types/test-globals", "node"],
|
||||||
"target": "es2022"
|
"target": "es2022"
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
/// <reference types="vitest" />
|
|
||||||
|
|
||||||
import angular from '@analogjs/vite-plugin-angular';
|
|
||||||
|
|
||||||
import { defineConfig } from 'vite';
|
|
||||||
import { coverageConfigDefaults } from 'vitest/config';
|
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
|
||||||
export default defineConfig(({ mode }) => {
|
|
||||||
return {
|
|
||||||
plugins: [angular()],
|
|
||||||
test: {
|
|
||||||
globals: true,
|
|
||||||
environment: 'jsdom',
|
|
||||||
setupFiles: ['src/test-setup.ts'],
|
|
||||||
include: ['**/*.spec.ts'],
|
|
||||||
reporters: ['default'],
|
|
||||||
pool: 'forks',
|
|
||||||
coverage: {
|
|
||||||
provider: 'istanbul',
|
|
||||||
reporter: ['text', 'html', 'cobertura'],
|
|
||||||
exclude: [...coverageConfigDefaults.exclude, '**/frozen-engine/**'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
define: {
|
|
||||||
'import.meta.vitest': mode !== 'production',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
Reference in new issue
Block a user