-
Notifications
You must be signed in to change notification settings - Fork 4
testing
SaiLoR uses three deliberately separated test tiers, each owning the class of bug the tier below it structurally cannot see. The boundaries are chosen so a faster, cheaper test covers everything it can, and slower, more expensive tests only exist for the seams that genuinely need them.
flowchart TD
PR["Pull request / push to main"]
PR --> CI["ci.yml → scripts/ci.sh"]
CI --> TC["tsc -b typecheck"]
CI --> WL["check:wiki"]
CI --> UNIT["npm test<br/>Vitest unit suite"]
CI --> BUILD["vite build<br/>static SPA"]
UNIT -. excludes .-> INT["*.integration.test.tsx"]
REL["release.yml<br/>release published"] --> IT["integration-tests.yml<br/>(reusable workflow_call)"]
IT --> INTJOB["integration-test job<br/>npm run test:integration"]
IT --> E2EJOB["e2e-test job<br/>xvfb-run npm run test:e2e"]
INTJOB --> RELBUILD["build job<br/>(needs: integration-tests)"]
E2EJOB --> RELBUILD
RELBUILD --> PKG["electron-builder<br/>macOS/Windows/Linux installers"]
INT -. "mocks getPlatform()<br/>real scratch git repos" .- INTJOB
E2EJOB -. "real Electron main<br/>dist-electron/main.js" .- E2EJOB
The CI gating flow: unit tests gate every PR; integration and e2e gate the release build.
The default, fastest tier. Runs on every PR.
Configured in the test block of vite.config.ts:
// vite.config.ts
test: {
environment: 'jsdom',
globals: true,
include: ['src/**/*.test.{ts,tsx}'],
exclude: [
'**/node_modules/**',
'**/dist/**',
'**/.{idea,git,cache,output,temp}/**',
'**/*.integration.test.{ts,tsx}',
],
},-
Environment:
jsdom, globals:true(sodescribe/it/expect/viare available without imports — though most files import them explicitly for editor/IDE support). -
Include:
src/**/*.test.{ts,tsx}. -
Exclude: the integration suite is explicitly kept out. Because Vite's
test.excludereplaces its default list rather than appending to it, the usual defaults (node_modules,dist, etc.) are repeated alongside the*.integration.test.{ts,tsx}pattern.
This tier is ~90 files of pure-logic and store tests that never touch a real filesystem or git binary. See Test conventions below for what each area covers.
The jsdom-based integration tests. Slower by design, kept off the default run, gated in front of release builds.
// package.json
"test:integration": "vitest run --config vitest.integration.config.ts",A standalone config rather than mergeConfig over vite.config.ts —
Vite's mergeConfig concatenates array fields like test.include rather than
replacing them, which would silently pull the ~90 unit test files into this run
too.
// vitest.integration.config.ts
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
include: ['src/test/integration/**/*.integration.test.tsx'],
},
})The suite lives in src/test/integration/*.integration.test.tsx. Each test:
-
Mocks
getPlatform()to inject a fakePlatformAdapter— the React app talks only to that interface (the same one that makes the app run inside Electron and in a plain browser), so mocking it at that single seam makes the whole app run under jsdom without a real Electron environment. -
Spins up a real scratch git repository per test (
mkdtempSync→git init→ initial commit), and wires a fakeGitPlatformwhosestatus/commit/beginPull/ etc. shell out to the realgitbinary against that directory — so merge, pull, push, branch-switch, and discard flows run against genuine git, not a stub. -
Renders the real React components with React Testing Library (
render,screen,userEvent), driving them with real clicks, typing, and DOM events — not direct store calls. -
Verifies independently of the app by reading the scratch repo directly
(e.g.
git log -1 --format=%s,git show HEAD:project.json) so a green test proves a real commit landed, not just that the store claims it did.
Because jsdom performs no real layout, the integration tests stub the geometry
primitives the components actually read (getBoundingClientRect,
Range.getClientRects, ResizeObserver) and mock react-pdf (which needs a
real canvas/PDF.js parsing jsdom cannot provide) — but everything above that
(selection capture, mark creation, the comment popover, the annotation form,
the Git panel) is the genuine component code.
The integration tests in src/test/integration/:
| File | Flow it owns |
|---|---|
annotationWorkflow.integration.test.tsx |
Author a schema (every field type) → annotate a real PDF (highlight, sticky note, comment, field value) → save → commit with real git |
consolidationAndMerge.integration.test.tsx |
Multi-reviewer consolidation and an explicit merge with a real conflict |
branchSwitch.integration.test.tsx |
Switching branches with uncommitted changes the target also touched — a real stash-carry-over → field-level conflict via GitMergeDialog
|
pull.integration.test.tsx |
A real git pull against a bare "origin" diverged by a second clone, through GitDialog's real "Pull" button |
discard.integration.test.tsx |
Discarding an uncommitted field-level change back to its last-committed value (writeWorking, not commit) |
pdfReadingPosition.integration.test.tsx |
"Continue where you left off" — reopening lands on the same paper/page |
screeningImport.integration.test.tsx |
Screening include/exclude decisions → "New from screening…" import flow |
fetchPolicy.integration.test.tsx |
Background-fetch safety guard against a repo whose own config names a fetch command (core.sshCommand, credential.helper, include.path, …) — tested against real git config --get-regexp
|
stashOps.integration.test.tsx |
pushStash/restoreStash/dropStash/branchFromStash and the branch-switch carry-over against real git — a stash that no longer fits rolls all the way back, never leaving conflict markers |
Electron smoke tests only — real main process, real contextBridge/IPC, real
fs. This tier exists for the class of bug jsdom structurally cannot see: the
preload bridge and the main process's IPC handlers actually wired up
correctly.
// package.json
"test:e2e": "cross-env ELECTRON=1 npm run build && playwright test",Requires ELECTRON=1 and a built app — the script builds it first
(cross-env ELECTRON=1 npm run build produces dist-electron/main.js).
// playwright.config.ts
export default defineConfig({
testDir: './e2e',
timeout: 30_000,
workers: 1, // each test launches its own real Electron process
reporter: 'list',
})One worker: there is no shared state to race, so parallelism would only cost CPU/RAM for no speed-up.
The e2e tests drive the window.slr bridge methods directly (openPath,
saveProject, gitProbe, gitStatus, gitPush) rather than through the
rendered UI — the native "Open"/"Save" dialogs a real user click would hit are
exactly the one thing Playwright can't drive here, so the tests call the bridge
methods a menu click would eventually reach instead.
e2e/openSaveProject.spec.ts covers three distinct concerns:
-
Open/save round-trip — opens a project by path through real IPC, then
saves it back. Exercises a real guard:
electron/main.ts'sproject:savehandler refuses to write to any path that wasn't first opened viaproject:open/project:openPath(knownProjectPaths) — the test proves the guard is real by asserting a save to an unopened path is rejected with/was not opened or chosen/. -
Real git IPC —
gitProbe(returnsavailable: true, version matches/git version/) andgitStatusthrough the hardenedrunGit/gitEnv/GIT_SAFE_CONFIGwrapper (which stripsGIT_DIR, disables hooks/pager/ ext-diff). The only test that reaches the real wrapper through the real contextBridge/IPC path; the jsdom integration tests fakeGitPlatformentirely. -
Split-file save/reopen round-trip — the only test that exercises the
real split layout (
project.jsoncarries metadata only;annotations/<id>/*.jsonholds the per-paper answer) and the reassembly (readProjectText→assembleLegacyProjectJson) a real reopen does to turn that back into the single in-memory shape.
e2e/gitPush.spec.ts covers the one flow no jsdom test can: a real git:push
IPC handler against a real bare "origin". It creates a bare origin and a local
clone, establishes upstream tracking with real git, leaves one commit
unpushed, then drives window.slr.gitPush(localDir) and confirms the commit
landed by reading the bare repo directly (git log -1 --format=%s in
originDir), independent of the app and of the local clone's own idea of what
happened.
The single source of truth for "does the app build and pass its checks". CI
providers (GitHub Actions, GitLab CI, …) do nothing more than check out the
code, provide a Node.js toolchain, and run this script — so the exact same
checks run locally with ./scripts/ci.sh.
# scripts/ci.sh — ordered steps (set -euo pipefail)
step "Type checking (tsc -b)" # npm run typecheck
step "Checking wiki links (openwiki/, user-guide/)" # npm run check:wiki
step "Running tests (vitest)" # npm test (unit tier only)
step "Building static SPA (vite build)" # npm run buildRun from the repo root regardless of where it's invoked; SKIP_INSTALL=1
skips npm ci/npm install when deps are already present.
# .github/workflows/ci.yml
on:
push: { branches: [main] }
pull_request: # every PR, whatever it targets
workflow_dispatch:The CI workflow runs ./scripts/ci.sh on ubuntu-latest (Node 24). It runs
the unit tier only (npm test excludes integration tests) on every push to
main and every pull request.
A reusable workflow (workflow_call + workflow_dispatch) with two jobs:
-
integration-test—npm run test:integration(the jsdom integration suite with real scratch git repos). -
e2e-test—xvfb-run -a npm run test:e2e. Needs a real display even though nothing is screenshotted: Electron opens a real native window on Linux regardless, hencexvfb-run.
Stays independently triggerable (Actions tab → "Run workflow", or
gh workflow run integration-tests.yml) for a fast check without a full
release run.
# .github/workflows/release.yml
on: { release: { types: [published] } }
jobs:
integration-tests:
uses: ./.github/workflows/integration-tests.yml
build:
needs: integration-tests # a broken e2e/integration flow must not reach a packaged releaseThe build job (needs: integration-tests) builds the packaged Electron app
for macOS, Windows, and Linux via electron-builder. A broken end-to-end
workflow (schema authoring, PDF annotation, git commit, merge/pull/branch-
switch/discard, screening import, real IPC) is blocked from reaching a
packaged release by this gate.
Pure-logic tests of the data model. Fixtures are built through the real load
path (loadProject / serializeProject), not hand-assembled Project objects,
so every input is exactly as schema-normalized as a real file would be. Cover:
schema resolution (defaults, path ids, duplicate sibling names, enum options,
max < min rejection, required defaults, visibleIf conditional-visibility
resolution — bare-name, absolute-path, nested all/any groups, equals
filtering, cross-branch/ancestor reads), isFieldVisible (fail-open semantics,
per-instance repeatable reads), normalizeTree / pruneTree / canAdd /
canRemove, loadProject input validation (ProjectLoadError), loadProject →
serializeProject round-trip, Paper.finished (a human declaration, not
derived), Paper.aiUsage (AI-use disclosure), config.ai (AI opt-out),
config.reviewers / Paper.reviews (multi-reviewer skeleton, per-reviewer
normalization, deterministic serialization), Paper.equal, screening,
Project.provenance / Project.protocol / Project.schemaInfo (degrade
field-by-field rather than all-or-nothing), and a regression for hand-edited
primitive instances the loader must not crash on.
// src/model/model.test.ts — representative schema-resolution + round-trip test
it('resolves and round-trips enum options', () => {
const resolved = resolveSchema([{ name: 'Kind', type: 'string', options: ['A', 'B', 'C'] }])
expect(resolved[0].options).toEqual(['A', 'B', 'C'])
const project = loadProject(/* … */)
const reDumped = JSON.parse(serializeProject(project))
expect(reDumped.config.schema[0].options).toEqual(['A', 'B', 'C'])
})Tests of the Zustand stores (useStore, useEditorStore, useGitStore,
useAiStore). The platform seam is mocked at getPlatform():
// src/state/store.marks.test.ts — the standard store-test platform mock
const mockPlatform = { kind: 'browser' as const, getOsInfo: () => null, /* … */ }
vi.mock('../platform', () => ({ getPlatform: () => mockPlatform }))
const { useStore } = await import('./store')A beforeEach loads a project into the store (st().loadFromText(PROJECT, …))
so each test starts from a known state. Cover: undo/redo (including coalescing
consecutive edits of the same field into one undo step, and a new edit clearing
the redo stack), annotation lifecycle (setFieldValue, addInstance /
removeInstance), marks (addHighlight / setMarkComment / setMarkColor /
removeMark), reading position (noteReadingPosition / loadReadingPosition),
reviewer seats, save / save-as, screening, alignment, batch-unanimous adoption,
and the hidden unlockAi session flag. The gitStore.test.ts suite stubs the
full GitPlatform shape and drives runPull's orchestration (dirty guard,
pull classification, parse boundary, merge, resolution dialog) through each
branch via beginPullResult / finishPullResult.
Pure-logic tests of the multi-reviewer consolidation: alignNode (matching
entries reviewers recorded in different orders, matching through wording
differences rather than demanding identical text, ranking by number of
agreeing fields), agreement metrics, unanimous adoption, disagreements
export, and readiness.
Pure-logic tests of the git layer. merge.test.ts builds fixtures through
loadProject (the real load path) and covers merge3 / mergeProjects /
applyResolutions / conflictId over repeatable findings and paper-meta
fields. ref.test.ts covers refProblem — the ref/path safety guard that
refuses empty refs, option-like refs (--upload-pack=…), control characters,
revision syntax (main^, main@{1}), and everything git check-ref-format
forbids. Also: changes.test.ts (field-change detection), output.test.ts
(parsePorcelain / capDiff), relpath.test.ts, url.test.ts,
deriveGitInfo.test.ts, concurrentRead.test.ts, ownAnnotationPath.test.ts.
Unit tests of pure helper functions extracted from components, tested
directly without rendering. The rendered-component coverage (real RTL rendering,
real clicks/typing) lives in the integration suite instead; these .test.ts
files cover the nontrivial decision logic the components export:
-
PaperList.test.ts—paperIsMarkedDone(the consolidation case: the status dot must mean "every numbered reviewer has answered", read frompaper.reviews, not "doespaper.annotationshave anything" — becauseadoptUnanimousValuesfillspaper.annotationsjust from opening the paper). -
GitDialog.test.ts—mixedDiscardConfirmMessage(warn before a commit silently reverts or deletes a Discard row; aPaperChangemarked Discard deletes the paper and all its annotation files, so it is named as "1 paper", not "1 field") andisProjectOwnPath. -
PdfViewer.test.ts—destinationPoint(XYZ/FitH/FitV/FitR destination parsing),markVerticallyVisible,dedupeOverlappingRects. -
PapersEditor.test.ts—duplicatePaperIds(trimmed, case-insensitive, empty ids not flagged; case-insensitive because both land in one folder on a case-insensitive checkout andvalidateDraftrefuses them at save time). - Plus
ConsolidationDialog,ConsolidationVerdicts,GitMergeDialog,AgreementDialog,NodeName,Toolbar, andPaperListperformance / completeness / finished variants.
| Area you changed | Run |
|---|---|
Data model (src/model/*) |
npm test — the relevant src/model/*.test.ts
|
Store (src/state/*) |
npm test — the relevant src/state/store.*.test.ts / editorStore.*.test.ts / gitStore.test.ts
|
Git logic (src/git/*) |
npm test (src/git/*.test.ts) and npm run test:integration (real-git merge/pull/push/branch-switch/discard) |
Component (src/components/*) |
npm test (src/components/*.test.ts) and npm run test:integration (the rendered-component flow) |
Platform seam / Electron main (electron/*, src/platform/*) |
npm run test:e2e (real IPC, real contextBridge, real fs) |
| Anything touching save/open/git IPC |
npm run test:e2e (the only tier that reaches the real main-process handlers) |
For a full local pre-release check, run all three tiers:
npm test && npm run test:integration && npm run test:e2e.
These pages are a mirror. They are maintained in
openwiki/ in the repository and published
here automatically. Editing a page here is fine — the change is committed back to that folder — but
the folder is the source of truth. See Operations → Wiki sync.
Repository · README · Issues · Releases · Annotation schema guide
SaiLoR is free software under the GNU General Public License v3.0.