Establish use-case regression collection and real browser gate
Some checks failed
Use-case regression gate / usecase-regressions (push) Failing after 6m2s

Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a10e2d-a5ef-7e92-b03d-b1694e915680
This commit is contained in:
tegwick 2026-10-06 14:02:01 +02:00
parent 7d21dbf705
commit a1989f19db
24 changed files with 778 additions and 8 deletions

View file

@ -0,0 +1,38 @@
# Uses the fleet's ubuntu-latest container label (node:20-bookworm).
# Archive checkout follows railiance-forge's non-root-host runner guidance.
name: Use-case regression gate
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
usecase-regressions:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Check out the triggering revision and prepare test tools
run: |
set -eu
apt-get update
apt-get install -y --no-install-recommends python3 python3-venv make ca-certificates curl
python3 -m venv /tmp/usecase-bootstrap
/tmp/usecase-bootstrap/bin/pip install uv==0.5.9
SHORT=$(printf '%s' "$GITHUB_SHA" | cut -c1-12)
mkdir -p source
curl -fsSL "https://forgejo.coulomb.social/${GITHUB_REPOSITORY}/archive/${SHORT}.tar.gz" -o /tmp/usecase-source.tar.gz
tar -xzf /tmp/usecase-source.tar.gz -C source --strip-components=1
cd source
/tmp/usecase-bootstrap/bin/uv sync --locked --python 3.12
npm ci --prefix scripts
cd scripts
npx playwright install --with-deps chromium
- name: Run full application and established use-case gate
run: |
set -eu
export PATH="/tmp/usecase-bootstrap/bin:$PATH"
cd source
make check
echo "JUnit report: source/var/test-results/check.xml"

1
.gitignore vendored
View file

