B0 Foundations
Test infrastructure and the two service changes the experiment needs. Nothing here is visible to a reader.
T0.1 Mock GitLab server
- Wave: 1
- Depends on: none
- Size: L
- Design: fixture-data.md
- Touches:
test/fixtures/mock-gitlab/server.js(new),test/fixtures/mock-gitlab/data.js(new),test/mock-gitlab.test.js(new) - Why: unit tests and Playwright need one GitLab that behaves the same way. The in-process
mockGitLabFetchintest/service.test.jsstays as it is; new tests use this one.
Steps
- Create
test/fixtures/mock-gitlab/data.jsexporting every value in fixture-data.md as constants:PROJECT,USER,COMMITS(c1, c2, c3, d1 with all fields),CONTENT({ V1, V2, V3, VMR }),REFS(map of sha and branch name to version key),MERGE_REQUEST,TAGS. ExportblockIdsas a plain object matching the table, so tests can import expectations without re-deriving them. - Create
test/fixtures/mock-gitlab/server.jsexporting:createMockGitLab({ origin })returning{ fetch, state, reset, setDown }.fetch(input, init)is afetch-compatible async function that routes on method and pathname and returns aResponse.stateis the in-memory store. The mock must implement every endpoint listed in fixture-data.md and respond404 { message: "Unhandled <METHOD> <path>" }to anything else.startMockGitLab({ port })wrappingfetchinnode:http(convert the incoming request to aRequest, write theResponseback) and returning{ server, origin, close() }. Add the four/__mock/*control endpoints here.- A CLI:
node test/fixtures/mock-gitlab/server.js --port 9999starts the server and printsmock-gitlab listening on http://localhost:9999.
- Authorization: reads accept
PRIVATE-TOKEN: mock-read-tokenorAuthorization: Bearer mock-user-token; writes (POST, PUT, DELETE under/api/v4/) require the bearer token and otherwise respond 401./api/v4/version,/oauth/*and/__mock/*need no authorization. - OAuth per fixture-data.md. The
authorizeredirect must echostateexactly and use theredirect_urifrom the query. GET /api/v4/projects/1/repository/commitsmust honourpath,ref_nameandper_page, and includeparent_idson every commit.
Acceptance
test/mock-gitlab.test.js with these tests, all passing under npm test:
mock gitlab lists document commits newest first with parent idsmock gitlab serves document content for every listed refmock gitlab fixture content matches the block id table(runsparseDocumentBlockson V3 and compares ids toblockIds.V3)mock gitlab oauth authorize redirects with code and statemock gitlab creates issue discussions that persist until resetmock gitlab down mode responds 503 and up mode recoversmock gitlab rejects writes without the user bearer token
npm test 2>&1 | grep -c "^✔ mock gitlab" # prints 7
node test/fixtures/mock-gitlab/server.js --port 9999 & sleep 1
curl -s http://localhost:9999/api/v4/version # {"version":"18.3.0","revision":"mock"}
kill %1
T0.2 Fixture Astro site
- Wave: 1
- Depends on: none
- Size: M
- Design: fixture-data.md, network.md
- Touches:
test/fixtures/site/**(new),test/fixture-site.test.js(new),.gitignore,.dockerignore,package.json(filesis unchanged; only add scripts) - Why: Playwright needs a real Astro page that renders the package components against the mock.
scripts/check-astro-compat.jsbuilds a throwaway site from the packed tarball; this fixture is committed and uses the working tree so component edits are testable without packing.
Steps
- Create
test/fixtures/site/package.json:
Run{ "name": "fio-redline-fixture-site", "private": true, "type": "module", "scripts": { "build": "astro build", "preview": "astro preview" }, "dependencies": { "astro": "<exact latest 7.x>", "@fridai/fio-redline": "file:../../.." } }npm installinside it and commit the generatedpackage-lock.json. Addtest/fixtures/site/node_modules/andtest/fixtures/site/dist/to.gitignoreand.dockerignore. test/fixtures/site/astro.config.mjs:defineConfig({ integrations: [gitHistory()] })importinggitHistoryfrom@fridai/fio-redline. (T2.2 later adds{ blocks: true }.)test/fixtures/site/src/content/doc.md: exactlyV3.test/fixtures/site/src/env.d.tsdeclaringvirtual:git-historyas the README shows.test/fixtures/site/src/pages/index.astro:- imports
Contentfrom../content/doc.md,GitHistoryFooter,GitTrackChanges,GitCommentsfrom the package subpaths, andgetGitMetafromvirtual:git-history; const meta = getGitMeta('test/fixtures/site/src/content/doc.md');- reads
const serviceUrl = import.meta.env.PUBLIC_REDLINE_SERVICE_URL || 'http://localhost:8787'; - renders
<main><article data-redline-document><Content /></article>followed by the three components withserviceUrl,siteId="demo",documentPath="src/content/doc.md", and the footer withmeta={meta}andliveRefreshMs={0}. - Give the footer
id="history".
- imports
- Root
package.jsonscripts:"fixture:build": "npm --prefix test/fixtures/site run build","fixture:preview": "npm --prefix test/fixtures/site run preview -- --port 4321 --host 127.0.0.1".
Acceptance
test/fixture-site.test.js:
fixture document equals the canonical V3 content(reads the file, compares toCONTENT.V3from the mock data module)fixture site builds with the working-tree package(spawnsnpm run fixture:build, asserts exit 0 and thattest/fixtures/site/dist/index.htmlcontainsdata-git-history-footer,data-git-track-changesanddata-git-comments)
npm test 2>&1 | grep -c "^✔ fixture" # prints 2
T0.3 Playwright harness and CI job
- Wave: 2
- Depends on: T0.1, T0.2, T0.4, T0.5
- Size: M
- Design: network.md (harness topology)
- Touches:
package.json,package-lock.json,playwright.config.js(new),test/e2e/run-service.js(new),test/e2e/health.spec.js(new),.gitlab-ci.yml,.gitignore - Why: the UX gate is a set of Playwright specs; this task makes the first one run locally and in CI.
Steps
npm install --save-dev --save-exact @playwright/test@<latest 1.x>andnpx playwright install chromium. Record the exact version; the CI image tag must match it.test/e2e/run-service.js: setsprocess.envfor the service exactly as follows, then importscreateNodeServerfrom../../service/src/server.jsand listens on 8787. Values:
HonourGITLAB_PUBLIC_URL=http://localhost:9999 GITLAB_INTERNAL_URL=http://localhost:9999 GITLAB_TOKEN=mock-read-token GITLAB_WEBHOOK_SECRET=mock-webhook-secret-mock-webhook-secret GITLAB_OAUTH_CLIENT_ID=mock-client GITLAB_OAUTH_CLIENT_SECRET=mock-client-secret SESSION_SECRET=0123456789abcdef0123456789abcdef DRAFT_SIGNING_SECRET=fedcba9876543210fedcba9876543210 PUBLIC_SERVICE_URL=http://localhost:8787 IDENTITY_MODE=gitlab-oauth ALLOW_ANONYMOUS_READ=true REQUIRE_ACCESS=false WRITE_ENABLED=true SITES_JSON={"demo":{"gitlabProjectId":1,"gitlabProjectPath":"group/project","defaultBranch":"main","contentRoots":["src/content"],"allowedOrigins":["http://localhost:4321"],"features":{"comments":true,"editing":false,"approvals":false,"merging":false}}} PORT=8787REDLINE_E2E_ANONYMOUS_READwhen set (falseoverridesALLOW_ANONYMOUS_READ) so one spec can test the 401 state.playwright.config.js(ESM):testDir: 'test/e2e',timeout: 30000,use: { baseURL: 'http://localhost:4321', trace: 'retain-on-failure' }, onechromiumproject, andwebServeras an array of three entries in this order:- mock:
node test/fixtures/mock-gitlab/server.js --port 9999,url: http://localhost:9999/api/v4/version - service:
node test/e2e/run-service.js,url: http://localhost:8787/v1/health - site:
npm run fixture:build && npm run fixture:preview,url: http://localhost:4321/,timeout: 180000All withreuseExistingServer: !process.env.CI.
- mock:
test/e2e/health.spec.js: one testservice health reports GitLab reachableusingrequest.get('http://localhost:8787/v1/health'), asserting status 200,ok === true,gitlab.reachable === true.- Root
package.jsonscript"test:e2e": "playwright test". Addplaywright-report/,test-results/to.gitignore. .gitlab-ci.yml: add jobe2ein stagetestwith the samerulesastest, imagemcr.microsoft.com/playwright:v<version>-noble,before_scriptofnpm ci,npm --prefix service ci,npm --prefix test/fixtures/site ci,script: npx playwright test, andartifacts: { when: on_failure, paths: [playwright-report/, test-results/], expire_in: 1 week }. Keep the existing comment style and cite the job’s purpose in a comment. Verify in the image thatnode --versionis 22 or newer. If it is older, record a blocker; do not install Node inside the job.
Acceptance
npm run test:e2e 2>&1 | tail -3 # "1 passed"
git diff --stat main -- .gitlab-ci.yml # shows only the added e2e job
Pipeline on experiment/redline shows job e2e green.
T0.4 Identity mode gitlab-oauth
- Wave: 1
- Depends on: none
- Size: M
- Design: state-machine.md (identity mode), network.md (cookie constraints)
- Touches:
service/src/config.js,service/src/index.js,service/src/oauth.js,service/src/validate-env.js,test/identity-mode.test.js(new),service/README.md(one table row and one paragraph),ops/service.env.example(add the variable, defaultcloudflare-access) - Why: the experiment runs without Cloudflare Access. OAuth must stand alone as the identity, and the dangerous
REQUIRE_ACCESS=falseheader-trust path must not be the way to do it. - Fallback: if OAuth cannot be made to work end to end, implement
IDENTITY_MODE=tailscaleas network.md describes instead, with equivalent tests, and record the switch inSTATUS.md. The e2e harness then sets the three Tailscale headers withpage.setExtraHTTPHeaders.
Steps
config.jsloadServiceConfig: addidentityMode: env.IDENTITY_MODE === 'gitlab-oauth' ? 'gitlab-oauth' : 'cloudflare-access'.config.jsrequestIdentity: whenconfig.identityMode === 'gitlab-oauth', never readCf-Access-Authenticated-User-EmailorCf-Access-Jwt-Assertion; return the service-token identity if the bearer matches, elsenull.index.jsauthenticatedIdentity: ingitlab-oauthmode returnawait oauthIdentity(request, env)when present, otherwiserequestIdentity(...).index.jsauth routes: ingitlab-oauthmode,start,callback,statusandlogoutdo not require an Access identity.statusreportsconnected = Boolean(oauth).oauth.js:oauthStartandoauthCallbackaccept a missingaccessIdentityingitlab-oauthmode. The sealed state’saccessEmailisnull; the session’saccessEmailis the GitLab user’s email. Add a helpercookieNames(env)that derivessecurefromString(env.PUBLIC_SERVICE_URL || '').startsWith('https:')and returns the two cookie names: with the__Host-prefix when secure, otherwisegit_review_oauth_stateandgit_review_session. Emit theSecureattribute only when secure.oauthStart,oauthCallback,oauthIdentityandoauthLogoutall use the helper.validate-env.js:ACCESS_TEAM_DOMAINandACCESS_AUDIENCEare required only incloudflare-accessmode.GITLAB_PUBLIC_URLacceptshttp:whenNODE_ENV !== 'production'. In production,gitlab-oauthmode requiresPUBLIC_SERVICE_URLto behttps:and does not requireREQUIRE_ACCESSto be true;cloudflare-accessmode keeps today’s rule.- Document the variable in
service/README.mdandops/service.env.example.
Acceptance
test/identity-mode.test.js, using the mock from T0.1 (createMockGitLab().fetch
assigned to globalThis.fetch for the duration of each test):
gitlab-oauth mode ignores Cloudflare Access headers(forgedCf-Access-*headers withALLOW_ANONYMOUS_READ=false→ 401)gitlab-oauth mode starts OAuth without an Access identity(GET start → 302 tohttp://localhost:9999/oauth/authorize?...;cloudflare-accessmode on the same request → 401)gitlab-oauth mode completes the callback and reports a connected user(follow start → state cookie; call callback withcode=mock-code&state=<state>→ 302 and a session cookie; call status with the session cookie →{ connected: true, user: { username: 'test' } })gitlab-oauth mode attributes writes to the OAuth user(POST/v1/sites/demo/commentswith the session cookie andWRITE_ENABLED=true→ 201, and the mock state shows a note by user 2)cookie security attributes follow the public service url scheme(http:→ noSecure, no__Host-;https:→ both)environment validation requires Access settings only in cloudflare-access mode
npm test 2>&1 | grep -c "^✔ gitlab-oauth mode\|^✔ cookie security\|^✔ environment validation" # prints 6
All pre-existing tests still pass unchanged.
T0.6 Verify rulesets: prettier, eslint, tsc
- Wave: 1
- Depends on: none
- Size: M
- Touches:
package.json,package-lock.json,.npmrc(new),prettier.config.mjs(new),.prettierignore(new),eslint.config.mjs(new),tsconfig.json(new), every file prettier rewrites - Why: the pipeline already includes the catalog’s
code-prettier,code-eslintandcode-tsccomponents; they are inactive only because the configuration files were never added. Activating them now means every later task is checked the same way the consuming site is.
Steps
.npmrcwith the@fridaiscope mapping exactly as the root README shows (environment-variable reference only, no token value). The executor needsFRIDAI_REGISTRY_READ_TOKENin its environment; if it is absent, this task is blocked and the rest of wave 1 proceeds.npm install --save-dev --save-exact @fridai/[email protected] [email protected] [email protected] typescript@<exact latest 6.x> astro@<exact latest 7.x>.astrois needed becauseindex.d.tsimports its types.prettier.config.mjs:export { default } from '@fridai/quality-rulesets/prettier';. If.astrofiles fail to parse, addprettier-plugin-astro(exact) and spread the shared config withplugins: ['prettier-plugin-astro']..prettierignore:node_modules,**/node_modules,**/dist,.wrangler,**/.wrangler,package-lock.json,**/package-lock.json.eslint.config.mjs:import rules from '@fridai/quality-rulesets/eslint'; export default [{ ignores: ['**/node_modules/**', '**/dist/**', '**/.wrangler/**'] }, ...rules];. Fix every reported problem in source; do not disable rules file-wide unless the shared ruleset is wrong for Node test files, in which case add a scoped override fortest/**and say so in the commit body.tsconfig.json:{ "compilerOptions": { "noEmit": true, "allowJs": true, "checkJs": false, "module": "nodenext", "moduleResolution": "nodenext", "target": "es2022", "skipLibCheck": true, "strict": true }, "include": ["index.d.ts", "*.js", "service/src/*.js", "scripts/*.js"], "exclude": ["node_modules", "test/fixtures/site"] }.checkJsstays false in this task; raising it is a later ratchet.- Two commits: first the configuration and dependency changes, then
style: apply the Fridai prettier rulesetcontaining only prettier’s rewrites, so reviewers can skip the second.
Acceptance
npx prettier --check . # "All matched files use Prettier code style!"
npx eslint . # no output, exit 0
npx tsc --noEmit # no output, exit 0
npm test 2>&1 | grep -E '^ℹ fail ' # fail 0
Pipeline on experiment/redline shows jobs prettier, eslint and tsc
green.
T0.5 Health probe and gitlab-unreachable
- Wave: 1
- Depends on: none
- Size: S
- Design: state-machine.md (Health, error code)
- Touches:
service/src/gitlab.js,service/src/index.js,test/reachability.test.js(new) - Why: the client must tell “service down” from “GitLab down”. Today both look like a generic failure.
Steps
gitlab.jscreateGitLabClient.request: wrap thefetchcall intry/catch. Passsignal: AbortSignal.timeout(10_000). On a thrown error (network, abort) thrownew ServiceError(502, 'gitlab-unreachable', 'GitLab could not be reached.', { path: url.pathname }). When the response status is 502, 503 or 504 after retries, throw the same code instead ofgitlab-api-error.gitlab.js: exportprobeGitLab(config)that performsGET ${config.gitlabInternalUrl}/api/v4/versionwithPRIVATE-TOKENandAbortSignal.timeout(3_000), caches the result for 15 seconds in a module map keyed by URL, and returns{ reachable, checkedAt, status }per the design.index.jshealth route: includegitlab: await probeGitLab(config). Health stays 200 even when unreachable.
Acceptance
test/reachability.test.js:
health reports gitlab reachable when the version endpoint answershealth reports gitlab unreachable when fetch throwsdocument state responds 502 gitlab-unreachable when fetch throwsdocument state responds 502 gitlab-unreachable when gitlab answers 503document state keeps gitlab-api-error for a 404 upstream
npm test 2>&1 | grep -c "^✔ health reports\|^✔ document state responds\|^✔ document state keeps" # prints 5