Skip to content

Latest commit

 

History

History
406 lines (284 loc) · 17.2 KB

File metadata and controls

406 lines (284 loc) · 17.2 KB

Development Guide

Language Policy

  • The application UI must be in English.
  • Source code, code comments, and repository documentation must be in English.

See also conventions.md.

Local Development

Prerequisites

  • Node.js 20+ with npm
  • A Google Cloud project
  • A Google account for testing

Install dependencies

npm install

If node or npm is installed but not available in the current PowerShell session, reopen the terminal or prepend Node.js to PATH temporarily:

$env:Path='C:\Program Files\nodejs;' + $env:Path

Create the local environment file

Create .env from .env.example:

cp .env.example .env

On Windows PowerShell:

Copy-Item .env.example .env

Then edit .env:

VITE_GOOGLE_CLIENT_ID=your-google-oauth-client-id.apps.googleusercontent.com
VITE_SUTRAPAD_FILE_NAME=sutrapad-data.json

Start the app

npm run dev

The dev server listens on all local interfaces, so you can open it from other devices in your LAN:

Test PWA features over HTTPS in local development

For full PWA behavior on another device, use a trusted local certificate because service workers require a secure context outside localhost.

Add these optional variables to .env:

VITE_DEV_HTTPS_KEY_PATH=.cert/dev-key.pem
VITE_DEV_HTTPS_CERT_PATH=.cert/dev-cert.pem

Then start the app with the usual command:

npm run dev

If both files exist, Vite serves the app over HTTPS and you can open:

  • local machine: https://localhost:5173
  • another device: https://YOUR-LAN-IP:5173

Recommended workflow:

  • create a local certificate with mkcert for localhost and your LAN IP
  • trust the generated root CA on the devices you want to test with

If the certificate files are configured but missing, the dev server exits with a clear error.

Example setup with mkcert on Windows

  1. Install mkcert.

If you use winget:

winget install FiloSottile.mkcert
  1. Install and trust the local root CA on your development machine:
mkcert -install
  1. Find your LAN IP address.

Example:

ipconfig

Look for your active adapter and copy the IPv4 address, for example 192.168.1.25.

  1. Generate a certificate for localhost, loopback, and your LAN IP:
New-Item -ItemType Directory -Force .cert
mkcert -key-file .cert/dev-key.pem -cert-file .cert/dev-cert.pem localhost 127.0.0.1 ::1 192.168.1.25

You can also use the helper script, which tries to detect your LAN IP automatically:

npm run cert:dev

If auto-detection picks the wrong address or cannot find one, pass the IP manually:

npm run cert:dev -- 192.168.1.25

The script creates .cert/dev-key.pem and .cert/dev-cert.pem for the selected LAN IP.

  1. Add the generated file paths to .env:
VITE_DEV_HTTPS_KEY_PATH=.cert/dev-key.pem
VITE_DEV_HTTPS_CERT_PATH=.cert/dev-cert.pem
  1. Start the dev server:
npm run dev
  1. Open the app:
  • on your computer: https://localhost:5173
  • on another device in the same network: https://192.168.1.25:5173

Trust the certificate on mobile devices

To avoid browser security warnings on phones or tablets, the device must trust the same mkcert root CA.

  • iPhone/iPad:
    • export the mkcert root CA from your computer
    • install it as a profile on the device
    • enable full trust for that certificate in Settings > General > About > Certificate Trust Settings
  • Android:
    • copy the root CA certificate to the device
    • install it from security certificate settings
    • note that some browsers or work profiles may still apply extra certificate restrictions

If you only need quick UI checks, plain HTTP over LAN is simpler. Use HTTPS when you need realistic PWA behavior such as service worker registration and installability.

Run checks

npm run check

This runs:

  • npm run lint
  • npm test
  • npm run build

Run mutation testing

npm run test:mutation

StrykerJS needs TypeScript 6.x. Both its tsconfig preprocessor and the typescript-checker plugin call ts.parseConfigFileTextToJson, which the TypeScript 7 rewrite removed — on TypeScript 7 the run dies with TypeError: ts.parseConfigFileTextToJson is not a function right after instrumentation, while npm run check stays green because tsc itself is happy. Stryker 10 makes the same call, so upgrading Stryker is not a way out. The typescript dependency is therefore held at ^6.0.3, with matching holds in renovate.json and .github/dependabot.yml; lift all three together once Stryker supports TypeScript 7.