@ -10,7 +10,6 @@
**/*cookie*.txt
docs/migration/samples/_local/
scripts/node_modules/
scripts/package-lock.json
# Local env overrides
.env

View file

@ -246,3 +246,18 @@ To create a new workplan:
1. Write the file following the format above
2. Run `statehub fix-consistency` locally; ask the operator only if the CLI or
State Hub API is unavailable.
## Established use-case regression gate
For changes to established product behavior or its tests, run `make check` before
commit: it includes the required real-browser journeys and registry coverage
validation. `make test` is the fast iteration command and explicitly deselects
browser tests; it is not the full acceptance gate. Install locked Node/browser
prerequisites with `make setup-usecases` (see docs/usecases/README.md).
Established journeys live in docs/usecases/registry.json, with user-confirmed
behavior contracts, synthetic fixtures and mandatory fast/browser test node IDs.
Do not skip/xfail or silently remove a protected journey to pass the gate. Update
contract, mapping and tests together for intentional user-approved changes.
Never use private protocols, live accounts, production URLs or email sends in
regression tests. Automated success does not mark transfer accepted by the user.

View file

@ -1,4 +1,4 @@
.PHONY: help sync migrate run test lint shell
.PHONY: help sync migrate run test lint shell check test-usecases test-browser setup-usecases
.DEFAULT_GOAL := help
@ -23,3 +23,16 @@ lint: ## ruff
shell: ## Django shell
uv run manage.py shell
setup-usecases: ## Install locked Node deps and Chromium for use-case checks
npm ci --prefix scripts
cd scripts && npx playwright install chromium
test-usecases: ## Required established journeys (fast + real browser), JUnit report
uv run pytest --verify-usecases -m usecase --junitxml=var/test-results/usecases.xml
test-browser: ## Browser journeys against isolated test app
uv run pytest --verify-usecases -m browser --junitxml=var/test-results/browser.xml
check: ## Full regression gate, including every established browser journey
uv run pytest --verify-usecases -m '' --junitxml=var/test-results/check.xml

View file

@ -17,7 +17,9 @@ spaces/pages with Title/Abstract/Visual + page copy/transfer —
uv sync
uv run manage.py migrate
make run # http://127.0.0.1:8008
make test
make test # fast tests
make setup-usecases
make check # required full regression gate, including browser use cases
```
With `DEBUG` and `OIDC_ENABLED=false` (defaults), **Sign in** uses a local
@ -45,3 +47,10 @@ dev-claims form. Platform OIDC: set env vars in `docs/dev.md`.
## License
Target Revenue Source License (TRSL V1C1). See [`LICENSE`](LICENSE).
## Established use cases
[Use-case collection and test infrastructure](docs/usecases/README.md) protects
user-confirmed workflows from regression, starting with ReichelAg weekly calls.
Run `make check` before accepting changes; automated success and productive
transfer acceptance remain separate.

View file

@ -89,3 +89,9 @@ Delivery: `docs/deploy.md`; residual records: `docs/intakes/csoc-residuals.md`.
First user-confirmed review case: ReichelAg weekly founder-call protocols
(UC-008), full duplication and manual editing plus clipboard copy for external
email. See docs/usecases/reichelag-weekly-call.md; live transfer is pending.
Established use-case regression protection is in docs/usecases/registry.json
and tests/usecases/, starting with ReichelAg. `make check` runs fast/application
and actual-browser journeys with mandatory test coverage validation and JUnit
reports. Forgejo workflow is configured; remote runner/required-merge-check
acceptance remains CSOC-IN-0005. User transfer acceptance stays separate.

View file

@ -16,6 +16,7 @@
| workplan | CSOC-WP-0006 | finished | — | workplans/CSOC-WP-0006-stage-1-space-page-capabilities.md |
| workplan | CSOC-WP-0007 | finished | — | workplans/CSOC-WP-0007-safe-transfer-readiness.md |
| workplan | CSOC-WP-0008 | active | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md |
| workplan | CSOC-WP-0009 | finished | — | workplans/CSOC-WP-0009-usecase-regression-infrastructure.md |
| task | CSOC-WP-0001-T01 | done | — | workplans/CSOC-WP-0001-bubble-io-exit-assessment.md |
| task | CSOC-WP-0001-T02 | done | — | workplans/CSOC-WP-0001-bubble-io-exit-assessment.md |
| task | CSOC-WP-0001-T03 | done | — | workplans/CSOC-WP-0001-bubble-io-exit-assessment.md |
@ -62,7 +63,12 @@
| task | CSOC-WP-0008-T08 | progress | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md |
| task | CSOC-WP-0008-T09 | progress | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md |
| task | CSOC-WP-0008-T10 | done | — | workplans/CSOC-WP-0008-production-transfer-acceptance.md |
| task | CSOC-WP-0009-T01 | done | — | workplans/CSOC-WP-0009-usecase-regression-infrastructure.md |
| task | CSOC-WP-0009-T02 | done | — | workplans/CSOC-WP-0009-usecase-regression-infrastructure.md |
| task | CSOC-WP-0009-T03 | done | — | workplans/CSOC-WP-0009-usecase-regression-infrastructure.md |
| task | CSOC-WP-0009-T04 | done | — | workplans/CSOC-WP-0009-usecase-regression-infrastructure.md |
| intake | CSOC-IN-0001 | closed | blue | docs/intakes/csoc-residuals.md |
| intake | CSOC-IN-0002 | closed | green | docs/intakes/csoc-residuals.md |
| intake | CSOC-IN-0003 | open | blue | docs/intakes/csoc-residuals.md |
| intake | CSOC-IN-0004 | open | green | docs/intakes/csoc-residuals.md |
| intake | CSOC-IN-0005 | open | green | docs/intakes/csoc-residuals.md |

View file

@ -142,3 +142,38 @@ notes: |
Pickup: docs/session-pickup.md
state_hub_intake_id: "019ffac3-fc7d-7850-93b6-b0d289881ec7"
```
---
## CSOC-IN-0005 — Activate required Forgejo use-case regression check
```yaml
id: CSOC-IN-0005
kind: intake
title: "Verify Forgejo use-case runner, retain reports and require the regression check"
lane: green
status: open
priority: high
owner: coulomb-social
repo: coulomb-social
origin: residual
origin_ref: CSOC-WP-0009
description: |
Local make check and the ReichelAg fast/real-browser contract pass.
.forgejo/workflows/usecases.yaml configures main push, PR and manual runs
on the existing ubuntu-latest container label. The source SHA archive is
publicly readable (HTTP 200 checked 2026-10-06); no credential embedded.
Through the Forgejo/railiance-forge owner lane, verify repo Actions and
runner admission, Python/Node/browser dependency bootstrap and the exact
usecase-regressions job on a triggering commit. Record a successful remote
run with the commit SHA, make check result and JUnit report receipt.
Configure report retention/artifacts and make the named job a required
merge check. Confirm a failing journey prevents merge and a passing repair
unblocks it. Do not claim branch enforcement from the local gate alone.
notes: |
Infrastructure home: docs/usecases/README.md.
Runner/profile changes and protected-branch administration require the
appropriate owner path; no shared runner or production change made here.
state_hub_intake_id: "01a1110f-e2ec-736f-8209-e14425b62373"
```

View file

@ -1,3 +1,14 @@
# Use-case regression gate — 2026-10-06
WP-0009 establishes the use-case collection/test infrastructure for ReichelAg
UC-008. See docs/usecases/README.md and registry.json. Before accepting changes,
run `make check` (includes required real-browser journey); `make test` is fast
only. Synthetic fixtures and test DB/content are isolated from private notes.
CI runner/admission/required-check receipts remain CSOC-IN-0005; real-user
transfer/deployment remain WP-0008.
---
# Current implementation focus — 2026-10-06
CSOC-WP-0008 now targets the first **user-reviewed use-case transfer off Bubble**.

118
docs/usecases/README.md Normal file
View file

@ -0,0 +1,118 @@
# Use-case collection and regression infrastructure
This collection records the jobs people actually do in coulomb.social and keeps
established behavior working while it is rebuilt and moved off Bubble.
Start with **CSOC-UC-008, ReichelAg weekly founder calls**: duplicate the full
previous protocol, edit the new agenda/notes manually, copy for external email.
## Collection
- `catalog.md`: discovery backlog, including inferred candidates.
- `registry.json`: executable index of established journeys and required tests.
- `reichelag-weekly-call.md`: user-confirmed behavior and remaining manual review.
- `first-transfer-review.md`: real-user migration acceptance walkthrough.
- `tests/usecases/fixtures/`: synthetic scenarios; never private protocol content.
Confirmation, regression protection and transfer acceptance are independent.
`established: true` means a user-confirmed behavior contract must keep passing;
it does **not** mean the use case has been accepted for productive transfer.
Only the user's review can grant that, with an explicit acceptance reference.
## Run the gate
Requires Python 3.12+, uv, Node 20+ and npm. Locked Python/Node dependencies are
tracked. First setup (Chromium OS libraries must be available):
```bash
uv sync --locked
make setup-usecases
# On a fresh Linux CI runner, install browser OS libraries too:
cd scripts && npx playwright install --with-deps chromium
```
From the repository root:
```bash
make test # Fast application checks; browser tests explicitly deselected
make test-usecases # All registered journey tests, including actual browser UI
make test-browser # Only browser journeys, still validates registry coverage
make check # Required full application + established use-case/browser gate
```
`make check` is the release/change validation command. Browser tests are required,
not conditionally skipped when Node, Playwright or Chromium is absent. A missing
dependency or test mapping fails the gate. JUnit results are written under
`var/test-results/` (gitignored). The ordinary `make test` is intentionally the
fast iteration command, not proof that all established journeys still work.
The registry validates **collected node IDs before deselection**: deleting or
renaming a protected test, removing its use-case marker, or giving it the wrong
browser/fast layer fails with `Use-case regression contract failed`. Disabling a
protected check with skip/xfail is also rejected. Update mappings deliberately
when refactoring; do not delete a required behavior to turn a failure green.
## What ReichelAg protection covers
Fast test: real application POSTs, full protocol duplication, edit/source
independence, rendered clipboard controls and reader access.
Browser test: isolated Django DB and temporary content root; synthetic writer,
reader and outsider; actual sign-in/list/page/duplicate/edit/save/reopen screens;
real static clipboard script; heading/list export as HTML/plain text; blocked
clipboard manual selection; sample rich email-editor consumption; reader mutation
and outsider read denial. It checks that browser writes persist and the original
protocol remains unchanged. Unexpected external browser requests are blocked.
Clipboard writes are intercepted using native browser ClipboardItem objects;
there is no OS clipboard export of private notes, external issuer or email send.
The test uses developer sign-in in its isolated test instance. It does **not**
prove production OIDC, restart/backup recovery, actual ReichelAg corpus fidelity,
or compatibility with the user's usual mail client. Those remain WP-0008 review
and deployment tasks.
The smaller `node scripts/test-copy-content.mjs` check remains available for
clipboard component debugging; the required browser gate exercises the actual
Django screens instead of substituting a hand-built page.
## Add the next established use case
1. Give it a stable `CSOC-UC-NNN` ID. Describe actor, goal, original steps,
expected outcome, evidence confidence, privacy and migration differences.
Keep an inferred case in the catalogue until the user confirms it.
2. Write a synthetic fixture with `synthetic: true` and matching `usecase_id`.
No real business content, member identifiers, tokens or recordings in fixtures.
3. Add fast and real-browser tests with `@pytest.mark.usecase("CSOC-UC-NNN")`;
browser tests also have `@pytest.mark.browser`. Use the isolated live-server
pattern; drive visible user steps rather than recreating app markup.
4. Register exact test node IDs, narrative/fixture paths and the preserved behavior
contract in `registry.json`; run `make check`. Established tests cannot skip.
5. Separately record real-user review/transfer acceptance with its receipt.
If behavior changes intentionally, update the user-approved contract and tests
together; keep unresolved gaps in live work records.
## CI and operational boundary
`.forgejo/workflows/usecases.yaml` requests the existing fleet ubuntu-latest
container runner on main pushes, pull requests and manual dispatch. It uses the
triggering SHA archive, locked installs, browser OS dependencies and `make check`.
It neither deploys the app nor imports production data. No credentials are
embedded; archive checkout needs existing public/read admission.
Local gate success is not a remote CI receipt. Confirm the runner label can run
the job and the repository has Actions enabled; preserve reports/artifacts and
make `usecase-regressions` a required merge check through the Forgejo owner lane.
That external admission/enforcement work is tracked as **CSOC-IN-0005**. Until
confirmed, the workflow is configured and the local gate is mandatory by this
repository's documented process; protected-branch enforcement is not claimed.
## Verification receipt — 2026-10-06
- `make check`: 88 passed, including the actual ReichelAg browser journey.
- `make test-usecases` equivalent command: 2 passed (fast + browser), 86 deselected.
- JUnit XML: `var/test-results/check.xml` (88 tests, 0 failures/errors) and
`usecases.xml` (2 tests, 0 failures/errors).
- Deliberately collecting only `tests/test_reichelag_workflow.py` with
`--verify-usecases` returns exit 4 and identifies the missing browser check.
- Ruff passes changed Python infrastructure/test files; whitespace check passes.
- Remote Forgejo run and merge enforcement are not asserted; CSOC-IN-0005 owns
those receipts. Existing repository-wide lint debt remains WP-0008-T07.

View file

@ -7,6 +7,10 @@ Bubble inventory/capability map; no fresh authenticated Bubble observation has
been performed in this session. All transfer decisions are **pending user review**. ReichelAg weekly founder
calls are now the first user-confirmed workflow: see reichelag-weekly-call.md.
Executable regression protection for established cases: registry.json and
[the test infrastructure](README.md). `make check` includes required browser
journeys; candidate entries below are not all established or protected yet.
## Transfer workflow
For every journey: capture a real example and original steps → describe actor,

View file

@ -0,0 +1,31 @@
{
"schema_version": 1,
"cases": [
{
"id": "CSOC-UC-008",
"title": "ReichelAg weekly founder-call protocols",
"established": true,
"confirmation": "user-confirmed",
"transfer_status": "pending-user-review",
"narrative": "docs/usecases/reichelag-weekly-call.md",
"fixture": "tests/usecases/fixtures/reichelag-weekly-call.json",
"contract": [
"Duplicate the complete previous protocol without changing the original",
"Edit and save the next agenda/protocol by hand and reopen it",
"Copy only rendered content with headings and bullet structure for external email",
"Fall back to selected manual text when clipboard writes are denied",
"Readers may read and copy but not modify; outsiders cannot read",
"The app does not send the email or need Bubble for these steps"
],
"tests": {
"fast": ["tests/test_reichelag_workflow.py::test_full_protocol_duplicate_edit_and_clipboard_control"],
"browser": ["tests/usecases/test_reichelag_browser.py::test_weekly_call_through_real_ui"]
},
"manual_review": [
"Reconcile the latest actual ReichelAg protocol and its links/assets",
"Paste into the user's usual mail client and review presentation",
"Confirm live persistence and measured paired restore before transfer"
]
}
]
}

View file

@ -79,3 +79,12 @@ and reader copy visibility. New workflow test passes Ruff.
HTML/plain payload construction, plain-only fallback and denied/manual selection.
Clipboard writes are intercepted with synthetic content; no real clipboard or
email recipient is used. This does not certify pasting into the user's mail client.
## Continuous regression protection
Registered in registry.json with a synthetic scenario and mandatory fast/browser
node IDs. `make check` drives the actual isolated app through duplication, manual
editing, reopen, HTML/plain/manual clipboard export and reader/outsider checks.
See README.md for setup, reports and extending protection to further cases.
This protects established behavior without marking productive transfer accepted.

View file

@ -29,7 +29,11 @@ DJANGO_SETTINGS_MODULE = "coulomb_social.settings.test"
python_files = ["test_*.py", "tests.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = "--tb=short -q"
addopts = "--tb=short -q -m 'not browser'"
markers = [
"usecase(id): regression protection for a registered user journey",
"browser: mandatory in make check; needs Node, Playwright and Chromium",
]
testpaths = ["tests", "coulomb_social"]
[tool.ruff]

57
scripts/package-lock.json generated Normal file
View file

@ -0,0 +1,57 @@
{
"name": "coulomb-social-scripts",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "coulomb-social-scripts",
"dependencies": {
"playwright": "1.62.1"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}

View file

@ -4,6 +4,6 @@
"type": "module",
"description": "Local tooling for auth capture and design extract (no app runtime)",
"dependencies": {
"playwright": "^1.49.0"
"playwright": "1.62.1"
}
}

View file

@ -0,0 +1,141 @@
// Real UI traversal with synthetic protocols; no production hosts or email.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { chromium } from 'playwright';
const base = new URL(process.argv[2]);
if (base.protocol !== 'http:' || !['localhost', '127.0.0.1'].includes(base.hostname)) {
throw new Error('Use-case browser runner requires an isolated loopback test server');
}
const scenario = JSON.parse(fs.readFileSync(process.argv[3], 'utf8'));
assert.equal(scenario.synthetic, true);
const browser = await chromium.launch({headless: true});
try {
const context = await browser.newContext();
// Fail closed on unexpected integrations. Only the isolated app can be contacted.
await context.route('**/*', route => {
if (new URL(route.request().url()).origin === base.origin) return route.continue();
return route.abort();
});
await context.addInitScript(() => {
window.clipboardMode = 'rich';
window.clipboardReceipts = [];
Object.defineProperty(navigator, 'clipboard', {configurable: true, value: {
write: async items => {
if (window.clipboardMode !== 'rich') throw new Error('Rich copy blocked');
const item = items[0];
window.clipboardReceipts.push({
html: await (await item.getType('text/html')).text(),
text: await (await item.getType('text/plain')).text(),
});
},
writeText: async text => {
if (window.clipboardMode === 'denied') throw new Error('Copy blocked');
window.clipboardReceipts.push({text});
},
}});
});
const page = await context.newPage();
page.setDefaultTimeout(10000);
const jsErrors = [];
page.on('pageerror', error => jsErrors.push(error.message));
const detail = `${base.origin}/app/spaces/${scenario.space_slug}/`;
const original = `${detail}?page=${scenario.previous_slug}`;
async function login(role) {
await page.goto(`${base.origin}/auth/dev-login/`);
await page.locator('input[name=subject]').fill(scenario[`${role}_subject`]);
await page.locator('input[name=tenant]').fill(scenario.tenant);
await page.getByRole('button', {name: 'Establish session'}).click();
await page.waitForURL('**/app/');
}
await login('writer');
assert.match(await page.locator('main').innerText(), new RegExp(scenario.space_title));
await page.locator(`a[href="/app/spaces/${scenario.space_slug}/"]`).click();
await page.getByRole('link', {name: scenario.previous_title, exact: true}).click();
await page.getByRole('link', {name: 'Duplicate page', exact: true}).click();
await page.getByLabel('New page slug').fill(scenario.next_slug);
await page.locator('input[name=title]').fill(scenario.next_title);
await page.getByRole('button', {name: 'Create duplicate'}).click();
await page.waitForURL(`**?page=${scenario.next_slug}`);
assert.match(await page.locator('#page-content').innerText(), /Completed point/);
await page.getByRole('link', {name: 'Edit page', exact: true}).click();
assert.equal(await page.getByLabel('Body (markdown)').inputValue(), scenario.previous_body);
await page.getByLabel('Body (markdown)').fill(scenario.next_body);
await page.getByRole('button', {name: 'Save page'}).click();
await page.waitForURL(`**?page=${scenario.next_slug}`);
await page.reload();
assert.match(await page.locator('#page-content').innerText(), /Follow-up point/);
assert.doesNotMatch(await page.locator('#page-content').innerText(), /Completed point/);
// Execute the actual static script loaded by the real detail page, not a fixture DOM.
for (const mode of ['rich', 'plain', 'denied']) {
await page.evaluate(mode => {
window.clipboardMode = mode;
window.clipboardReceipts = [];
}, mode);
await page.getByRole('button', {name: 'Copy content to clipboard'}).click();
await page.waitForFunction(() => !document.getElementById('copy-page-content').disabled);
const result = await page.evaluate(() => ({
receipts: window.clipboardReceipts,
status: document.getElementById('copy-content-status').textContent,
manual: document.getElementById('copy-content-manual').value,
hidden: document.getElementById('copy-content-manual').hidden,
selected: document.getElementById('copy-content-manual').selectionEnd,
}));
const text = mode === 'denied' ? result.manual : result.receipts[0].text;
assert.match(text, /Next weekly call/);
assert.match(text, /- Follow-up point/);
assert.match(text, / - Nested detail/);
assert.match(text, /- Another agenda item/);
assert.match(text, /New notes with emphasis/);
assert.doesNotMatch(text, /Edit page|Duplicate page|Source:|content-plane|Sign out/);
if (mode === 'denied') {
assert.equal(result.receipts.length, 0);
assert.equal(result.hidden, false);
assert.equal(result.selected, text.length);
assert.match(result.status, /Ctrl\+C or Cmd\+C/);
assert.doesNotMatch(result.status, /Content copied/);
} else {
assert.match(result.status, /Content copied/);
if (mode === 'rich') {
const html = result.receipts[0].html;
assert.match(html, /<ul>/);
assert.match(html, /<strong>emphasis<\/strong>/);
// A synthetic rich email editor can consume the same exported HTML.
const email = await context.newPage();
await email.setContent('<div id="email-draft" contenteditable="true"></div>');
await email.locator('#email-draft').evaluate((node, html) => {node.innerHTML = html;}, html);
assert.equal(await email.locator('#email-draft li').count(), 3);
assert.match(await email.locator('#email-draft').innerText(), /New notes with emphasis/);
await email.close();
}
}
}
await page.goto(original);
assert.match(await page.locator('#page-content').innerText(), /Completed point/);
assert.doesNotMatch(await page.locator('#page-content').innerText(), /New notes/);
await login('reader');
await page.goto(`${detail}?page=${scenario.next_slug}`);
assert.match(await page.locator('#page-content').innerText(), /New notes/);
assert.equal(await page.getByRole('link', {name: 'Edit page', exact: true}).count(), 0);
assert.equal(await page.getByRole('button', {name: 'Copy content to clipboard'}).count(), 1);
await page.getByRole('button', {name: 'Copy content to clipboard'}).click();
await page.waitForFunction(() => !document.getElementById('copy-page-content').disabled);
assert.match(await page.locator('#copy-content-status').innerText(), /Content copied/);
const csrf = await page.locator('input[name=csrfmiddlewaretoken]').first().inputValue();
const deniedPost = await page.request.post(`${detail}pages/${scenario.next_slug}/edit/`, {
form: {csrfmiddlewaretoken: csrf, title: 'Unauthorized change',
slug: scenario.next_slug, body: 'Unauthorized reader edit'},
});
assert.equal(deniedPost.status(), 403);
assert.match(await deniedPost.text(), /read-only/);
const denied = await page.goto(`${detail}pages/${scenario.next_slug}/edit/`);
assert.equal(denied.status(), 403);
await login('outsider');
const forbidden = await page.goto(`${detail}?page=${scenario.next_slug}`);
assert.equal(forbidden.status(), 404);
assert.equal(await page.locator('#page-content').count(), 0);
assert.deepEqual(jsErrors, []);
console.log(`${scenario.usecase_id}: real UI duplication/edit/reopen/clipboard/access passed`);
} finally {
await browser.close();
}

