RedlineKnowledge base

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 mockGitLabFetch in test/service.test.js stays as it is; new tests use this one.

Steps

  1. Create test/fixtures/mock-gitlab/data.js exporting 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. Export blockIds as a plain object matching the table, so tests can import expectations without re-deriving them.
  2. Create test/fixtures/mock-gitlab/server.js exporting:
    • createMockGitLab({ origin }) returning { fetch, state, reset, setDown }. fetch(input, init) is a fetch-compatible async function that routes on method and pathname and returns a Response. state is the in-memory store. The mock must implement every endpoint listed in fixture-data.md and respond 404 { message: "Unhandled <METHOD> <path>" } to anything else.
    • startMockGitLab({ port }) wrapping fetch in node:http (convert the incoming request to a Request, write the Response back) and returning { server, origin, close() }. Add the four /__mock/* control endpoints here.
    • A CLI: node test/fixtures/mock-gitlab/server.js --port 9999 starts the server and prints mock-gitlab listening on http://localhost:9999.
  3. Authorization: reads accept PRIVATE-TOKEN: mock-read-token or Authorization: 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.
  4. OAuth per fixture-data.md. The authorize redirect must echo state exactly and use the redirect_uri from the query.
  5. GET /api/v4/projects/1/repository/commits must honour path, ref_name and per_page, and include parent_ids on 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 ids
  • mock gitlab serves document content for every listed ref
  • mock gitlab fixture content matches the block id table (runs parseDocumentBlocks on V3 and compares ids to blockIds.V3)
  • mock gitlab oauth authorize redirects with code and state
  • mock gitlab creates issue discussions that persist until reset
  • mock gitlab down mode responds 503 and up mode recovers
  • mock 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 (files is unchanged; only add scripts)
  • Why: Playwright needs a real Astro page that renders the package components against the mock. scripts/check-astro-compat.js builds a throwaway site from the packed tarball; this fixture is committed and uses the working tree so component edits are testable without packing.

Steps

  1. Create test/fixtures/site/package.json:
    {
      "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:../../.." }
    }
    Run npm install inside it and commit the generated package-lock.json. Add test/fixtures/site/node_modules/ and test/fixtures/site/dist/ to .gitignore and .dockerignore.
  2. test/fixtures/site/astro.config.mjs: defineConfig({ integrations: [gitHistory()] }) importing gitHistory from @fridai/fio-redline. (T2.2 later adds { blocks: true }.)
  3. test/fixtures/site/src/content/doc.md: exactly V3.
  4. test/fixtures/site/src/env.d.ts declaring virtual:git-history as the README shows.
  5. test/fixtures/site/src/pages/index.astro:
    • imports Content from ../content/doc.md, GitHistoryFooter, GitTrackChanges, GitComments from the package subpaths, and getGitMeta from virtual: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 with serviceUrl, siteId="demo", documentPath="src/content/doc.md", and the footer with meta={meta} and liveRefreshMs={0}.
    • Give the footer id="history".
  6. Root package.json scripts: "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 to CONTENT.V3 from the mock data module)
  • fixture site builds with the working-tree package (spawns npm run fixture:build, asserts exit 0 and that test/fixtures/site/dist/index.html contains data-git-history-footer, data-git-track-changes and data-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

  1. npm install --save-dev --save-exact @playwright/test@<latest 1.x> and npx playwright install chromium. Record the exact version; the CI image tag must match it.
  2. test/e2e/run-service.js: sets process.env for the service exactly as follows, then imports createNodeServer from ../../service/src/server.js and listens on 8787. Values:
    GITLAB_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=8787
    Honour REDLINE_E2E_ANONYMOUS_READ when set (false overrides ALLOW_ANONYMOUS_READ) so one spec can test the 401 state.
  3. playwright.config.js (ESM): testDir: 'test/e2e', timeout: 30000, use: { baseURL: 'http://localhost:4321', trace: 'retain-on-failure' }, one chromium project, and webServer as 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: 180000 All with reuseExistingServer: !process.env.CI.
  4. test/e2e/health.spec.js: one test service health reports GitLab reachable using request.get('http://localhost:8787/v1/health'), asserting status 200, ok === true, gitlab.reachable === true.
  5. Root package.json script "test:e2e": "playwright test". Add playwright-report/, test-results/ to .gitignore.
  6. .gitlab-ci.yml: add job e2e in stage test with the same rules as test, image mcr.microsoft.com/playwright:v<version>-noble, before_script of npm ci, npm --prefix service ci, npm --prefix test/fixtures/site ci, script: npx playwright test, and artifacts: { 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 that node --version is 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, default cloudflare-access)
  • Why: the experiment runs without Cloudflare Access. OAuth must stand alone as the identity, and the dangerous REQUIRE_ACCESS=false header-trust path must not be the way to do it.
  • Fallback: if OAuth cannot be made to work end to end, implement IDENTITY_MODE=tailscale as network.md describes instead, with equivalent tests, and record the switch in STATUS.md. The e2e harness then sets the three Tailscale headers with page.setExtraHTTPHeaders.

Steps

  1. config.js loadServiceConfig: add identityMode: env.IDENTITY_MODE === 'gitlab-oauth' ? 'gitlab-oauth' : 'cloudflare-access'.
  2. config.js requestIdentity: when config.identityMode === 'gitlab-oauth', never read Cf-Access-Authenticated-User-Email or Cf-Access-Jwt-Assertion; return the service-token identity if the bearer matches, else null.
  3. index.js authenticatedIdentity: in gitlab-oauth mode return await oauthIdentity(request, env) when present, otherwise requestIdentity(...).
  4. index.js auth routes: in gitlab-oauth mode, start, callback, status and logout do not require an Access identity. status reports connected = Boolean(oauth).
  5. oauth.js: oauthStart and oauthCallback accept a missing accessIdentity in gitlab-oauth mode. The sealed state’s accessEmail is null; the session’s accessEmail is the GitLab user’s email. Add a helper cookieNames(env) that derives secure from String(env.PUBLIC_SERVICE_URL || '').startsWith('https:') and returns the two cookie names: with the __Host- prefix when secure, otherwise git_review_oauth_state and git_review_session. Emit the Secure attribute only when secure. oauthStart, oauthCallback, oauthIdentity and oauthLogout all use the helper.
  6. validate-env.js: ACCESS_TEAM_DOMAIN and ACCESS_AUDIENCE are required only in cloudflare-access mode. GITLAB_PUBLIC_URL accepts http: when NODE_ENV !== 'production'. In production, gitlab-oauth mode requires PUBLIC_SERVICE_URL to be https: and does not require REQUIRE_ACCESS to be true; cloudflare-access mode keeps today’s rule.
  7. Document the variable in service/README.md and ops/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 (forged Cf-Access-* headers with ALLOW_ANONYMOUS_READ=false → 401)
  • gitlab-oauth mode starts OAuth without an Access identity (GET start → 302 to http://localhost:9999/oauth/authorize?...; cloudflare-access mode on the same request → 401)
  • gitlab-oauth mode completes the callback and reports a connected user (follow start → state cookie; call callback with code=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/comments with the session cookie and WRITE_ENABLED=true → 201, and the mock state shows a note by user 2)
  • cookie security attributes follow the public service url scheme (http: → no Secure, 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-eslint and code-tsc components; 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

  1. .npmrc with the @fridai scope mapping exactly as the root README shows (environment-variable reference only, no token value). The executor needs FRIDAI_REGISTRY_READ_TOKEN in its environment; if it is absent, this task is blocked and the rest of wave 1 proceeds.
  2. npm install --save-dev --save-exact @fridai/[email protected] [email protected] [email protected] typescript@<exact latest 6.x> astro@<exact latest 7.x>. astro is needed because index.d.ts imports its types.
  3. prettier.config.mjs: export { default } from '@fridai/quality-rulesets/prettier';. If .astro files fail to parse, add prettier-plugin-astro (exact) and spread the shared config with plugins: ['prettier-plugin-astro']. .prettierignore: node_modules, **/node_modules, **/dist, .wrangler, **/.wrangler, package-lock.json, **/package-lock.json.
  4. 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 for test/** and say so in the commit body.
  5. 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"] }. checkJs stays false in this task; raising it is a later ratchet.
  6. Two commits: first the configuration and dependency changes, then style: apply the Fridai prettier ruleset containing 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

  1. gitlab.js createGitLabClient.request: wrap the fetch call in try/catch. Pass signal: AbortSignal.timeout(10_000). On a thrown error (network, abort) throw new 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 of gitlab-api-error.
  2. gitlab.js: export probeGitLab(config) that performs GET ${config.gitlabInternalUrl}/api/v4/version with PRIVATE-TOKEN and AbortSignal.timeout(3_000), caches the result for 15 seconds in a module map keyed by URL, and returns { reachable, checkedAt, status } per the design.
  3. index.js health route: include gitlab: await probeGitLab(config). Health stays 200 even when unreachable.

Acceptance

test/reachability.test.js:

  • health reports gitlab reachable when the version endpoint answers
  • health reports gitlab unreachable when fetch throws
  • document state responds 502 gitlab-unreachable when fetch throws
  • document state responds 502 gitlab-unreachable when gitlab answers 503
  • document 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
Git history

Loading the page's history…