This runs StrykerJS mutation testing. stryker.config.mjs is the single source of truth for the mutate scope — it carries a comment per entry explaining why the file is in or out. This page used to duplicate the list and went stale the moment the scope was widened (6 view modules documented vs. 21 actually configured), so it describes the shape instead:

  • Globbed wholesale, so a new module in one of these is mutated automatically: src/lib/**, src/app/logic/**, src/app/storage/**, src/app/session/**, src/app/capture/**.
  • Listed one by one, because their directories also hold modules without dedicated tests: the Drive services, src/app/lifecycle/*, and the src/app/view/** modules that have a // @vitest-environment happy-dom suite of their own.
  • Explicitly excluded with a ! pattern: *.d.ts, and pure-data modules like src/app/logic/lexicon/stoplist.ts (a frozen Czech-stopword Set — Stryker fires a StringLiteral mutant per word and no real test can pin them), src/app/logic/lexicon/types.ts, and the message catalogs src/lib/i18n/en.ts / src/lib/i18n/cs.ts (several hundred sentences; the shipped wording is asserted where it renders, not through these modules). Note the exclusion is the data, never the logic beside it: i18n/locales, i18n/plural, i18n/index and app/logic/locale all stay in scope.

Adding a new source file:

  • If it lands in one of the globbed directories, the include is automatic.
  • Otherwise, add an explicit path to mutate: in the same change as the file. If it's pure data or types, add a matching ! exclusion so the score reflects logic only. If it genuinely has to wait for a test, add it to DEFERRED_FROM_MUTATION in tests/mutate-scope.test.ts with a reason.
  • tests/mutate-scope.test.ts enforces all of the above — it fails when a src/**/*.ts is none of mutated, !-excluded, or deferred-with-a-reason, so a module can no longer go silently unmeasured. It also fails on a mutated module whose only test vi.mocks it or imports just its type: that shape reports every mutant as NoCoverage, which is how src/app/view/palette.ts sat at 0.00 % with 166 mutants.
  • A new file that pulls overall below thresholds.break is a signal to either write more tests before merging or to lower break: temporarily with a comment explaining the deferred work — never silently merge a file that pulls CI red.

The HTML report is written to:

  • reports/mutation/mutation.html

A machine-readable JSON report (standard mutationtestingelementsschema.json shape) is written alongside it at reports/mutation/mutation.json.

Important:

  • Do not run npm run test:coverage and npm run test:mutation in parallel.
  • Both tools use coverage artifacts during their runs, and running them at the same time can break Stryker with missing coverage/.tmp files.
  • If you want both reports locally, run them sequentially.

GitHub Actions also runs mutation testing in a separate Daily Mutation Testing workflow once per day and on manual dispatch.

Google Cloud Setup

In Google Cloud Console:

  1. Create or open a project.
  2. Enable Google Drive API.
  3. Configure the OAuth consent screen.
  4. Create an OAuth client with type Web application.

Set the OAuth client to include:

  • Authorized JavaScript origins
    • http://localhost:5173
    • https://localhost:5173
    • http://YOUR-LAN-IP:5173 when testing over LAN without HTTPS
    • https://YOUR-LAN-IP:5173 when testing over LAN with HTTPS
    • https://filda.github.io
  • Authorized redirect URIs
    • none is required for the popup token flow used by this app

Notes:

  • The OAuth client type must be Web application.
  • The origin must match exactly, including protocol, host, and port.
  • If you switch from localhost to a LAN IP such as 192.168.88.40, that LAN origin must be added explicitly.
  • redirect_uri_mismatch in this app usually means the current page origin is missing from Authorized JavaScript origins, or the wrong OAuth client ID is being used.

Scopes used by the app:

  • openid
  • profile
  • email
  • https://www.googleapis.com/auth/drive.file

Official references:

Deployment

Production site:

GitHub Pages is configured through GitHub Actions and uses the repository subpath /sutrapad/.

Manual deployment flow

  1. Push the desired commit to GitHub.
  2. Open the repository Actions tab.
  3. Run the Validate and Deploy workflow manually.
  4. Wait for the workflow to finish and then open the production URL above.

GitHub Pages build configuration

  • Add repository variable VITE_GOOGLE_CLIENT_ID in Settings > Secrets and variables > Actions > Variables.
  • The Validate and Deploy workflow reads this variable during the production build.
  • A separate .env file is not created in CI; the value is injected directly into the build environment.

Capture Features

Link capture

Optional query parameters:

  • url - the captured page URL
  • title - optional page title passed by a bookmarklet

Text note capture

The app can enrich note titles with:

  • time-of-day labels such as early morning or high noon
  • reverse geocoded place labels via Nominatim
  • cached location labels in localStorage

Android share sheet capture

When the PWA is installed on Android, the app can appear in the system share sheet.

  • shared links are routed to the existing ?url= and ?title= capture flow
  • shared plain text is routed to the existing ?note= capture flow
  • the manifest share_target uses GET, so no extra service worker POST handling is needed for text and link shares
  • Android testing note: the app needs the browser's real Install flow, not only Add to Home screen
  • a home screen shortcut alone may not register SutraPad as a share target
  • verified path so far: install the PWA through Chrome on Android, then share into SutraPad from the system share sheet

Bookmarklet And Shortcut

The app exposes a bookmarklet link in the UI.

