Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 20 additions & 7 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,28 @@ jobs:
with:
version: 2.116.0

- name: Start isolated database and apply migrations
run: supabase db start
- name: Install dependencies
run: npm ci

- name: Database policy tests
run: npm run test:db
- name: Install Chromium
run: npx playwright install --with-deps chromium

- name: Stop isolated database
if: always()
run: supabase stop --no-backup
- name: Database and real workspace journey
env:
WORKSPACE_GATE_ARTIFACT_ROOT: ${{ runner.temp }}/workspace-gate
run: ./.superset/db-gate.sh

- name: Upload workspace failure artifacts
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: workspace-gate-failure-${{ github.run_id }}
path: |
${{ runner.temp }}/workspace-gate/
test-results/
playwright-report/
if-no-files-found: ignore
retention-days: 7

browser-tests:
if: github.event_name == 'pull_request'
Expand Down
92 changes: 89 additions & 3 deletions .superset/db-gate.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Runs the Docker-backed Supabase migration/pgTAP gate
# (`supabase db start` + `npm run test:db`) with exclusive access across
# Runs the Docker-backed Supabase, database, collaboration, and real-Auth browser gate
# with exclusive access across
# Superset workspaces of this repo. `supabase/config.toml` sets no
# project_id or custom port block, so `supabase db start` always binds the
# CLI's default ports and container names - two workspaces running it at
Expand All @@ -14,14 +14,37 @@ source "$DIR/lib/lock.sh"

DB_LOCK="$HOME/.superset/debatelab-db.lock"
stack_started=false
runtime_dir=""
owned_pids=()