27
tests/conftest.py Normal file
View file

@ -0,0 +1,27 @@
"""Keep registered use-case coverage present before any marker deselection."""
import pytest
from tests.usecases.registry import load_cases, validate_coverage
def pytest_addoption(parser):
parser.addoption("--verify-usecases", action="store_true",
help="Fail if established use cases lose registered test coverage")
@pytest.hookimpl(tryfirst=True)
def pytest_collection_modifyitems(config, items):
if not config.getoption("--verify-usecases"):
return
collected = {}
for item in items:
collected[item.nodeid] = {
"usecases": {m.args[0] for m in item.iter_markers("usecase") if m.args},
"browser": item.get_closest_marker("browser") is not None,
"disabled": any(item.get_closest_marker(m) for m in ("skip", "skipif", "xfail")),
}
try:
validate_coverage(load_cases(), collected)
except (KeyError, ValueError, TypeError, OSError) as exc:
raise pytest.UsageError(f"Use-case regression contract failed: {exc}") from exc

View file

@ -1,5 +1,8 @@
"""UC-008 synthetic acceptance; contains no private ReichelAg notes."""
import json
from pathlib import Path
import pytest
from django.urls import reverse
@ -9,6 +12,7 @@ from coulomb_social.apps.spaces.models import SpaceMembership
from coulomb_social.apps.spaces.services import create_space, put_space_page
@pytest.mark.usecase("CSOC-UC-008")
@pytest.mark.django_db
def test_full_protocol_duplicate_edit_and_clipboard_control(tmp_path, settings, client):
settings.CONTENT_ROOT = str(tmp_path / "content")
@ -18,7 +22,9 @@ def test_full_protocol_duplicate_edit_and_clipboard_control(tmp_path, settings,
issuer="https://issuer.test", subject="writer",
)
space = create_space(member, title="Synthetic weekly calls", slug="weekly-calls")
body = "# Weekly call\n\n- Open question\n- Completed point\n\n## Decisions\n\nNotes.\n"
scenario = json.loads((Path(__file__).parent / "usecases/fixtures/reichelag-weekly-call.json")
.read_text(encoding="utf-8"))
body = scenario["previous_body"]
put_space_page(member, space, "previous-call", title="Previous call", body=body)
client.force_login(user)
response = client.post(reverse("spaces:page_copy", kwargs={
@ -27,7 +33,7 @@ def test_full_protocol_duplicate_edit_and_clipboard_control(tmp_path, settings,
assert response.status_code == 302
assert pageops.read_page(space.slug, "next-call").body == body
assert pageops.read_page(space.slug, "previous-call").body == body
edited = "# Next call\n\n- Follow-up point\n\n## Protocol\n\nNew notes.\n"
edited = scenario["next_body"]
response = client.post(reverse("spaces:page_edit", kwargs={
"slug": space.slug, "page_slug": "next-call",
}), {"title": "Next call", "slug": "next-call", "body": edited})
@ -38,7 +44,7 @@ def test_full_protocol_duplicate_edit_and_clipboard_control(tmp_path, settings,
assert b"Copy content to clipboard" in response.content
assert b"Duplicate page" in response.content
assert b'id="page-content"' in response.content
assert b"<li>Follow-up point</li>" in response.content
assert b"Follow-up point" in response.content
assert b"spaces/copy-content.js" in response.content
assert b"Completed point" not in response.content

View file

@ -0,0 +1 @@
"""Established user journeys and their synthetic regression scenarios."""

View file

@ -0,0 +1,16 @@
{
"usecase_id": "CSOC-UC-008",
"synthetic": true,
"space_slug": "weekly-calls",
"space_title": "Synthetic weekly calls",
"previous_slug": "previous-call",
"previous_title": "Previous weekly call",
"previous_body": "# Weekly call\n\n- Open question\n - Follow-up detail\n- Completed point\n\n## Decisions\n\nPrevious notes.\n",
"next_slug": "next-call",
"next_title": "Next weekly call",
"next_body": "# Next weekly call\n\n- Follow-up point\n - Nested detail\n- Another agenda item\n\n## Protocol\n\nNew notes with **emphasis**.\n",
"writer_subject": "usecase-writer",
"reader_subject": "usecase-reader",
"outsider_subject": "usecase-outsider",
"tenant": "tenant:synthetic-usecases"
}

View file

@ -0,0 +1,68 @@
"""Versioned journey registry and mandatory collected-test coverage validation."""
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
REGISTRY = ROOT / "docs/usecases/registry.json"
def load_cases():
data = json.loads(REGISTRY.read_text(encoding="utf-8"))
if data.get("schema_version") != 1 or not isinstance(data.get("cases"), list):
raise ValueError("Unsupported use-case registry schema")
cases = data["cases"]
if not cases:
raise ValueError("The use-case registry cannot be empty")
ids = set()
for case in cases:
uid = case["id"]
if uid in ids:
raise ValueError(f"Duplicate use-case id: {uid}")
ids.add(uid)
if case["confirmation"] not in {"inferred", "observed", "user-confirmed"}:
raise ValueError(f"Unknown confirmation for {uid}")
if case["transfer_status"] not in {"pending-user-review", "accepted", "deferred"}:
raise ValueError(f"Unknown transfer status for {uid}")
if case["transfer_status"] == "accepted" and not case.get("acceptance_ref"):
raise ValueError(f"Accepted case {uid} needs a user acceptance reference")
for field in ("narrative", "fixture"):
path = (ROOT / case[field]).resolve()
if not path.is_relative_to(ROOT) or not path.is_file():
raise ValueError(f"Missing or outside-repo {field} for {uid}")
fixture = json.loads((ROOT / case["fixture"]).read_text(encoding="utf-8"))
if fixture.get("synthetic") is not True or fixture.get("usecase_id") != uid:
raise ValueError(f"Fixture must be synthetic and linked to {uid}")
if not isinstance(case["established"], bool):
raise ValueError(f"established must be a boolean for {uid}")
if set(case["tests"]) != {"fast", "browser"}:
raise ValueError(f"Expected fast and browser test layers for {uid}")
if case["established"]:
if case["confirmation"] != "user-confirmed" or not case["contract"]:
raise ValueError(f"Established case {uid} needs user confirmation and contract")
for layer in ("fast", "browser"):
if not case["tests"].get(layer):
raise ValueError(f"Established case {uid} missing {layer} coverage")
return cases
def validate_coverage(cases, collected):
"""Require exact node IDs, UC marker, and correct fast/browser layer."""
ids = {case["id"] for case in cases}
for nodeid, markers in collected.items():
if markers["usecases"] - ids:
raise ValueError(f"Use-case test has an unregistered ID: {nodeid}")
for case in cases:
if not case["established"]:
continue
for layer, nodeids in case["tests"].items():
for nodeid in nodeids:
markers = collected.get(nodeid)
if markers is None:
raise ValueError(f"{case['id']} missing collected {layer} test: {nodeid}")
if case["id"] not in markers["usecases"]:
raise ValueError(f"{nodeid} is not marked for {case['id']}")
if markers.get("disabled"):
raise ValueError(f"Protected use-case test cannot skip/xfail: {nodeid}")
if markers["browser"] != (layer == "browser"):
raise ValueError(f"Wrong regression layer for {nodeid}")

View file

@ -0,0 +1,51 @@
"""Actual browser user journey against an isolated Django live server."""
import json
import subprocess
from pathlib import Path
import pytest
from coulomb_social.apps.members.models import Member, User
from coulomb_social.apps.spaces import pageops
from coulomb_social.apps.spaces.models import SpaceMembership
from coulomb_social.apps.spaces.services import create_space, put_space_page
ROOT = Path(__file__).resolve().parents[2]
SCENARIO = ROOT / "tests/usecases/fixtures/reichelag-weekly-call.json"
@pytest.mark.usecase("CSOC-UC-008")
@pytest.mark.browser
@pytest.mark.django_db(transaction=True)
def test_weekly_call_through_real_ui(tmp_path, settings, request):
settings.DEBUG = True
settings.OIDC_ENABLED = False
settings.USER_ENGINE_BASE_URL = ""
settings.FLEX_AUTH_BASE_URL = ""
settings.FORGEJO_BASE_URL = ""
settings.CONTENT_ROOT = str(tmp_path / "content")
settings.STATIC_ROOT = str(tmp_path / "static")
scenario = json.loads(SCENARIO.read_text(encoding="utf-8"))
actors = {}
for role in ("writer", "reader", "outsider"):
subject = scenario[f"{role}_subject"]
actors[role] = Member.objects.create(
user=User.objects.create_user(username=subject), tenant_id=scenario["tenant"],
user_engine_user_id=subject, issuer="https://local.dev/issuer", subject=subject,
)
space = create_space(actors["writer"], title=scenario["space_title"],
slug=scenario["space_slug"])
put_space_page(actors["writer"], space, scenario["previous_slug"],
title=scenario["previous_title"], body=scenario["previous_body"])
SpaceMembership.objects.create(space=space, member=actors["reader"], role="viewer")
server = request.getfixturevalue("live_server")
result = subprocess.run(
["node", str(ROOT / "scripts/test-usecase-browser.mjs"), str(server), str(SCENARIO)],
cwd=ROOT, capture_output=True, text=True, timeout=120,
)
assert result.returncode == 0, result.stdout + result.stderr
# Browser POSTs must persist the new protocol without changing the old one.
assert (pageops.read_page(space.slug, scenario["previous_slug"]).body
== scenario["previous_body"])
assert pageops.read_page(space.slug, scenario["next_slug"]).body == scenario["next_body"]

View file

@ -0,0 +1,105 @@
---
id: CSOC-WP-0009
type: workplan
title: "Established use-case collection and executable regression gate"
domain: communication
repo: coulomb-social
status: finished
owner: codex
topic_slug: coulomb-social
created: "2026-10-06"
updated: "2026-10-06"
related:
- CSOC-WP-0008
state_hub_workstream_id: "d9ad740d-82a9-5130-b7da-29901542409a"
---
# CSOC-WP-0009 — Keep established use cases working
Milestone: every established journey has a versioned behavior contract and a
repeatable regression check. Starting with ReichelAg weekly protocols, a change
that breaks duplication, manual editing, clipboard export or access fails the
required local/CI check. Automated success never grants productive-transfer
acceptance in place of the user.
## T01 — Establish the collection and coverage contract
```task
id: CSOC-WP-0009-T01
status: done
priority: high
state_hub_task_id: "8aaf65d3-9931-52a1-8b2c-63888316abf8"
```
Machine registry links stable UC IDs to narrative, synthetic scenario fixtures,
mandatory fast and browser test node IDs, user-confirmation and transfer status.
Validate registry and collected tests; an established case cannot lose coverage
silently. Document how candidate → confirmed → regression-protected → user
accepted records are maintained without conflating those dimensions.
## T02 — Exercise ReichelAg through the actual browser UI
```task
id: CSOC-WP-0009-T02
status: done
priority: high
state_hub_task_id: "40ef120b-eccf-5350-a905-af45a6e38ab8"
```
Isolated Django test DB and temporary CONTENT_ROOT, synthetic actors and protocols.
Playwright drives real login/list/detail/duplicate/edit/save/reopen/copy screens;
verify full-copy/source independence, payload formatting, manual fallback,
reader edit denial and outsider isolation. Serve the real static script. No
production hosts, real notes, issuer, outgoing mail or private credentials.
## T03 — Make the regression gate reproducible
```task
id: CSOC-WP-0009-T03
status: done
priority: high
state_hub_task_id: "d950b4d6-faf4-5e95-8679-35e887dfa01f"
```
One make check command runs full application and use-case/browser checks and
writes JUnit reports. Provide fast/browser targets and locked Node dependencies,
bootstrap instructions and CI integration. Missing browser/dependencies must
fail the required gate; default fast tests may explicitly deselect browser tests.
CI activation depends on actual Forgejo runner admission; do not claim a remote
run from local tests alone.
## T04 — Verify and sync
```task
id: CSOC-WP-0009-T04
status: done
priority: high
state_hub_task_id: "7aa0b568-c385-551b-b3e4-b43b3ce6e7f3"
```
Run required gate, changed-file lint and coverage validation. Prove a missing
mandatory test is rejected. Document receipts, file any CI admission residual as
a live record, finish this infrastructure workplan, log, commit and sync.
## Implementation evidence
- Registry, fixture and stable fast/browser test mappings established for UC-008.
- Actual Django screens/static script exercised by Chromium against isolated
synthetic data. Full gate: **88 passed**; changed Python files pass Ruff.
- Omitted browser test collection deliberately rejected with pytest exit 4
and a missing registered browser-test error; not silently skipped.
- Locked Playwright dependencies, setup targets, JUnit reports and Forgejo
main/PR/manual workflow configured. Source archive read checked HTTP 200.
- Remote runner receipt, report retention and required merge-check enforcement
handed off before completion as **CSOC-IN-0005**, origin residual WP-0009.
- Productive transfer/live deployment/private protocol review remain WP-0008;
this infrastructure does not grant user acceptance.
## Completion
Required gate rerun after final browser permission checks: 88 passed; JUnit
reports show zero failures/errors. Reader clipboard copying succeeds while a
valid-CSRF reader POST is rejected as read-only. User-case coverage-loss probe
returned exit 4 as expected. AGENTS.md now requires make check for established
behavior changes. CSOC-IN-0005 is already registered as the remote CI enforcement
residual. All local infrastructure tasks complete; no production change made.