Backend (FastAPI, Python 3.11+):
cd backend
python -m venv venv && source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
python manage.py setup # idempotent: missing personal workspaces + workspace files
uvicorn app:app --host 127.0.0.1 --port 9001 --reloadFrontend (React + Vite):
cd frontend
npm install
npm run dev # :5173, proxies /api → 127.0.0.1:9001
npm run build # outputs dist/ (FastAPI serves it in the Docker image)First run: the app seeds an admin account with a random password printed
once to the console (only while zero non-guest accounts exist). User CRUD
also via python manage.py (create-user, set-password, set-admin,
rename-user, delete-user, list-users, list-workspaces, set-member,
sweep-guests, migrate, backups).
Docker:
docker build -t gamma .
docker run -p 9001:9001 -v gamma-data:/data ghcr.io/tim4431/gammaDefault to the smallest set of tests that covers the changed behavior and its direct consumers. Do not run the full backend suite, all frontend tests, or the full browser suite after every edit. Once relevant checks pass, repeat them only after further relevant changes or when a failure needs investigation.
- Backend changes: select the relevant
tests/test_*.pyfiles. Prefer whole files because tests within a file can depend on earlier tests. For a few files, omit-n autoto avoid starting a worker per CPU. - Frontend pure-module changes: invoke
node --testwith the relevant test files directly.npm testalways includes the full module suite. - UI behavior changes: build once after the final frontend edit so the suite
sees current code, then
npm run e2e -- --changed, which runs the groups the working tree's changes select (see the browser suite;--changed main --listshows a branch's selection without running it). Narrow further with--groupor--onlywhen the selection is broader than the change, e.g. a one-line edit in a shared module selects every group. Check that the intended steps actually ran. A pure-module change does not automatically require a build or browser run. - Shared contracts and helpers: include tests for affected consumers. For example, changes to shared normalization cases need both Python and Node coverage; auth, workspace access, migrations, and block storage may require several related suites. Broaden further when the impact cannot be bounded.
- Documentation-only changes: check the diff and referenced commands/paths; application tests and builds are unnecessary.
Examples (paths are relative to the indicated directory):
# From backend/: OAuth behavior and its MCP integration
python -m pytest tests/test_mcp_oauth.py tests/test_mcp.py -q
# From frontend/: settings module behavior
node --test tests/settings.test.mjs
# From frontend/: UI behavior (build once before the browser run)
npm run build
npm run e2e -- --changed # the groups these changes select
npm run e2e -- --group settings # or name themReport which checks ran and any relevant coverage gaps. Full suites remain
the PR CI safety net in .github/workflows/check.yml; run them locally for
broad changes, an explicit request, or unresolved regression concerns.
Only the browser suite selects from the changed files (--changed); backend
and pure-module tests are still chosen by hand.
cd backend
pip install -r requirements-dev.txt # pytest, pytest-xdist, httpx
python -m pytest tests -q -n auto --dist loadfile # parallel, ~15 s
python -m pytest tests -q # serial, ~50 s (simpler tracebacks)In-process API tests (FastAPI TestClient) against a throwaway data
directory — no server, no network: an autouse fixture in conftest.py
refuses every non-loopback socket connect and DNS lookup, so a test that
forgets to stub a metadata / PDF / AI fetch fails at once instead of
passing slowly on the network. pytest.ini names tests/, so a bare
pytest from backend/ works too. The suite runs in parallel with
pytest-xdist: every worker process imports conftest.py and so gets its own
throwaway data directory, and --dist loadfile keeps each file's tests on
one worker in file order (tests inside a file may build on each other;
files never may). The shared client fixture carries the cookie of the last
login on that worker, so a "not signed in" check uses the anon fixture (a
fresh client), never client. One data directory serves every file on a
worker, so an account name belongs to the module that creates it: prefix
names with the module's area (bk_admin, ca_alice), create them through
conftest.make_user, and pick folder names no other module uses in the
guest fixture's workspace (its account is conftest.guest_name(), a
fresh guest-… name per run — never write "guest"). make_user fails the run when a second module asks for a
name another module already created. Run them with the project venv's
interpreter (venv/Scripts/python.exe on Windows): the two vector-math
tests need ziamath from requirements.txt, and a system/conda python
without it fails them with "ziamath is not importable" rather than a
puzzling path count.
The AI agent's tests are split by area — test_ai_tools_registry.py
(scopes, permissions, the system prompt), test_ai_tools_pages.py,
test_ai_tools_blocks.py, test_ai_tools_search.py (the executors),
test_ai_wire.py (provider wire formats, SSE parsing, history replay; pure)
and test_ai_agent_loop.py (/api/ai/chat with a faked provider) — over the
fixtures in tests/ai_fixtures.py. Its org fixture creates one account
per test module (the module's name is in the username), so the files never
see each other's pages or provider entries.
Rules the frontend mirrors — search normalization (gamma/textnorm.py ↔
frontend/src/shared/lib/textnorm.js) and folder-label paths (gamma/foldertags.py ↔
frontend/src/library/libraryUtils.js) — are pinned by ONE set of cases both sides
read: tests/shared/*.json at the repository root, run by
backend/tests/test_shared_fixtures.py and the matching node tests. Add a
case there when a rule changes; whichever side drifts fails.
The frontend has no linter and no component tests. Its pure modules have
node --test tests (npm test from frontend/, the files in
frontend/tests/*.test.mjs): blockOps (diff/apply), collabSession (the
page session's transport logic over fakes — ordering, reconciliation,
retries, presence, the caret throttle), sessionState, settings
(navigation, presets), textnorm and libraryUtils (the shared cases
above), mdMarks (the formatting hotkeys' toggle), logseqPdfModel (tree
ops), blockHistory (the undo classifier) and menuAim (the safe-triangle
geometry). A module is testable there when its relative imports carry the
.js extension (node resolves nothing else); modules that import React can
still be imported for their pure exports. Actual React rendering and
interactions are exercised by the browser suite below, plus one standalone
browser regression: npm run e2e:latex bundles the block
editor with esbuild over an in-memory fixture (latex_editing.md).
cd frontend
npm run build # the suite drives frontend/dist
npm run e2e # parallel workers, ~2 min on 6; exit 1 on any failure
npm run e2e -- --jobs 1 # one worker, groups in order, live output (~8 min)
npm run e2e -- --changed # only the groups the working tree's changes select
npm run e2e -- --changed main --list # a branch's selection and why, without running
npm run e2e -- --group ink,collab # these groups (ids: `--list`)
npm run e2e -- --only collab # steps whose name contains "collab" (one worker)
npm run e2e -- --continue # keep going after a failure
npm run e2e -- --headed # watch the browser
npm run e2e -- --keep # keep the temp data dir + server.logHigh-zoom tablet regressions: npm run e2e -- --only "pdf touch" --keep.
GAMMA_E2E_BROWSER=webkit (after npx playwright install webkit) runs the
suite in WebKit. Native touch gestures need Chromium's CDP, so under WebKit
the touch scenario checks only the 400% PDF/ink paint and bitmap release at
tablet dimensions and DPR 2. That is browser emulation, not an iPad measurement.
frontend/tests/e2e/run.mjs starts an ISOLATED backend (the project venv's
python — or the interpreter GAMMA_E2E_PYTHON names — over a fresh
GAMMA_DATA_DIR under the OS temp dir, on a free port, serving a copy of
frontend/dist taken at start, so a build during the run cannot break its
page loads), creates the accounts alice / bob, and drives Playwright's
Chromium (playwright is a devDependency; the browser is downloaded once on
first launch). A failed step saves a screenshot of every open page plus the
pages' recorded problems and the server log's tail under the temp dir's
failures/, and the temp dir is kept (the summary prints its path). The
check workflow runs the suite on every PR and uploads those folders as the
e2e-failures artifact. harness.mjs holds the server lifecycle, Account (session
cookie + X-Gamma-Workspace for API seeding, browser contexts logged in as
that account), makePdf (a small real PDF with a text layer), and step().
The run is split into groups (GROUPS in tests/e2e/select.mjs, one per
scenario file except notes-pdf-share, whose scenarios hand data along;
run.mjs maps each id to its scenario functions). --jobs N (default:
half the CPUs, at most 6; one with --only) forks N workers, each with its
own backend, data dir and browser; a worker takes the next group off the
queue, longest first, and the group's lines print as one block when it
ends. A group must not rely on another group's data: put scenarios that do
in the same group. Without --continue a failure stops handing out groups
and the ones running finish. The wall time is bounded by the longest group
(settings and the first-run guide, about a minute each), so split one of
those before adding workers.
--changed [ref] picks the groups from what changed: the working tree
(staged, unstaged, untracked) against HEAD, or everything since the branch
left ref. RULES in select.mjs maps each source path (first matching
glob wins) to the groups that exercise it: frontend/src/guide/** to the
three tour groups, backend/gamma/ink.py to the ink groups, docs, desktop,
extension and backend tests to none, and what every scenario goes through
(src/app/, src/shared/, the home library, the page's live session, the
backend core: auth, db, blocks, ops, uploads) to all of them. A changed
scenario file also selects every scenario that imports its helpers, and a
changed line naming a data-guide / data-tour anchor selects the tours
whatever file it is in. The run prints each selected group with the files
behind it. tests/e2eSelect.test.mjs fails when a file under
frontend/src, frontend/public or backend/gamma reaches only the
catch-all (a new folder or module needs its rule), when a group's scenario
files drift from scenarios/, or when a rule names an unknown group. When a
change's reach is broader than its rule says (a new prop threaded through
App.jsx into one pane), the selection errs wide: that is what --group
is for. CI still runs everything.
Flaky steps. A step that passes here and fails on CI is almost always a
timing assumption that a slow machine breaks, and the CI runner has 4 cores.
To reproduce that, pin a run to 4 cores. On Windows, start
node tests/e2e/run.mjs --continue --jobs 3 with Start-Process -PassThru
and set .ProcessorAffinity = 0xF at once (the workers, backends and
browsers inherit it). On Linux, use taskset -c 0-3. The patterns behind
past flakes, and their fixes:
- Checking once right after an action (
assert(await x.count() === 1)): poll withuntil()or wait for the locator instead. - Acting while something is still moving (a touch fling's momentum, a resize): wait for two equal readings first.
- Racing a background job (an indexer still running, the backup scheduler): wait for its state, never for a fixed time.
- A slow round the app runs by itself (a 20 s poll, a 30 s scheduler round): trigger it the way a user would (the tab regaining visibility), or make the app start it at once when that is the right behaviour anyway.
Open: ink edit: pen resumes writing… once missed a tap on the stroke on a
loaded 4-core run. Stale layout, a long-press cancel and the undo's
pending delete were each ruled out, so tapInk now reports what was under
the tap when no menu opens.
The scenarios live in tests/e2e/scenarios/:
-
mermaid.mjs: note/chat diagrams, streaming fences, editing, source copying, SVG downloads, theme changes and Markdown round trips. Run with--only mermaid; implementation details in mermaid.md. -
mentions.mjs: paper search, keyboard and touch selection, reference limits, persistence, PDF receipts and textarea shrink after clearing context. Run with--only mentions. -
chatNavigation.mjs: a library or PDF chat reply keeps streaming and is saved while the user navigates away and back, before or after it finishes.--only "chat navigation". -
publish.mjs: publishing a page to Gamma Cloud end to end. A second Gamma is started as the share host (new Server({env})inharness.mjspassesGAMMA_CLOUD_ISSUER,GAMMA_CLOUD_POLICY=provision,GAMMA_CLOUD_SHARE_HOST=1), andfakeCloud.mjsstands in for the account server both servers trust: discovery naming the share host, an authorization endpoint that answers at once, PKCE code and refresh grants, EdDSA ID tokens with their JWKS,/userinfo, and the profile and server-list endpoints. The account links its identity through the real round trip from the share popover, publishes, opens the link on the share host anonymously, changes the cloud share's audience, syncs, checks the pill and Settings' Publishing row, unpublishes, and stops publishing from Settings.--only publish. -
mirror.mjs: Settings → Account & sync → Clones — the server clones one of its own workspaces through the dialog with a write token made via the API: Sync, the empty conflicts list, opening the clone, the sync pill's log and settings (cadence, detach, reattach), a same-block conflict resolved on its row chip, Remove origin (mirror.md).--only mirror. -
notes.mjs: New page → title → first block (the seed-block insert), Shift+Enter / Tab / Shift+Tab / Backspace, Enter as a line break vs the Enter-as-new-block preference, Ctrl+Z, the handle menu, todo checkboxes, an uploaded image (its URL must carry the workspace), the workspace switcher. Runs in a NON-default workspace on purpose. -
pdf.mjs: upload + page by attachment, the viewer's text layer, a highlight from a text selection (overlay, quote row, persisted position), the find bar hitting page 2, an AI citation link highlighting its quote on the cited page (pdf_citations.md). -
transfers.mjs: the Import and Export dialogs — format/source cards, the review step and its switches, direct export for fixed formats. -
ink.mjs: handwriting. The tool strip and its presets, mouse strokes becoming an ink block with an.inkupload, persistence across a reload, the eraser, stroke undo/redo, the partial eraser, a lasso move + delete, the notes card's jump + outline (/Inkin the exported PDF istest_ink.py). Pen input: coalesced sample timing, pressure and lift endpoints in the uploaded file, prediction, palm suppression and palm-first pen takeover, cleanup afterpointercancel/ lost capture. Chromium's native touch and pen, and synthetic Pencil events for Safari's handler order. Stylus latency and OS palm rejection still need a real tablet. -
inkEditing.mjs: tap-to-select and the selection menu. Colour/width edits keeping pressure and time, duplicate ids, selective delete, the Undo/Redo buttons, a finger lasso-move across groups in pen-only mode, swipe/hold arbitration, a pen resuming through a selection, menu placement on a small screen, view/edit shares. Native Chromium touch/pen, asserting on the persisted stroke files.--only "ink edit:";--only inkruns both files. -
guide.mjs: the first-run guide (onboarding.md) —?guide=starts a tour and is consumed from the URL, every registered home-view anchor is present once, the demo step adds a paper by itself (pointed at an uploaded PDF throughgamma-guide-vars, so no network), the user's highlight checks the next step off, Esc leaves and records the dismissal, the account menu's "Take the tour" restarts it. -
ipad.mjs: the installed web app (ipad.md) — the manifest and its icons,theme-colorfollowing the theme, the standalone-mode block in the bundled stylesheet (display-modecannot be emulated in Chromium).--only ipad. -
pdfTouch.mjs: 400% rendering under an emulated canvas limit, distant-page release/repaint, live ink, native touch swipes (pdf_loading.md).--only "pdf touch". -
pdfload.mjs: PDF loading, in a non-default workspace — the timing probe (a 300-page, 20 MB document opened cold at an emulated 20 Mbps, the IndexedDB backfill, a warm reopen, a same-tab return; reports the per-phaseperformance.mark("pdf-<phase>")stamps and the bytes on the wire as each step's note, asserts only that it paints), then the behaviours: page boxes from the manifest (a landscape page below the fold), the last-read page after a reload, two large documents through the parsed-document cache, the anonymous share view by ranges (pdf_loading.md).npm run e2e -- --only "pdf load". -
files.mjs: files dropped on a block row / the page body become file chips (adropFileshelper builds a real DataTransfer; the paste step builds aClipboardEventin the page, since Playwright'sdispatchEventcannot), a PDF chip's right-click "Add to library" makes the document page in the project's folder and the chip gets an open-page button, a markdown chip's "Add to library" imports a note page and leaves the file untouched, the upload endpoint's lab-file / executable rule. -
collab.mjs: two accounts in a shared workspace: presence, live ops, edits to different blocks, same-block last-writer-wins, undo after a remote edit, rename propagation, edits made offline replaying, remote delete, a highlight made by the other person. -
run.mjs: login, guest access, and refusing an inaccessible explicit workspace without opening a different library. -
share.mjs: the share dialog, the anonymous share view (PDF, highlight, image through the share token, no editor), an edit share.
Every step also asserts that no API call failed (4xx/5xx), no console error
and no page error happened meanwhile (openPage records them;
EXPECTED_FAILURES in the harness lists designed refusals such as the
metadata fetch's 404 for a PDF without identifiers). Wait for the state a
step needs with until() / waitForFunction / waitForEvent, never a fixed
sleep — the one left (the view-only share's "no editor opens") is a
negative check with nothing to wait for. New UI work touching the
save path, workspaces, auth or rendering of URLs should add a step here; the
/verify skill runs this suite.
- Server log — Settings → Server → "Server log" (admin only): the
in-memory ring buffer behind
GET /api/admin/logs, filterable to warnings / errors; the Dashboard above it counts them since startup and shows the build and the update check. Backend code must log throughgamma/logbuf.py'slog(neverprint()); uselog.warningfor what an admin should notice. Secrets are masked at insert time. Gone on restart. - Session log + debug tracing — Settings → Diagnostics: browser-side event
log; the "Debug logging" toggle traces reading-position/restore/sync
events into it and the console. Every PDF load phase lands here as
pdf <phase> +<ms>(ms since the viewer started opening that url) and as aperformance.mark("pdf-<phase>")for devtools' Performance panel — the phases and what a healthy open looks like: pdf_loading.md. - Background tasks — the tasks popover shows every client-side job
(downloads, uploads, imports, metadata / citation / title / translation AI
jobs) and the server's indexing (
GET /api/tasks). A row carries a progress bar while the work can measure itself (bytes, translated pages, indexed papers) and a stop button (hover) while it can be stopped: the viewer's download and the export download abort their fetch, uploads abort their XHR, the AI jobs and zip imports abort their request, translation halts the engine, indexing asks the server (DELETE /api/tasks/indexing, which finishes the current paper and skips the rest). A stopped row reads "stopped" and ignores the job's own late reports (cancelledTransfersRefin App.jsx). The client polls/api/tasksevery 2 s only while the popover is open or indexing is known to run; otherwise a 60 s heartbeat, and nothing at all while the tab is hidden (one refresh when it comes back). Anything that starts indexing (the search panel's library query, the Settings reindex buttons) callswakeTasksso the button appears at once instead of waiting for the heartbeat. Work started elsewhere (the AI chat's own extraction, another tab) shows up within the heartbeat. - Status bar — Settings → Appearance turns the floating status pill into a persistent bar under the tabs.
- Report a problem — account menu → "Report a problem…", or the Help
section of Settings → Diagnostics.
src/support/ReportProblem.jsxasks what happened and how to reproduce it, thensrc/support/problemReport.js(pure, unit-tested intests/problemReport.test.mjs) builds the report: the build (buildonGET /api/session,version.build_info()), the browser and screen, the kind of view open and the workspace's kind and role (never its name), the session log's warnings and errors plus its newest lines, and — for an admin — the Server dashboard line and the server log's last warnings. Every line goes through a JS mirror oflogbuf.scrub. "Open GitHub issue" copies the whole report to the clipboard and opens.github/ISSUE_TEMPLATE/bug_report.ymlprefilled through its field ids (description,steps,diagnostics; trimmed to GitHub's URL budget, in which case the status line says to paste). Nothing leaves the browser until the reporter submits the form; "Copy report" is the path for people without GitHub. Keep the field ids and the query parameters in step. Screen recording (the dialog's Record… row, shown wheregetDisplayMedia+MediaRecorderexist): the dialog folds into a pill while the reporter reproduces the problem, Stop (the pill, the browser's own stop-sharing bar, or the 3-minute cap) brings it back with the file (webm, or mp4 where that is what the browser records; 1.5 Mbit/s, no sound), Save downloads it, and opening the form saves it too. A URL cannot carry a file, so the steps name the file and the reporter drops it into the form (GitHub uploads it with the issue). The e2e step stubs the picker with a canvas stream so the recorder itself runs for real. - Library health — Settings → Library maintenance lists, per paper:
metadata state, extracted-text chars, and search-index coverage, with
per-row retry/reindex buttons plus batch actions: Fetch needed / Refetch
all for metadata, and Reindex needed (only papers the index is missing,
holds stale, or hasn't visited — a targeted
/api/search-reindexwithdoc_ids, unlike the Index section's full Rebuild).
- Every guest login makes a new throwaway account, deleted with its
workspace
guest_ttl_hours(default 24) later or on logout (guests.md) — don't park test data there. In backend tests theguestfixture's account name isconftest.guest_name(). - Slow endpoints (downloads, AI calls, PyPDF2) are deliberately sync
defso FastAPI's threadpool runs them; don't convert them toasync defwhile they hold blocking calls. - All state is SQLite + files under the data dir (
GAMMA_DATA_DIR, default the repo'sdata/): globalusers.db(accounts, workspaces, memberships, shares, personal prefs), per-workspaceworkspaces/<id>/pages.db,data.db,uploads/. Safe to inspect with any SQLite client while the server runs; on Windows, open handles lock the directory (matters for the migration's moves — stop the server beforemanage.py migrate). - The server upgrades the data directory at startup (
gamma/migrations.py, snapshot first, log line[migrate]) and refuses to start on a directory written by a newer Gamma — migrations.md.python manage.py migrate --statussays where a directory stands. - Every API call names its workspace (
X-Gamma-Workspaceheader from the fetch wrapper,?ws=on the websocket and in URLs); a 403 "not a member" on an otherwise fine request means the tab's workspace is not the one you expect — the id is in the URL. - An AI reply ending in "lost the connection to the server (network
error)" means the browser→Gamma connection was cut mid-stream, not that the
provider failed (that comes back in-band as "AI call failed: …"). The
streams send a keepalive line every 15 s of silence (ai.md) and
the server logs
client closed the stream after Ns; if it still happens, a proxy in front of Gamma is closing idle responses sooner than that. - Timestamps are UTC ISO strings with
Z(page_now()); keep the format.