stop_owned_processes() {
local index
for ((index=${#owned_pids[@]}-1; index>=0; index--)); do
kill -- "-${owned_pids[$index]}" 2>/dev/null || true
done
for ((index=${#owned_pids[@]}-1; index>=0; index--)); do
wait "${owned_pids[$index]}" 2>/dev/null || true
done
}

cleanup() {
local status=$?
trap - EXIT

stop_owned_processes

if [ -n "$runtime_dir" ] && [ -f "$runtime_dir/function.env" ]; then
rm -- "$runtime_dir/function.env"
fi

if [ "$stack_started" = true ] && ! supabase stop --no-backup; then
[ "$status" -ne 0 ] || status=1
fi
if [ "$status" -ne 0 ] && [ -n "$runtime_dir" ]; then
echo "Workspace gate failed; local service logs retained at $runtime_dir"
elif [ -n "$runtime_dir" ] && [ -d "$runtime_dir" ]; then
rm -r -- "$runtime_dir"
fi
lock_release "$DB_LOCK"
exit "$status"
}
Expand All @@ -30,6 +53,69 @@ echo "Waiting for exclusive access to the local Supabase stack..."
lock_acquire "$DB_LOCK" 600
trap cleanup EXIT

supabase db start
if [ -n "${WORKSPACE_GATE_ARTIFACT_ROOT:-}" ]; then
mkdir -p -- "$WORKSPACE_GATE_ARTIFACT_ROOT"
runtime_dir="$(mktemp -d "$WORKSPACE_GATE_ARTIFACT_ROOT/workspace-gate.XXXXXX")"
else
runtime_dir="$(mktemp -d)"
fi
supabase start >"$runtime_dir/supabase.log" 2>&1
stack_started=true
npm run test:db

status_json="$(supabase status -o json 2>/dev/null)"
status_value() {
node -e 'const value=JSON.parse(process.argv[1])[process.argv[2]]; if (!value) process.exit(1); process.stdout.write(String(value))' "$status_json" "$1"
}
api_url="$(status_value API_URL)"
anon_key="$(status_value ANON_KEY)"
service_role_key="$(status_value SERVICE_ROLE_KEY)"
database_url="$(status_value DB_URL)"
app_origin="http://127.0.0.1:18081"
project_id="$(basename "$(pwd)")"
mailer_host="$(docker ps --filter "name=supabase_inbucket_$project_id" --format '{{.Names}}')"
if [ -z "$mailer_host" ]; then
echo "Local Mailpit container is unavailable" >&2
exit 1
fi
LOCAL_SERVICE_ROLE_KEY="$service_role_key" \
LOCAL_MAILER_HOST="$mailer_host" \
node scripts/workspace-test-env.mjs .superset/workspace-test.env "$runtime_dir/function.env"

setsid supabase functions serve workspace-invites --no-verify-jwt \
--env-file "$runtime_dir/function.env" >"$runtime_dir/functions.log" 2>&1 &
owned_pids+=("$!")

VITE_LOCAL_PREVIEW=true \
VITE_LOCAL_SUPABASE_URL="$api_url" \
VITE_LOCAL_SUPABASE_ANON_KEY="$anon_key" \
VITE_COLLABORATION_URL=ws://127.0.0.1:12345 \
setsid npm run dev -- --host 127.0.0.1 --port 18081 --strictPort >"$runtime_dir/vite.log" 2>&1 &
owned_pids+=("$!")

mailer_url="$(status_value MAILPIT_URL)"
for url in "$mailer_url/api/v1/messages" "$app_origin"; do
for _ in $(seq 1 200); do
if curl --fail --silent --output /dev/null "$url"; then break; fi
sleep 0.1
done
curl --fail --silent --output /dev/null "$url"
done
function_url="$api_url/functions/v1/workspace-invites"
for _ in $(seq 1 200); do
if curl --fail --silent --output /dev/null --request OPTIONS --header "Origin: $app_origin" "$function_url"; then break; fi
sleep 0.1
done
curl --fail --silent --output /dev/null --request OPTIONS --header "Origin: $app_origin" "$function_url"

LOCAL_SUPABASE_URL="$api_url" \
LOCAL_SUPABASE_ANON_KEY="$anon_key" \
LOCAL_SUPABASE_SERVICE_ROLE_KEY="$service_role_key" \
WORKSPACE_TEST_MAILER_URL="$mailer_url" \
COLLABORATION_DATABASE_URL="$database_url" \
COLLABORATION_PORT=12345 \
COLLABORATION_ALLOWED_ORIGINS="$app_origin" \
SUPABASE_URL="$api_url" \
SUPABASE_ANON_KEY="$anon_key" \
PLAYWRIGHT_BASE_URL="$app_origin" \
npx playwright test --config=playwright.workspace.config.ts
7 changes: 7 additions & 0 deletions .superset/workspace-test.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Test-only values for the locked local workspace browser gate.
WORKSPACE_INVITE_SECRET=local-workspace-invite-secret-for-browser-gate
WORKSPACE_INVITE_MAILER_URL=http://workspace-mailer.invalid:8025/api/v1/send
WORKSPACE_INVITE_MAILER_TOKEN=local-workspace-mailer
WORKSPACE_INVITE_MAILER_FORMAT=mailpit
WORKSPACE_INVITE_BASE_URL=http://127.0.0.1:18081
WORKSPACE_INVITE_ALLOWED_ORIGINS=http://127.0.0.1:18081
8 changes: 4 additions & 4 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,15 +77,15 @@ Follow existing functional React, Zustand, Zod, and shadcn/Radix patterns. Reuse

Run the closest unit or component test while changing behavior. Run lint, type checking, and build before handing work off.

## Collaboration service spike
## Collaboration service

The Phase 0 service evidence lives under `services/collaboration/`. Run:
The local PostgreSQL-backed Hocuspocus service lives under `services/collaboration/`. Run its focused tests with:

```bash
npm run test:collaboration
```

The spike is not a deployed service. Its in-memory writer lock is single-process evidence; the database-backed service and advisory-lock lifecycle belong to P2.1.
Start it only with explicit local Supabase values for `COLLABORATION_DATABASE_URL`, `COLLABORATION_PORT`, `COLLABORATION_ALLOWED_ORIGINS`, `SUPABASE_URL`, and `SUPABASE_ANON_KEY`. The service binds loopback, takes one PostgreSQL session advisory lock per loaded document epoch, and closes sessions on access or restore notifications. It is not a production deployment.

## Edge Function workflow

Expand Down Expand Up @@ -119,7 +119,7 @@ npm run test:db
supabase stop --no-backup
```

The default local stack begins at port `54321` and uses shared container names, so it is not parallel-safe across worktrees. The wrapper holds `~/.superset/debatelab-db.lock` and stops the stack on exit, including after a test failure. Outside Superset, coordinate ownership before starting it. The agent that starts the stack stops it before finishing, including after failures when safe.
The default local stack begins at port `54321` and uses shared container names, so it is not parallel-safe across worktrees. The wrapper holds `~/.superset/debatelab-db.lock` through database tests and the real-Auth collaboration browser journey, and stops the stack and owned processes on exit. Outside Superset, coordinate ownership before starting it. The agent that starts the stack stops it before finishing, including after failures when safe.

Never point local tests at staging or production. Never apply a migration to an unverified project.

Expand Down
6 changes: 3 additions & 3 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ For a bug, watch the reproduction fail for the expected reason before implementi
npm run test:collaboration
```

This runs the isolated Node service spike with real Hocuspocus providers and Yjs documents. It covers durable acknowledgement ordering, reconnect sync, duplicate delivery, schema and size rejection, writer serialization, stale epochs, and process-kill recovery. It does not prove PostgreSQL advisory locks or a deployed collaboration service.
This runs the Node collaboration service with real Hocuspocus providers and Yjs documents. It covers durable acknowledgement ordering, reconnect sync, duplicate delivery, schema and size rejection, PostgreSQL adapter behavior, proposal decision rollback, writer serialization, stale epochs, and process-kill recovery. It does not prove a deployed service.

## Edge Function tests

Expand Down Expand Up @@ -136,15 +136,15 @@ In Superset, run:
./.superset/db-gate.sh
```

The wrapper acquires `~/.superset/debatelab-db.lock`, starts the stack, runs `npm run test:db`, and stops the stack on exit, including after a test failure. Outside Superset, run:
The wrapper acquires `~/.superset/debatelab-db.lock`, starts full local Supabase, runs `npm run test:db`, then holds the same lock while a controlled Mailpit relay, collaboration process, and Vite client run the real-Auth desktop/mobile workspace journey. Pull requests run this same gate in the `database-tests` job and retain its logs and browser traces on failure. It stops every owned process and the stack on exit, including after failure. Outside Superset, run only the database portion with:

```bash
supabase db start
npm run test:db
supabase stop --no-backup
```

The stack replays migrations and runs pgTAP tests. `CREATE INDEX CONCURRENTLY` requires the stated CLI floor so it runs outside the migration batch.
The stack replays migrations and runs pgTAP tests. The browser portion creates isolated local users and deletes them, captures mail only in local Mailpit, restarts the collaboration process, and proves recovery and revocation. `CREATE INDEX CONCURRENTLY` requires the stated CLI floor so it runs outside the migration batch.

The local stack begins at port `54321` and is not parallel-safe across worktrees. The task that starts it must stop it before finishing or abandoning the run. Use `supabase stop --no-backup` unless another active task explicitly owns the same stack.

Expand Down
59 changes: 28 additions & 31 deletions e2e/fixtures/workspace-review-ime.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,33 @@
import { createWorkspaceEditor } from "/src/lib/workspace/editor-schema.ts";
import * as review from "/src/lib/workspace/review.ts";

const canonicalDocument = new Y.Doc();
const suggestionDocument = new Y.Doc();
const canonical = createWorkspaceEditor(canonicalDocument);
const suggestion = createWorkspaceEditor(suggestionDocument);
canonical.commands.setContent("<p>Accepted:</p>");
suggestion.commands.setContent("<p></p>");
document.querySelector("#suggestion-editor").append(suggestion.view.dom);

let phase = "idle";
let proposal = null;
let proposalId = "ime";
let canonicalUpdates = 0;
const events = [];
canonicalDocument.on("update", () => canonicalUpdates += 1);
let suggesting = false;
const yDocument = new Y.Doc();
const editor = createWorkspaceEditor(yDocument, {
isSuggesting: () => suggesting,
onSuggestion(hunk) {
phase = "settled";
proposal = review.createProposal({
id: proposalId,
authorId: "ime-member",
documentEpoch: 1,
editor,
hunks: [hunk],
});
},
});
editor.commands.setContent("<p>Accepted:</p>");
yDocument.on("update", () => canonicalUpdates += 1);
suggesting = true;
document.querySelector("#suggestion-editor").append(editor.view.dom);

for (const type of ["compositionstart", "beforeinput", "input", "compositionend"]) {
suggestion.view.dom.addEventListener(type, (event) => {
editor.view.dom.addEventListener(type, (event) => {
if (type === "compositionstart") phase = "composing";
if (type === "compositionend") phase = "settled";
events.push({
Expand All @@ -40,10 +51,10 @@
}

const snapshot = () => ({
canonical: canonical.getText(),
canonical: editor.getText(),
canonicalUpdates,
suggestion: suggestion.getText(),
editorComposing: suggestion.view.composing,
suggestion: editor.getText(),
editorComposing: editor.view.composing,
phase,
events: [...events],
proposal: proposal && {
Expand All @@ -55,31 +66,18 @@
window.workspaceIme = {
snapshot,
capture(id) {
const at = canonical.state.doc.content.size - 1;
proposal = review.createProposalAfterComposition({
compositionState: phase,
proposal: {
id,
authorId: "ime-member",
documentEpoch: 1,
editor: canonical,
hunks: [{
id: `${id}-insert`,
kind: "insert",
at,
content: [{ type: "text", text: suggestion.getText() }],
}],
},
});
proposalId = id;
return snapshot();
},
accept() {
suggesting = false;
proposal = review.acceptProposalHunks({
editor: canonical,
editor,
documentEpoch: 1,
proposal,
hunkIds: [proposal.hunks[0].id],
});
suggesting = true;
return snapshot();
},
reject() {
Expand All @@ -90,7 +88,6 @@
return snapshot();
},
resetSuggestion() {
suggestion.commands.clearContent();
phase = "idle";
proposal = null;
events.length = 0;
Expand Down
Loading
Loading