Proof page. CI/CD and automated testing.
ApplyForge is a Next.js 16 TypeScript application. Every push and pull request installs from the lockfile and then has to clear four gates: typecheck, lint, unit tests and a production build, followed by a Docker image build that boots the container and checks it serves. The output below was not written by hand. This page is generated by a script that runs each command in the repository and pastes back its real stdout and exit code.
What this page does not claim. The workflow commit is in the local repository and has not been published yet, so no run has executed on GitHub and there is no green badge to show. Saying so is the point: the four gates below were verified by running them, and the badge becomes true only after the push. What is not captured here is the second job: the Docker image build and the container boot check are real steps in the workflow file below, but running them needs a Docker daemon and would publish nothing checkable to this page, so they are shown as workflow configuration only and no output is claimed for them.
The workflow does not hide its commands inside the runner. Every gate is an npm
script that runs identically on a laptop and in CI, and npm run verify chains all four so
the whole pipeline can be reproduced with one command before pushing.
| Command | Runs |
|---|---|
| npm run build | next build |
| npm run typecheck | tsc --noEmit |
| npm run lint | eslint . |
| npm run test | vitest run |
| npm run verify | npm run typecheck && npm run lint && npm run test && npm run build |
TypeScript compiles the whole project with no emit. A type error fails the pipeline before anything else runs.
$ npm run typecheck > applyforge@0.1.0 typecheck > tsc --noEmit
ESLint flat config over the whole repository, using the Next.js core web vitals rules plus the TypeScript rules. Next.js 16 removed the built in lint command, so the ESLint CLI is called directly.
$ npm run lint > applyforge@0.1.0 lint > eslint .
The unit suite over the ingestion, scoring, validation and storage logic. The network and the model API are mocked at the module boundary, so the suite runs offline and finishes in well under a second.
$ npm test
> applyforge@0.1.0 test
> vitest run --reporter=verbose
RUN v4.1.10 applyforge
✓ __tests__/pnet.test.ts > htmlToText > removes scripts, styles and tags 2ms
✓ __tests__/pnet.test.ts > htmlToText > decodes the entities that actually show up in job ads 0ms
✓ __tests__/pnet.test.ts > htmlToText > collapses runs of whitespace and trims 0ms
✓ __tests__/pnet.test.ts > htmlToText > returns an empty string for empty input 0ms
✓ __tests__/pnet.test.ts > fetchHtml > sends a browser user agent, because the board rejects the default one 0ms
✓ __tests__/pnet.test.ts > fetchHtml > throws with the status code when the board refuses 1ms
✓ __tests__/pnet.test.ts > pnetSearch > builds the keyword URL, and the location URL when a location slug is given 0ms
✓ __tests__/pnet.test.ts > pnetSearch > maps an embedded listing onto a typed item 0ms
✓ __tests__/pnet.test.ts > pnetSearch > fills in blanks for a listing with no salary, company or date 0ms
✓ __tests__/pnet.test.ts > pnetSearch > coerces an id sent as a string 0ms
✓ __tests__/pnet.test.ts > pnetSearch > drops entries with no title or no url 1ms
✓ __tests__/pnet.test.ts > pnetSearch > does not stop at a brace or a quote inside a snippet string 0ms
✓ __tests__/pnet.test.ts > pnetSearch > returns an empty list when the page has no embedded state 1ms
✓ __tests__/pnet.test.ts > pnetSearch > returns an empty list when the embedded state is truncated 1ms
✓ __tests__/pnet.test.ts > pnetSearch > returns an empty list when the state has no results at all 0ms
✓ __tests__/claude.test.ts > claudeJson request > parses the JSON body out of the text block 7ms
✓ __tests__/claude.test.ts > claudeJson request > sends the system prompt, the user prompt and the JSON schema 1ms
✓ __tests__/claude.test.ts > claudeJson request > honours a max token override 1ms
✓ __tests__/claude.test.ts > claudeJson request > reads the text block even when other block types come first 0ms
✓ __tests__/claude.test.ts > claudeJson failure handling > reports a safety refusal in plain words 3ms
✓ __tests__/claude.test.ts > claudeJson failure handling > names truncation instead of leaking a bare JSON syntax error 1ms
✓ __tests__/claude.test.ts > claudeJson failure handling > reports an empty or missing text block clearly 0ms
✓ __tests__/claude.test.ts > claudeJson failure handling > quotes the start of the payload when the JSON will not parse 0ms
✓ __tests__/claude.test.ts > claudeJson failure handling > lets a transport error from the SDK propagate 0ms
✓ __tests__/adzuna.test.ts > adzunaEnabled > is true only when both credentials are present 4ms
✓ __tests__/adzuna.test.ts > adzunaEnabled > returns no results and makes no request when the source is not configured 1ms
✓ __tests__/adzuna.test.ts > adzunaSearch query building > sends the credentials and the fixed feed settings 1ms
✓ __tests__/adzuna.test.ts > adzunaSearch query building > maps phrase and or-terms onto the right parameters 0ms
✓ __tests__/adzuna.test.ts > adzunaSearch query building > adds a search radius only when a location is given 0ms
✓ __tests__/adzuna.test.ts > adzunaSearch result mapping > builds a title from role, company, location and salary 1ms
✓ __tests__/adzuna.test.ts > adzunaSearch result mapping > omits the salary text entirely when the listing has no salary, instead of printing R undefined 0ms
✓ __tests__/adzuna.test.ts > adzunaSearch result mapping > reuses the minimum when only one end of the salary range is published 0ms
✓ __tests__/adzuna.test.ts > adzunaSearch result mapping > survives a listing with a null description 0ms
✓ __tests__/adzuna.test.ts > adzunaSearch result mapping > strips HTML out of the snippet and caps it at 300 characters 2ms
✓ __tests__/adzuna.test.ts > adzunaSearch result mapping > drops listings with no redirect URL, because they cannot be scraped or applied to 0ms
✓ __tests__/adzuna.test.ts > adzunaSearch failure handling > returns an empty list when the payload has no results key 0ms
✓ __tests__/adzuna.test.ts > adzunaSearch failure handling > throws a labelled error carrying the status code 8ms
✓ __tests__/validate.test.ts > parseAnalyzeResult > accepts a well formed payload and returns it typed 6ms
✓ __tests__/validate.test.ts > parseAnalyzeResult > clamps an out of range score instead of storing it 0ms
✓ __tests__/validate.test.ts > parseAnalyzeResult > rejects a payload that is not an object 2ms
✓ __tests__/validate.test.ts > parseAnalyzeResult > rejects a payload missing required fields and names every one of them 1ms
✓ __tests__/validate.test.ts > parseAnalyzeResult > rejects a job payload with no usable score 0ms
✓ __tests__/validate.test.ts > parseAnalyzeResult > rejects a wrong type on a nullable field 0ms
✓ __tests__/validate.test.ts > parseAnalyzeResult > treats a missing nullable field as null rather than undefined 1ms
✓ __tests__/validate.test.ts > parseAnalyzeResult > rejects qualifications or gaps that are not arrays 2ms
✓ __tests__/validate.test.ts > parseAnalyzeResult > drops junk entries inside qualifications and gaps 1ms
✓ __tests__/validate.test.ts > parseAnalyzeResult > still validates a not-a-job payload, where the model fills placeholders 0ms
✓ __tests__/validate.test.ts > parsePicks > reads picks from the wrapper object 0ms
✓ __tests__/validate.test.ts > parsePicks > accepts a bare array too 0ms
✓ __tests__/validate.test.ts > parsePicks > drops repeated indices, so one advert is never analysed twice 0ms
✓ __tests__/validate.test.ts > parsePicks > drops indices outside the candidate list 0ms
✓ __tests__/validate.test.ts > parsePicks > drops non integer indices 0ms
✓ __tests__/validate.test.ts > parsePicks > coerces an integer sent as a string 0ms
✓ __tests__/validate.test.ts > parsePicks > returns an empty list when the model shortlists nothing 0ms
✓ __tests__/validate.test.ts > parsePicks > throws when the payload has no picks array at all 1ms
✓ __tests__/analyze.test.ts > resultToJob field mapping > maps every model field onto the stored job 11ms
✓ __tests__/analyze.test.ts > resultToJob field mapping > falls back to the source URL when the ad exposes no apply link 0ms
✓ __tests__/analyze.test.ts > resultToJob field mapping > leaves applyUrl null for pasted text with no source URL 0ms
✓ __tests__/analyze.test.ts > resultToJob field mapping > keeps a missing salary as null instead of inventing a range 0ms
✓ __tests__/analyze.test.ts > resultToJob field mapping > clamps a score the model pushed out of range 1ms
✓ __tests__/analyze.test.ts > resultToJob field mapping > truncates the stored advert text at 20000 characters 1ms
✓ __tests__/analyze.test.ts > resultToJob field mapping > stamps a unique id and a parseable ISO timestamp 0ms
✓ __tests__/analyze.test.ts > analyzeJob > sends the CV and the page text to the model and returns a validated result 0ms
✓ __tests__/analyze.test.ts > analyzeJob > caps the page text sent to the model at 16000 characters 0ms
✓ __tests__/analyze.test.ts > analyzeJob > rejects a malformed model payload instead of storing a broken job 1ms
✓ __tests__/analyze.test.ts > analyzeJob > rejects a payload where the score came back as prose 0ms
✓ __tests__/analyze.test.ts > triageCandidates > returns the shortlisted indices 0ms
✓ __tests__/analyze.test.ts > triageCandidates > sanitises indices the model invented 0ms
✓ __tests__/analyze.test.ts > triageCandidates > numbers the candidates in the prompt so the indices mean something 0ms
✓ __tests__/analyze.test.ts > generatePack > maps the model output onto an application pack 0ms
✓ __tests__/analyze.test.ts > generatePack > leaves the email draft null when the ad does not take email applications 0ms
✓ __tests__/analyze.test.ts > generatePack > falls back to the job title when a body arrives with no subject 0ms
✓ __tests__/analyze.test.ts > generatePack > gives the model the known gaps so the letter stays honest 0ms
✓ __tests__/store.test.ts > store round trip > creates the data directory and an empty db on first read 46ms
✓ __tests__/store.test.ts > store round trip > writes and reads back a job unchanged 3ms
✓ __tests__/store.test.ts > store round trip > survives a full write, read, mutate, write cycle 6ms
✓ __tests__/store.test.ts > store round trip > writes the file as readable indented JSON 4ms
✓ __tests__/store.test.ts > store round trip > puts a newly added job at the top of the list 4ms
✓ __tests__/store.test.ts > store round trip > updates in place rather than appending a second copy 8ms
✓ __tests__/store.test.ts > store round trip > deletes only the requested job 5ms
✓ __tests__/store.test.ts > store round trip > returns undefined for an unknown id and no-ops on an unknown delete 1ms
✓ __tests__/store.test.ts > store round trip > reads the CV, and returns an empty string when there is none 1ms
✓ __tests__/store.test.ts > store hasUrl dedupe > matches the exact stored URL 4ms
✓ __tests__/store.test.ts > store hasUrl dedupe > matches the same listing with a trailing slash, www or http 1ms
✓ __tests__/store.test.ts > store hasUrl dedupe > matches the same listing carrying campaign tracking parameters 2ms
✓ __tests__/store.test.ts > store hasUrl dedupe > does not match a different listing 2ms
✓ __tests__/store.test.ts > store hasUrl dedupe > does not match on an empty URL, even though jobs can have a null sourceUrl 3ms
✓ __tests__/store.test.ts > store resilience > treats a db file with no jobs array as empty 3ms
✓ __tests__/store.test.ts > store resilience > refuses to overwrite a corrupted db file, so scored jobs are not lost 2ms
✓ __tests__/scan-helpers.test.ts > interleave > takes one from each source in turn 2ms
✓ __tests__/scan-helpers.test.ts > interleave > keeps going once the shorter sources run out, with no undefined holes 0ms
✓ __tests__/scan-helpers.test.ts > interleave > handles no sources and all empty sources 0ms
✓ __tests__/scan-helpers.test.ts > interleave > preserves order inside a single source 0ms
✓ __tests__/scan-helpers.test.ts > interleave > gives a broad source no more than its turn in the first slots 0ms
✓ __tests__/scan-helpers.test.ts > dedupeCandidates > keeps distinct listings in their original order 0ms
✓ __tests__/scan-helpers.test.ts > dedupeCandidates > drops an exact repeat from a second query 1ms
✓ __tests__/scan-helpers.test.ts > dedupeCandidates > drops the same listing arriving with a trailing slash, www or http 0ms
✓ __tests__/scan-helpers.test.ts > dedupeCandidates > drops the same listing arriving with campaign tracking parameters, which used to be analysed twice 0ms
✓ __tests__/scan-helpers.test.ts > dedupeCandidates > keeps listings that differ only in a meaningful query parameter 0ms
✓ __tests__/scan-helpers.test.ts > dedupeCandidates > drops listings the store already holds 0ms
✓ __tests__/scan-helpers.test.ts > dedupeCandidates > asks the store using the normalised key, not the raw URL 1ms
✓ __tests__/scan-helpers.test.ts > dedupeCandidates > drops entries with an unusable URL 0ms
✓ __tests__/scan-helpers.test.ts > resolvePicks > maps indices back to candidates in the order the model ranked them 0ms
✓ __tests__/scan-helpers.test.ts > resolvePicks > never returns more than the cap 0ms
✓ __tests__/scan-helpers.test.ts > resolvePicks > drops out of range and non integer indices instead of returning undefined 0ms
✓ __tests__/scan-helpers.test.ts > resolvePicks > returns nothing when the model shortlisted nothing 0ms
✓ __tests__/normalize.test.ts > normalizeScore > keeps a valid score unchanged 1ms
✓ __tests__/normalize.test.ts > normalizeScore > holds the 0 and 100 boundaries 0ms
✓ __tests__/normalize.test.ts > normalizeScore > clamps above 100 and below 0 0ms
✓ __tests__/normalize.test.ts > normalizeScore > rounds a fractional score to an integer 0ms
✓ __tests__/normalize.test.ts > normalizeScore > accepts a numeric string, because models return "73" often enough 0ms
✓ __tests__/normalize.test.ts > normalizeScore > returns null for values that are not a score 0ms
✓ __tests__/normalize.test.ts > normalizeUrl > strips a trailing slash 0ms
✓ __tests__/normalize.test.ts > normalizeUrl > treats http, https, www and host casing as the same listing 1ms
✓ __tests__/normalize.test.ts > normalizeUrl > keeps path case, because paths can be case sensitive 0ms
✓ __tests__/normalize.test.ts > normalizeUrl > drops tracking parameters and the fragment 0ms
✓ __tests__/normalize.test.ts > normalizeUrl > keeps meaningful query parameters and orders them 0ms
✓ __tests__/normalize.test.ts > normalizeUrl > is stable when called twice 0ms
✓ __tests__/normalize.test.ts > normalizeUrl > returns an empty string for empty input 0ms
✓ __tests__/normalize.test.ts > normalizeUrl > falls back to a trimmed lowercase key for non URL input 0ms
✓ __tests__/normalize.test.ts > normalizeUrl > leaves a non http scheme alone rather than rewriting it to https 0ms
Test Files 8 passed (8)
Tests 121 passed (121)
Start at 14:55:19
Duration 566ms (transform 461ms, setup 143ms, import 623ms, tests 196ms, environment 1ms)
The production Next.js build, standalone output. It compiles every route and collects page data, so it catches what a typecheck cannot: a route that fails at build time. distDir is a config-only option in Next.js 16, so this page builds into an isolated directory through NEXT_DIST_DIR rather than into .next, which means capturing this gate cannot disturb a development server that is already running. No API key is set for it, because the Claude client is constructed lazily on first use, so a build never needs a credential to exist.
$ npm run build > applyforge@0.1.0 build > next build ▲ Next.js 16.2.12 (Turbopack) - Environments: .env.local Creating an optimized production build ... ✓ Compiled successfully in 4.4s Running TypeScript ... Finished TypeScript in 2.2s ... Collecting page data using 7 workers ... Generating static pages using 7 workers (0/6) ... Generating static pages using 7 workers (1/6) Generating static pages using 7 workers (2/6) Generating static pages using 7 workers (4/6) ✓ Generating static pages using 7 workers (6/6) in 117ms Finalizing page optimization ... Route (app) ┌ ○ / ├ ○ /_not-found ├ ƒ /api/jobs ├ ƒ /api/jobs/[id] ├ ƒ /api/jobs/[id]/pack └ ƒ /api/jobs/scan ○ (Static) prerendered as static content ƒ (Dynamic) server-rendered on demand
Two jobs. The first runs the four gates above. The second depends on the first, so no Docker build minutes are spent when a typecheck has already failed, and it boots the built image to confirm it serves HTTP 200 as a non root user, because an image that builds is not the same as an image that runs. A concurrency group cancels superseded runs, since a newer push makes an older run's result worthless. Step names in order:
name: CI
# Push covers main, pull_request covers everything on its way to main. Keeping
# push on every branch as well would fire two runs of the identical commit for a
# same repo pull request, because the concurrency group below is keyed on
# github.ref and those two events carry different refs, so neither cancels the
# other. Two full Docker builds per pull request is a real cost for no signal.
on:
push:
branches: [main]
pull_request:
# A newer push to the same ref makes the previous run pointless, so cancel it.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
NEXT_TELEMETRY_DISABLED: '1'
# No ANTHROPIC_API_KEY, not even a fake one. src/lib/claude.ts constructs the
# Anthropic client lazily on first use, so neither the build nor the test suite
# needs a credential to exist, and no CI step calls the Claude API.
jobs:
quality:
name: Typecheck, lint, test, build
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up Node
uses: actions/setup-node@v7
with:
node-version: '24'
cache: npm
- name: Install dependencies
run: npm ci
- name: Typecheck
run: npm run typecheck
- name: Lint
run: npm run lint
- name: Test
run: npm test
- name: Build
run: npm run build
docker:
name: Docker image build
runs-on: ubuntu-latest
timeout-minutes: 20
needs: quality
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up Buildx
uses: docker/setup-buildx-action@v4
- name: Build image (build only, never pushed)
uses: docker/build-push-action@v7
with:
context: .
push: false
load: true
tags: applyforge:ci
cache-from: type=gha
# ignore-error keeps a cache export hiccup from failing the build. The
# image build itself is still a hard gate.
cache-to: type=gha,mode=max,ignore-error=true
- name: Verify the image boots and serves the UI
run: |
# No API key is passed. The boot check only asks whether the server
# comes up and serves the UI, and the Claude client is built lazily,
# so nothing on that path needs a credential.
docker run -d --name applyforge-ci -p 3000:3000 applyforge:ci
for _ in $(seq 1 30); do
if curl -fsS http://127.0.0.1:3000/ >/dev/null 2>&1; then
echo "Container is serving on port 3000."
docker rm -f applyforge-ci
exit 0
fi
sleep 2
done
echo "Container did not become healthy in time."
docker logs applyforge-ci
docker rm -f applyforge-ci
exit 1