Compatibility notes:

  • Desktop Chrome, Brave, and Opera generally work well with drag-to-bookmarks-bar bookmarklets.
  • Desktop Safari supports bookmarklets too, but adding them is often easier by creating a normal bookmark first and then replacing its URL with the copied bookmarklet code.
  • On iPhone and iPad, the Shortcut is the recommended capture flow.

Architecture Notes

  • Client-only application with no backend
  • PWA foundation powered by vite-plugin-pwa
  • Sign-in with Google Identity Services
  • Multiple notes stored in Google Drive
  • One note per JSON file plus a notebook index file
  • Each note is stored as its own JSON file in Google Drive
  • A separate JSON index file keeps the note list and active note selection
  • Production PWA assets and the service worker are generated by vite-plugin-pwa
  • Location labels are powered by OpenStreetMap and Nominatim
  • src/app.ts acts as the main controller and orchestration layer
  • src/app/logic/** contains pure helpers that should be preferred for business rules
  • src/app/storage/** contains local persistence and backward-compatibility normalization
  • src/app/session/** contains sign-in and sync orchestration
  • src/app/capture/** contains capture-specific use cases
  • src/app/view/** contains DOM-building UI helpers

Testing Notes

  • When a piece of logic becomes awkward to test through DOM setup in src/app.ts, extract it into src/app/logic/**, src/app/storage/**, or another pure helper module first.
  • Coverage and mutation testing both improve faster when new behavior lands in pure modules instead of controller or DOM wiring code.
  • src/app/view/render-app.ts is intentionally still mostly untested at the unit level; prefer moving decision-heavy logic out of it before adding large DOM-heavy tests.
  • Stryker mutates the pure-helper areas (src/lib, src/app/{logic,storage,session,capture}), the auth + Drive services, the src/app/lifecycle modules with focused tests, and the subset of src/app/view/** that has dedicated happy-dom tests. The full list lives in stryker.config.mjs; tests/mutate-scope.test.ts fails if a new module isn't classified, so the two can't drift apart silently.
  • View files without a dedicated test, the composition root in src/app.ts, and glue modules under src/app/{render-callbacks,render-helpers,silent-capture-runner,state-store,sync-helpers}.ts are intentionally not mutated yet — their only coverage is the smoke test, which is too coarse to discriminate mutants. Add a focused test before widening the mutate scope to one of these; each one is listed with its reason in DEFERRED_FROM_MUTATION (tests/mutate-scope.test.ts), so that list doubles as the backlog.

Non-functional tests

tests/nfr/** asserts properties of the system rather than answers from functions: request counts, concurrency caps, round-trip idempotence of load → save → load, index drift recovery, and the resident-model shape — all against a workspace generated in the shape of the real one (tests/nfr/workspace-fixture.ts, ~6 470 notes) and an in-memory Drive (tests/nfr/fake-drive.ts, which evaluates the stores' real query strings through tests/nfr/drive-query.ts and counts every call). The numbers they assert come from src/lib/budgets.ts, the same module the app's runtime guards in src/services/drive/save-policy.ts read, so a budget is changed in one place and both sides move together. Counts and invariants only; no timing assertions — see docs/nfr-testing-plan.md for the rationale and the layers still to come.

The whole tests/nfr/ tree runs in npm test (a few seconds; npm run test:nfr runs it alone, and CI runs it as its own "Non-functional budgets" step so a red there reads as "something scales with the note count" rather than "a unit test broke") and is excluded under Stryker (vitest.config.ts): a broad property test kills no mutant a targeted unit test shouldn't already kill, and would mask the missing assertion the mutation score exists to expose. Logic the NFR layer exercises still needs its own unit test under mutation pressure.

At runtime the same budgets are observed, not just tested: GoogleDriveStore reports soft-budget overruns through onBudgetOverrun, every Drive operation is metered (src/services/drive/drive-meter.ts, one meter per load / save / refresh / rebuild via createWorkspaceIO's measured()), and src/app/session/main-thread-observers.ts listens for long tasks, slow interactions and a heap sample. All of it lands in the diagnostics$ snapshot (src/app/logic/diagnostics.ts) and is shown on Settings → Diagnostics; overruns, failed operations and interactions over 200 ms also reach the console as [budget], [drive] and [main-thread] lines.

Two guard tests keep codebase-level invariants from drifting the way tests/mutate-scope.test.ts does for the mutate scope: tests/body-reader-scope.test.ts fails when a src/ module reads a note's body without being classified as placeholder-aware (guarded) or placeholder-safe (harmless) — the class of bug behind the 2026-09-07 incident.

Structure

  • src/services/google-auth.ts - browser OAuth token flow
  • src/services/drive-store.ts - notebook index and per-note file storage in Drive
  • src/app.ts - app controller and orchestration
  • src/app/logic/ - pure business logic helpers
  • src/app/storage/ - local workspace persistence and normalization
  • src/app/session/ - session restore and sync flows
  • src/app/capture/ - note capture flows
  • src/app/view/ - DOM rendering helpers