Skip to content
Build and test

Build and test

Generated from DEVELOPMENT.md in the repository. Do not edit. Open the source file.

These rules make local and CI testing fast and the same.

Iteration speed

A slow tool is a defect. Make every test, CI job, screenshot run and evaluation harness fast on purpose.

  • Do not use a fixed sleep. Wait for a condition (an event, an element, document.fonts.ready, two animation frames).
  • Make timeouts and intervals configurable. Tests use short values. Production defaults stay, and a unit test covers them (example: --sse-heartbeat, --http-write-timeout in lasboltd, tested in server/cmd/lasboltd/timeouts_test.go).
  • Run independent work in parallel: t.Parallel() in tests that own their state, parallel viewports in make shots, parallel gates in make gates. Bound browser concurrency.
  • A random or long test has a short default and a long mode (LASBOLT_TEST_LONG=1, make test-long). Regression seeds always run.
  • Measure before you add a slow step. Print the time of a step if it can grow.
  • Do not run the production KDF where the test is not about the KDF. Screenshots use a WASM with the insecurekdf tag.

Time budget (Apple Silicon, 14 cores, warm cache). Keep the numbers. If a change makes one worse, fix it or say why.

CommandBudget
make checkunder 30 s (about 11 s, cold cache)
make testunder 60 s (about 31 s, cold cache)
make e2eunder 60 s (about 26 s; 50 s with TestTeamsBrowser, which holds 3 of the 4 browser slots for about 6 s)
make cli-e2eunder 20 s (about 8 s)
make e2e-extensionunder 30 s (about 5 s warm; the first run downloads Chrome for Testing, about 15 s)
make test-jsunder 2 s (about 0.1 s)
make shotsunder 90 s on the reference machine (about 60 s there; the recovery and reset views of DESIGN 8.1 run a real KDF in the fast WASM). The views of shared spaces (DESIGN 17.20) add about 13 s: on a slower machine the run took 82 s without them and 95 s with them. They run at the laptop and the phone viewport only, and the seed of the shared spaces runs only there. The views of teams (DESIGN 17.23) add about 11 s (46 views with their seed); a full run took 118 s on a busy machine.
make shots VIEWS=... VIEWPORTS=laptopunder 15 s
make gatesunder 2 min (about 41 s, cold cache)
make apple-testunder 15 s (about 2 s warm, 10 s cold)
make androidunder 60 s warm (the helper about 3 s, Gradle about 5 s; the first run downloads Gradle and the libraries)
make android-testunder 30 s (about 5 s warm)
make android-e2eunder 60 s with a booted emulator (about 16 s; the first cold boot takes some minutes)
make apple-e2eunder 60 s after make apple (the self test is about 3 s; an incremental make apple about 20 s)

Toolchain

  • Go 1.27. CI uses the image golang:1.27.
  • golangci-lint v2.14.0 and govulncheck v1.9.0. Both are pinned in the Makefile. make tools builds them once with go install into .tools/<tool>-<version>/ (not in the repository). The version is in the path, so a new pin builds a new binary. make lint uses these binaries, so the versions are the same locally and in CI. Change a pin on purpose.
  • Do not use cgo. All builds use CGO_ENABLED=0, except the iOS core library (make ios-core, DESIGN 14.1).

Commands

CommandAction
make checkThe fast loop: go vet and tests with the insecurekdf tag, without -race, all modules at the same time. Use it while you work.
make versionPrints the version of this checkout (DESIGN 19, “Versions and releases”).
make release-tag V=0.1.0Makes the release commit (CHANGELOG.md) and the annotated tag v0.1.0. It does not push. See “Versions and releases”.
make gatesAll gates before a merge, at the same time: test, test-prod, lint, build, e2e, cli-e2e. It builds the WASM first (all gates embed it). It prints one line for each gate, then a summary, and shows the log tail of a failed gate. Logs: .harness/logs/<gate>.log. The exit code is not zero if a gate failed.
make testUnit and integration tests, with -race and the insecurekdf tag. The random tests use short counts.
make test-longmake test with the full counts of the random tests (LASBOLT_TEST_LONG=1). CI runs it.
make test-prodTests without insecurekdf. This proves that production KDF limits apply.
make lintlint-go (go vet, golangci-lint, govulncheck) and lint-js (node --check on the page scripts).
make wasmBuilds core/bind/wasm to server/internal/web/static/pwcrypto.wasm and copies wasm_exec.js. Both files are git-ignored. Without them the login pages cannot derive keys.
make iconsMakes static/icons.css from the icon sprite static/lasbolt-icons.svg. Run it after you change the sprite.
make buildBuilds the WASM island, then the static binaries bin/lasboltd, bin/lasboltctl and bin/lasbolt.
make e2eBrowser E2E tests (build tags e2e,insecurekdf) in parallel. They use the browser harness. TestCLI is not in it (see cli-e2e).
make cli-e2eCLI integration tests (TestCLI*, tags e2e,insecurekdf), in parallel. Each test starts its own real lasboltd (built once for the run, with --device-poll-interval 200ms), then drives the real lasbolt. TestCLICtlBinary also builds lasboltctl and drives it over the control socket of the real lasboltd. No browser needed. About 5 s (it was 53 s with a 5 s poll interval, two builds for each test and serial tests).
make shotsScreenshots of the key views, all viewports. See “Browser harness”. Filters: VIEWS=..., VIEWPORTS=..., PARALLEL=N. Without VIEWS it also runs shots-extension.
make brand, make brand-checkWrite, or compare, every generated brand file from brand/tokens.json with brand/cmd/lasbolt-brand (docs/LOGO.md, docs/BRAND.md): logos, favicons, the CSS, Swift, Kotlin and Android brand files, the previews in docs/brand/. brand-check also fails when a contrast check fails. It takes less than 1 s. make brand also renders the layered iOS icon (apple/LasboltiOS/AppIcon.icon) into docs/brand/palettes/ with ictool from Xcode 26 (/Applications/Xcode.app/Contents/Applications/Icon Composer.app/Contents/Executables/ictool; set LASBOLT_ICTOOL for another path). Without ictool it skips the renders. brand-check never needs ictool. It runs in make check, make lint (so in make gates) and in the CI lint job. To switch the blue, change active in brand/tokens.json and run make brand. make logo and make logo-check are old names. make apple-assets makes the generated PNG files of the Apple asset catalogs; make apple-project and make ios-project run it.
make extensionBuilds the browser extension (DESIGN 11.11): dist/extension-chrome/, dist/extension-firefox/, dist/lasbolt-firefox.zip, dist/extension-safari/. The icons come from brand/ (make extension runs brand-check and the generator first). The build program (extension/build, standard library only) merges manifest.json with each overlay and checks the result (files, no remote code, the Chrome ID, the Firefox ID).
make test-jsThe unit tests of the extension (node --test extension/test/*.test.mjs: the field finder, the URL and frame rules) and node --check on its scripts. Node only, no package. In make gates.
make e2e-extensionTestExtension: the built Chrome extension in Chrome for Testing (--headless=new) with the real lasbolt nm-host, agent and lasboltd. Not in make e2e; in make gates. It writes .harness/shots/extension/ (popup light, dark, E2E locked, agent locked, the approval wait light and dark; the inline menu light and dark, the approval wait light and dark; index.html).
make shots-extensionThe same as make e2e-extension (the shots are a part of the test).
make shots-quickThe same for the laptop and phone viewports (all views).
make wasm-testBuilds the WASM with the insecurekdf tag into .harness/wasm/ (never into static/). make shots does it.
make apple-helperBuilds the lasbolt that the macOS app bundles (dist/apple-helper/lasbolt, CGO_ENABLED=0, arm64). make apple-project runs it.
make apple-project, make apple, make apple-openMake the Xcode project (xcodegen), build the app with xcodebuild (free Personal Team, DESIGN 11.12), or open it in Xcode. Needs Xcode and xcodegen. Not in make gates or CI.
make apple-testThe Swift tests of apple/LasboltKit (Swift Testing, with xcodebuild test; no app host, no signing): the agent client, the view models with a fake agent, the Safari relay rules, the clipboard rule. swift test in apple/LasboltKit runs the same tests.
make apple-releaseThe build for daily use (DESIGN 11.13): Release, hardened runtime on the app, the appex and the helper, no get-task-allow, in dist/apple-release/Lasbolt.app. It has no keychain entitlement and no profile, so it does not expire, and Touch ID is hidden. make apple-release TOUCHID=1 makes the Touch ID build in dist/apple-release-touchid/Lasbolt.app (keychain group and profile; with a free team rebuild every 7 days). `scripts/apple-release-check.sh default
make apple-e2eLasbolt --selftest of the built app against pwharness serve (scripts/apple-e2e.sh): the bundled lasbolt, a temporary home and socket, the file key store. It never uses your login. It also checks that the helper is signed and has no entitlements.
make runRuns lasboltd in dev mode on http://localhost:8080.
make vectorsWrites the crypto test vectors again. All inputs are fixed, so on unchanged code the file stays the same byte for byte. A ciphertext that uses a random nonce or key (sym, HPKE, stream) cannot be made again: the generator keeps the committed one if it opens with the fixed key. A new case gets its random bytes from a fixed seed. TestVectorsStable (in make test) fails if the generator output differs from the committed file.

SwiftUI previews are a gate

  • A SwiftUI change is not done until its previews are rendered and looked at, in light and dark. A preview that compiles can still be blank: a .fixedSize(horizontal: false, vertical: true) text in the list column of the split view made a layout loop that blanked the whole window (the rollback banner, 2026-10-17). Only the render showed it.
  • Render through the Xcode MCP bridge (XcodeOpenWorkspace with the absolute path of apple/Lasbolt.xcodeproj, then RenderPreview). Xcode cannot load the local package LasboltKit from a worktree under .claude/, so an agent in a worktree cannot render: the main thread renders after the merge.

Install on this Mac

  • make install: make install-cli (lasbolt, lasboltd and lasboltctl into $(PREFIX)/bin, default ~/.local/bin, no sudo) and, on macOS, make install-app (the Release app into $(APP_DIR), default ~/Applications; a running Lasbolt quits first, and the new app adopts its agent).
  • make install TOUCHID=1: the Touch ID build of the app. Run it again every 7 days (free Apple team, DESIGN 11.13).
  • make uninstall: removes the binaries and the app. Data and login stay.

Versions and releases

DESIGN 19 is the contract. One version covers all artifacts. The source is an annotated tag vMAJOR.MINOR.PATCH on master.

  • scripts/version.sh [--full|--short|--build] prints the version. The Makefile reads it once and passes it to Go (-X .../core/buildinfo.Version), xcodebuild (MARKETING_VERSION and CURRENT_PROJECT_VERSION on the command line), Gradle (lasboltVersionName, lasboltBuildNumber) and the extension build (-version, -version-name). No generated file in the repository changes with the version.
  • make version prints the version of this checkout. make VERSION=1.2.3 <target> sets it by hand.
  • scripts/version_test.sh (in make check, about 2 s) tests version.sh and release-tag.sh in temporary repositories.
  • To release: put each change that a user sees in CHANGELOG.md (Unreleased), in the commit of the change. Then, on a clean master: make release-tag V=0.1.0. It checks the tree, the branch and the order of the version, runs make gates (RELEASE_GATES=skip skips it), edits CHANGELOG.md, makes the commit Release v0.1.0 and the annotated tag. It does not push. Check the result, then run the command that it prints (git push origin master v0.1.0). The tag starts the CI job release. Build and install the apps from the tagged commit on a developer machine.
  • Check a build: lasbolt version; plutil -p Lasbolt.app/Contents/Info.plist (CFBundleShortVersionString, CFBundleVersion); aapt2 dump badging app-debug.apk (versionCode, versionName); version and version_name in dist/extension-*/manifest.json.
  • A CI job that makes a versioned artifact needs the full history (GIT_DEPTH: "0").
  • A dev build changes its version with each commit. A new lasbolt then replaces a running agent (the build in hello differs), and you unlock again.

Server start

  • Order: open the database, unseal, serve. Flags and LASBOLT_* variables: --addr, --data-dir, --key-provider, --unseal-passphrase-file, --key-file, --dev.
  • LASBOLT_PUBLIC_URL (flag --public-url) is the server address for the links of lasboltd user reset-password and for the invitation links of lasboltctl.
  • Instance administration (DESIGN 17.16): --ctl-socket PATH (env LASBOLT_CTL_SOCKET; empty is off; recommended /run/lasbolt/ctl.sock) opens the control socket of lasboltctl (mode 0600, no network; on Linux the peer user ID is checked). --user-purge-interval (env LASBOLT_USER_PURGE_INTERVAL, default 1h) is the time between runs of the job that removes deleted accounts after their grace period (setting delete_grace, default 30 days); tests call service.PurgeDueUsers and use a short grace. lasboltctl is server/cmd/lasboltctl (status, users list|disable|enable, owner add, invite create|list|revoke, registration show|set; --socket, --json, exit codes 0 to 5). The registration setting (closed, invite, open) is in the database; --allow-registration only seeds it once. The harness has ServerOptions.Registration and UserPurgeInterval, SeedOptions.Org and pwharness serve --org (more users, a disabled and a deleting user, open invitations), and harness.RegisterAccountInvite. Tests: ctl/ctl_test.go, cmd/lasboltctl/main_test.go, harness/org_test.go (make test), harness/ctl_e2e_test.go (make cli-e2e). Shots: make shots VIEWS=organisation,org-. The test for a socket path must use a short directory (os.MkdirTemp("", ...)): macOS limits a socket path to about 104 bytes.
  • Peer sync policy (DESIGN 15.11, 15.15; cmd/lasboltd/peer_config_test.go tests the flags): --peer-sync on|off (default on; off means no peer registry and no relay push), --peer-udp (the reflexive UDP port: a port, host:port or off; default 7849, loopback only in dev mode; a failed default bind is a warning, a failed bind of a flag you set stops the start), --peer-stun host:port (repeatable; default none), --peer-list-max-age (default 168h), --peer-udp-public (the port that devices are told, default the bound port). Env: LASBOLT_PEER_SYNC, LASBOLT_PEER_UDP, LASBOLT_PEER_STUN, LASBOLT_PEER_LIST_MAX_AGE, LASBOLT_PEER_UDP_PUBLIC. Tests that start lasboltd many times pass --peer-udp off. The harness has ServerOptions.PeerUDP (empty is off; 127.0.0.1:0 takes a free port), PeerSyncOff, PeerListMaxAge, PeerPunchInterval, PeerUDPPublic and PeerRevokedKeep.
  • Test flags (production defaults stay, cmd/lasboltd/timeouts_test.go tests them): --e2e-idle (idle time of an unlocked E2E tab, default 15m, DESIGN 7.4), --device-poll-interval (default 5s), --sse-heartbeat, --http-write-timeout, --approval-wait (longest wait of one read call for a per-use approval, default 20s, cut to half of the write timeout, DESIGN 10.21). The browser harness sets them with ServerOptions (E2EIdle, DevicePollInterval, ApprovalWait).
  • The database is lasbolt.db in the data directory. After unseal, the data key is the store sealer. Flag --allow-registration (env LASBOLT_ALLOW_REGISTRATION=1) sets the registration setting to open once (DESIGN 17.16); after that the setting rules.
  • Dev data in ./.dev stays between runs. After the first user, registration needs an invitation (invite; Settings › Organisation). Run make clean to reset it.

Backups and restore

  • lasboltd makes a backup every --backup-interval (default 24h) in --backup-dir (default <data-dir>/backups). It keeps --backup-keep backups (default 14). Interval 0 turns backups off. Keep 0 keeps all backups. Env: LASBOLT_BACKUP_DIR, LASBOLT_BACKUP_INTERVAL, LASBOLT_BACKUP_KEEP, LASBOLT_BACKUP_RECIPIENT, LASBOLT_BACKUP_IDENTITY.
  • Commands (use the same --data-dir and key provider flags as the server): lasboltd backup now, lasboltd backup list, lasboltd backup verify <file>, lasboltd restore <file>.
  • lasboltd user reset-password <username> [--url URL] prints a one-time reset link (1 hour) for a user who forgot the master password and has no recovery kit (DESIGN 8.1, docs/INSTALL.md). It needs only --data-dir (no data key). cmd/lasboltd/user_test.go tests it.
  • To seal backups for storage away from the server: run lasboltd backup keygen --out backup.key. Keep backup.key (private) away from the server. Start the server with --backup-recipient backup.key.pub. Add --backup-identity backup.key to verify and restore for .pwbk files.
  • To restore: stop lasboltd. Run lasboltd restore <file>. The command refuses to run if the database is in use. It keeps the old database as lasbolt.db.before-restore-<time> and copies the backup to lasbolt.db. It sets a new db_epoch. Start lasboltd. Clients push their data again. Purges that were made after the backup can come back.
  • Test restore often: run lasboltd backup verify on the newest backup. The tests in server/internal/backup and server/cmd/lasboltd run in make test.

CLI

  • Code: client/ (module gitlab.com/lasbolt/lasbolt/client). make test, make test-prod and make lint cover it. Usage: client/README.md.
  • The CLI test is in server/internal/harness/cli_e2e_test.go, because client/ cannot import server/internal. It builds lasbolt from client/cmd/lasbolt with CGO_ENABLED=0, and uses the production KDF parameters on both sides (neither binary has the insecurekdf tag). Each login runs Argon2id once, so it takes some time. Use caffeinate -i make cli-e2e on macOS for a long run.
  • The test sets LASBOLT_HOME to a temporary directory for each CLI. Use the same variable when you try lasbolt by hand, so your own login stays untouched.
  • Local state of the CLI: the key of the local store is in the OS key store, or in keys.json wrapped by a passphrase (DESIGN 10.16). Tests and CI use LASBOLT_KEYSTORE=file and LASBOLT_KEY_PASSPHRASE=..., so they need no D-Bus session and never touch the real keychain. TestMacKeychain uses the real keychain only with LASBOLT_TEST_KEYCHAIN=1.
  • TestRecoveryKitBrowser and TestOperatorResetBrowser (in make e2e, DESIGN 8.1): make a kit, log out, recover with a new password, read an E2E item, the old password and the old code fail, the code is in no request, database file or log; an operator reset, the old E2E folder locked for ever and deleted, the keys revoked by the reset, a new E2E folder.
  • TestE2ECLI (in make e2e, it needs the browser) drives the real lasbolt against the in-process server: E2E reads and writes after a password login, a device-code login with E2E keys approved in the browser, a read session that cannot write, a scoped key from Settings › Keys, and the leak checks (database files, server log, request bodies). The API tests of grants and keys are in server/internal/syncapi/grants_test.go.
  • TestCLI runs the CLI through the agent. Each CLI has its own LASBOLT_HOME and so its own agent. The test stops it at the end. Agent unit tests use sockets in a short /tmp/pw* directory (Unix socket paths are short).
  • Import from Bitwarden (DESIGN 16.6): TestCLIImportBitwarden (make cli-e2e, no browser, about 2 s with the quick KDF of the test binary) imports the fixtures of core/importer/bitwarden/testdata into a plain and an E2E folder, with --dry-run, --skip-existing, password protected files (stdin, LASBOLT_IMPORT_PASSWORD, a wrong password), an account-restricted file, CSV and a ZIP with attachments (counted, not imported). TestImportBrowser (make e2e) drives Settings › Import: error states, a protected file into a plain folder, a plain export into an E2E folder, no marker text in a request or in the database, and a read of the result with a second client. TestCLIImportBulk (make cli-e2e) imports 100 generated items into a plain and an E2E folder and logs the times; LASBOLT_IMPORT_BULK=3000 go test -tags e2e,insecurekdf -run TestCLIImportBulk ./internal/harness (in server/) is the long run (about 50 s). The unit tests: core/importer/bitwarden (write form), core/bind/e2ehost/importbw_test.go (worker part), client/internal/cli/cmd_import_test.go. Shots: make shots VIEWS=import (import-file, import-password, import-wrong-password, import-restricted, import-check, import-e2e-target, import-running, import-done, with dark variants). The fixtures hold only made-up data. A real Bitwarden file (PBKDF2 and Argon2id) is still needed before release (DESIGN 16.5).
  • Before you finish, check the other platforms: GOOS=linux go vet ./... and GOOS=windows go vet ./... in client/.

Android

The Android app (DESIGN 13) is in android/ (Gradle, Kotlin, Compose). It is not in make gates and not in CI: the targets need the Android SDK and an emulator.

Install the tools (once, Apple Silicon)

brew install openjdk@21
brew install --cask android-commandlinetools
export JAVA_HOME=/opt/homebrew/opt/openjdk@21
export ANDROID_HOME=$HOME/Library/Android/sdk
sdkmanager --sdk_root=$ANDROID_HOME "platform-tools" "emulator" "platforms;android-36" \
    "build-tools;36.1.0" "cmdline-tools;latest" "system-images;android-36;google_apis_playstore;arm64-v8a"
sdkmanager --sdk_root=$ANDROID_HOME --licenses
avdmanager create avd -n lasbolt-api36 -d pixel_8 -k "system-images;android-36;google_apis_playstore;arm64-v8a"
  • The Makefile sets JAVA_HOME (default /opt/homebrew/opt/openjdk@21) and ANDROID_HOME (default ~/Library/Android/sdk) if they are not set. The Gradle wrapper (9.8.1) is checked in (android/gradlew); do not install Gradle. Versions are pinned in android/gradle/libs.versions.toml.
  • The helper is lasbolt built for android/arm64 without cgo (make android-helper). It goes to android/app/src/main/jniLibs/arm64-v8a/liblasbolt.so. The file is generated and ignored by the VCS. There is no x86_64 build, so the emulator must be arm64.

Commands

CommandAction
make android-helperBuilds the helper into jniLibs.
make androidThe helper, then ./gradlew assembleDebug (android/app/build/outputs/apk/debug/app-debug.apk).
make android-testJVM unit tests (testDebugUnitTest): the agent client with a fake socket, the model with a fake agent, the store key logic, the dns file, the helper runner with shell scripts as a fake lasbolt, and (DESIGN 13.11, 13.12) the autofill field finder (also for save), the browser pin list, the autofill backend and flow, the fill plan, the value mapping, the pick history and the never list (13.15), the save backend and flow, and the write functions of the model, with a fake agent.
make android-e2escripts/android-e2e.sh: reuses a running emulator or boots lasbolt-api36 (-no-window -no-audio -no-boot-anim, quickboot snapshot; it saves the snapshot when it stops an emulator that it started), polls sys.boot_completed, builds and installs both APKs, starts pwharness serve --test-hooks, runs adb reverse, runs the instrumented tests (am instrument), and pulls the screenshots to .harness/shots/android/ (index.html; light and dark of each screen; screencap through the shell). Env: AVD, BOOT_TIMEOUT, CLASS (one test class, for example CLASS=AutofillTest), INST_ARGS (more am instrument arguments, for example -e stay_ms 1000). Classes: AutofillTest (drop-down, erase test, Settings), E2ETest, EditTest (New, Edit, Delete), SaveTest (save from the test WebView), KindsTest (strong password, one-time code, card, ranking, Never and its list in Settings; 13.15). AutofillTest turns on Lasbolt as the autofill service of the emulator for its run and restores the old value; it picks datasets in the system drop-down with UiAutomator and uses the debug-only TestWebActivity. It runs on the emulator- serial only.
make android-shotsThe same as make android-e2e (the shots are a part of the test).

Devices (adb). With more than one phone or emulator, set ANDROID_DEVICE=emulator or ANDROID_DEVICE=usb (the only one of that kind), or ANDROID_SERIAL=<serial> (from make android-devices). The emulator targets always use the emulator.

CommandAction
make android-devicesLists the phones and emulators (adb devices -l).
make android-emulatorStarts lasbolt-api36 with a window, or reuses a running emulator, and waits for the boot (ANDROID_AVD, ANDROID_BOOT_TIMEOUT).
make android-emulator-stopStops the emulators and saves the quickboot snapshot.
make android-connect ADDR=<ip>:<port>Connects a phone over Wi-Fi. On the phone: Developer options › Wireless debugging. Pair it once with adb pair <ip>:<pair port>.
make android-installBuilds the debug APK and installs it. The app data stays.
make android-runInstalls and starts the app.
make android-stopStops the app.
make android-logsThe log of the running app only (logcat --pid).
make android-screenshotSaves a screenshot to .harness/shots/android/manual-<time>.png. In a debug build screenshots work; with Settings › Privacy › “Block screenshots” on (the default of a release build), screens with secret values are black.
make android-shelladb shell.
make android-resetDeletes all app data on the device (the login, the local store, the wrapped store key).
make android-uninstallRemoves the app.
  • Tests wait for conditions (composeRule.waitUntil, polling getprop). The clipboard clear time (LasboltApp.clipClearMs) is short in the test.
  • FLAG_SECURE makes a screenshot black. The test turns it off with LasboltApp.secureWindows = false for the shots only. test1_secureFlagIsSet runs with it on and checks the window flag.
  • The device-code login cannot be approved in the test (it needs a browser session). The test checks the wait screen, the code and the URL that the app opens, then cancels.
  • Biometric tests (DESIGN 13.16, 13.17). BiometricTest needs a screen lock PIN and a fingerprint on the emulator. scripts/android-biometric.sh makes them (enroll: PIN 1234 and one fingerprint, with uiautomator dump, input and adb emu finger touch; add: a second fingerprint, touch id 2, so the Keystore key becomes invalid; clear: removes the PIN and with it the fingerprints). It needs ANDROID_SERIAL=emulator-NNNN and stops for any other serial: it never acts on a phone. make android-e2e runs the other test classes first (no PIN), then enrols, grants POST_NOTIFICATIONS, runs BiometricTest#test1_flow, adds the second fingerprint, runs test2 and test3, and clears the PIN at the end (KEEP_BIOMETRIC=1 keeps it, for a faster CLASS=BiometricTest run; the first enrolment takes about 30 s). A test in the phone cannot call the host, so the script runs a toucher: it waits for the line biometric prompt shows: of the tag LasboltBio in logcat, waits TOUCH_DELAY seconds (default 1, so the test can take a screenshot of the prompt), then sends emu finger touch 1 until the line biometric prompt result: shows. All its adb calls use the emulator serial.
  • The test hook POST /_harness/use-approval {"e2e": false} of pwharness serve --test-hooks makes a pending approval for the seeded user. The poll times of the app (LasboltApp.approvalPollMs) and the clock of the 14-day rule (LasboltApp.clockOffsetMs) can be set by the test.

iOS

The iOS app (DESIGN 14) is a SwiftUI app that links the Go core as a static library. It needs Xcode (the iOS SDK and a simulator runtime) and xcodegen. No Apple account is needed for the simulator.

Commands

  • make ios-core: builds dist/ios/LasboltCore.xcframework (a device slice and a simulator slice, scripts/ios-core.sh). This is the only cgo build of the repository. IOS_TAGS=insecurekdf adds a build tag.

  • make ios: ios-core, the project (make ios-project) and a simulator build with ad-hoc signing (the Keychain needs an entitlement, so an unsigned build fails with -34018).

  • make ios-test: the unit tests of the iOS target (link parser, store key rules, the real Keychain) on the simulator. It builds the core with insecurekdf.

  • make ios-e2e (scripts/ios-e2e.sh): reuses a booted simulator or boots one (and shuts it down only if it booted it), starts pwharness serve --test-hooks --seed 1, builds the core with insecurekdf, runs the XCUITests and writes light and dark screenshots with an index.html to .harness/shots/ios/. The tests approve the device code through the harness hook. Settings: IOS_SIM (default iPhone 17 Pro), IOS_RUNTIME (iOS-26-2), BOOT_TIMEOUT, TEST_TIMEOUT (each wait of the tests, s), ONLY=E2ETests/testName. A failed wait writes fail-*.png.

  • make ios-run: make ios, then install and start on the simulator.

  • AutoFill (DESIGN 14.8): make ios-test also tests the snapshot and the snapshot key. make ios-e2e has one AutoFill test (the snapshot is written). No UI test drives the system AutoFill sheet or Settings (the simulator does not list a third-party provider, and driving SpringBoard crashed it); the extension model has unit tests, and the extension itself is checked by hand on a device (DESIGN 14.8). The extension target needs the App Group, so a build for a device needs a paid team. In a fresh worktree, make ios-project needs apple/Local.xcconfig (an empty file is enough for the simulator), make apple-helper and make extension. Memory of the unlock: start the app with simctl launch, with SIMCTL_CHILD_LASBOLT_AF_HARNESS=1 SIMCTL_CHILD_LASBOLT_AF_PROBE=1, for a launch with a core built with IOS_TAGS= (empty), and read memprobe.txt in the App Group folder (create memprobe.on there first).

  • Face ID and approvals (DESIGN 14.9): the UI tests set LASBOLT_UITEST_FACEID=1, so the app uses an in-memory Face ID store and shows no system sheet. LASBOLT_APPROVAL_POLL_MS (the tests use 300) sets the poll time of the approvals. The test hook POST /_harness/use-approval {"e2e": false|true} of pwharness serve --test-hooks makes a pending approval of the seeded user. The tests do not request the notification permission (a system alert). Real Face ID, notifications and the background wake are checked by hand on a phone (DESIGN 14.9).

  • make ios-open: builds the core library and opens apple/Lasbolt.xcodeproj. It is one project for macOS and iOS: pick the LasboltiOS scheme and the iPhone or a simulator in the toolbar. Do not change settings in Xcode: apple/project.yml makes the project, so a change in Xcode is lost.

  • make ios-device: builds for a connected iPhone with the team of apple/Local.xcconfig (a free team build lasts 7 days), registers the iPhone with the team, installs and launches. IOS_DEVICE=<UDID> picks the iPhone (xcrun devicectl list devices); the default is the first connected one. On the first install, trust the developer on the iPhone: Settings › General › VPN & Device Management. It does not run in CI and nobody runs it by default.

  • The tests use the cheap KDF only through insecurekdf. A build for use (make ios, make ios-device) has the real KDF, and IOS_TAGS= make ios-e2e runs the tests with it. With LASBOLT_TRACE_FILE set (the UI tests set it), the core writes the time of each helper command and of StartAgent to that file. A login takes about 0.2 s with the real KDF in the simulator (DESIGN 14.7).

  • make apple-test still runs the LasboltKit tests on macOS.

  • While you work: make check. For a UI change, add a filtered make shots VIEWS=item-meta VIEWPORTS=laptop,phone and look at the pictures.

  • Before you report (agents): make gates once, and one full make shots if you changed UI. Do not run them after each small edit.

  • make gates includes test-prod. It runs at the same time as the others and is not on the longest path, so it costs no wall time. It proves the production KDF limits. CI runs test-long and test-prod.

  • A change in the random sync tests or the sync engine: run make test-long.

Test rules

  • Tests own their state: a new server, a database in t.TempDir(), a random port. Then t.Parallel() is safe, so add it. t.Setenv and os.Chdir forbid it. The tests of the harness package share a limit of Chrome processes (browserSlots in e2e_test.go). A test that needs more than two browsers takes them all at once with browserSlotsAll. If it takes them one by one, parallel tests can each hold a slot and wait for another one, and the run stops until the timeout.

  • TestConvergence and TestSubtreeScopeRandom run 1 or 2 seeds by default and the full set with LASBOLT_TEST_LONG=1. The regression seeds 26, 92, 168, 191 and 389 always run. Do not remove a regression seed.

  • Peer transport tests (core/peernet, DESIGN 15.17) run on an in-memory UDP network with NAT nodes (vnet_test.go), so they need no real ports and no fixed sleeps; they wait for conditions. The default run takes about 2 s (3 s with -race). TestLoopbackUDP and TestTwoEnginesExchangeOverQUIC (server/internal/syncapi) use real UDP on 127.0.0.1 only. TestBeacons uses loopback multicast and skips when the host does not deliver it. To add a NAT case, add a line to natCase in nat_test.go. Timings are Config fields (KeepAlive, IdleClose, PathTimeout, HandshakeTimeout) and ReachOptions (Window, Interval).

  • Peer loop of the agent (DESIGN 15.19). go test -run TestPeer ./internal/cli in client/ (12 tests, about 3 s with -race) uses a fake server, real engines with memory stores, and real UDP on loopback. No test sleeps: each polls a condition with a 15 s limit. TestCLIPeers (make cli-e2e, server/internal/harness/peer_cli_e2e_test.go, about 3.5 s) runs two real agents and a lasboltd that the test stops and starts again on the same ports and data (srvCtl): the item that agent A writes while the server is down shows on agent B, then both settle, then the pin. The CLI tests set LASBOLT_PEER=off (the default for a device with no setting), so the agents of the other tests do not peer; TestCLIPeers turns peer sync on with lasbolt peer on and peer set networks 127.0.0.0/8 (the only way to get loopback endpoints) and peer set beacons off (no multicast in tests). Timers of the agent loop, for tests only: LASBOLT_PEER_DEBOUNCE (2 s), _DIAL_EVERY (30 s), _EXCHANGE_EVERY (5 min), _NETWORK_EVERY (30 s), _GATE_RETRY (1 min), _EVENT_RETRY (30 s, the longest wait to read the event stream again), _UNKNOWN_REFRESH (10 s), _RETRY (2 s), _PUNCH_WINDOW (5 s), _PUNCH_INTERVAL (200 ms), _REFLEX_INTERVAL (300 ms); each takes a Go duration. Run the real-agent test several times after a change of the loop: go test -tags e2e,insecurekdf -count=10 -run TestCLIPeers ./internal/harness in server/.

  • TestSimultaneousReach (core/peernet, DESIGN 15.19 item 14) calls Reach(DialNow) on both sides of real loopback sockets at the same moment. 60 rounds by default (about 0.05 s), 1000 with LASBOLT_TEST_LONG=1; LASBOLT_SIMUL_ROUNDS sets the count. A change in core/peernet sessions, Reach or the filter: run it with -race -count=3 in the long mode.

  • TestPeerConvergence (core/sync, DESIGN 15.13) runs 6 seeds of 250 steps by default and 40 seeds of 500 steps with LASBOLT_TEST_LONG=1. LASBOLT_PEER_SEEDS, LASBOLT_PEER_SEED and LASBOLT_PEER_STEPS change that. The regression seed 226 always runs. It takes about 1.4 s for the whole package (4.4 s with -race), and about 12 s in the long mode. TestRulesSameAsMemState (server/internal/store) compares the store with core/oprules over random ops (6 seeds, 40 in the long mode). A change in core/oprules, core/sync or the store rules: run make test-long.

  • The SSE idle test (TestBinaryTwoClientsSSE) starts lasboltd with short timeouts (--http-read-timeout, --http-write-timeout, --sse-write-window, --sse-heartbeat), keeps the stream idle for several write timeouts, and checks heartbeats and a later event. Production defaults: read 30 s, write 60 s, SSE write window 30 s, heartbeat 25 s.

  • The insecurekdf build tag lowers the Argon2id minimums for tests. Release builds never use it. Configuration cannot lower the minimums.

  • Use pwcrypto.TestKDFParams in tests that run the KDF.

  • Integration tests use httptest, a SQLite database in t.TempDir(), and the insecure-static key provider.

  • Auth tests use a TLS httptest server with Dev: false, a cookie jar, a fake clock (store.SetClock and auth.Config.Now), and core/bind/keys as the client. Use a new server for each test: all requests come from one IP, and the IP rate limit is per server.

  • Tests do not use the network or fixed ports.

  • Crypto changes need new vectors in core/pwcrypto/testdata/vectors.json. Other clients (WASM, Swift, Kotlin) must pass the same vectors.

Dev mode

  • lasboltd --dev uses the insecure-static key provider, a database in ./.dev/, and plain HTTP on localhost.
  • In dev mode the session cookie has no Secure flag and no __Host- prefix. Otherwise browsers reject it over plain HTTP.
  • Dev mode refuses to listen on a non-loopback address.

Browser harness

The harness shows the web app in a headless browser. It never opens a window and never takes focus. Use it to see your UI change, and for browser E2E tests.

  • Code: server/internal/harness (library) and server/cmd/pwharness (CLI). The server uses the real web, auth and vault code (built by server/internal/app, which lasboltd uses too).
  • Browser: chrome-headless-shell from Chrome for Testing. The harness looks for it in this order: $LASBOLT_CHROME, then <cache>/chrome-headless-shell/<version>/ (<cache> is $LASBOLT_CACHE_DIR, else ~/.cache/lasbolt; CI sets it into the project folder), then it downloads it. The download is the only network use. It happens in the e2e build tag code and in pwharness only. Normal tests never use the network. pwharness chrome downloads the browser and prints its path.
  • Extension tests: the headless shell cannot load an extension, so TestExtension uses the full Chrome for Testing with --headless=new. Order: $LASBOLT_CHROME_FULL, then <cache>/chrome/<version>/, then a download (about 190 MB). It never uses the installed Chrome. The browser gets its own user data directory with the native messaging host manifest in NativeMessagingHosts/, and the environment of the test CLI (LASBOLT_HOME, LASBOLT_AGENT_SOCKET, a temporary HOME), so the host that Chrome starts talks to the agent of the test.
  • The browser derives keys in WASM, so the WASM files must exist. make e2e builds the production WASM. make shots builds a second WASM with the insecurekdf tag (see “Fast KDF for screenshots”).
  • Seed data: --seed N gives the same names and values each time. IDs, salts and times are random. The user is demo with password correct horse battery staple. The data has 3 levels of folders, about 60 items (logins, cards, notes, keys), custom fields, TOTP secrets, items with history and items in the trash. It also has the data of the Phase 2 screens: two API devices and one other web session (Settings > Devices), the item “Stripe live key” with an open conflict note, a trashed item that another device edited after the delete, 14 empty backup files (names only, for the date in the purge dialog), and an E2E folder “Documents” with an E2E conflict note. The E2E folder is made by a client (core/e2e), because its ops need a client signature. Phase 3 data (DESIGN 10.15): a device-code session “lasbolt CLI · ci-runner” with write access, audit rows (an approval, a session start, secret reads with a purpose), and SeedResult.NewApproval, which writes a pending approval request straight into the store (not through the rate-limited HTTP call) for the approval views. Per-use approval (DESIGN 10.21): “Stripe live key” and the E2E item “Tax portal” have @approve = each-use, and SeedResult.NewUseApproval writes a pending approval straight into the store (ClearUseApprovals denies them). Tests: TestCLIApproval (make cli-e2e, decisions as JSON posts) and TestApprovalBrowser (make e2e, prompts in the browser). Metadata (DESIGN 10.18): “Stripe live key” has metadata of all well-known kinds (a list, a production chip, an expires date 17 days ahead, links, auth with auth_header) and a free key rotation_job, all from one writer. “OpenWeather” has an expires date 5 days in the past and one value that the build-box device wrote (two writers). The E2E item “Tax portal” has metadata, made through core/e2e (ItemInput.Meta). The dates depend on the day, so these views change from day to day.
  • Shared spaces (DESIGN 17.20): SeedOptions.Shared (set by make shots, not by pwharness serve) adds the users bob, carol, dave, erin and frank and five shared spaces that the real client code makes (seedshared.go, SharedSeed): “Team” (demo owns it; a writer, a use member and a reader), “Ops” (demo has the role use), “Archive” (demo waits for access), “Finance” (an operator reset of erin: keys changed) and “Audit” (a removed member, no re-key: the folder waits for new keys). Server.ResetKeys(username) does an operator reset in the store for a test. Test: TestSharedSpaceBrowser (make e2e, two browsers, about 3 s): create a space, add after the safety number step, read as bob, the use role, remove and re-key, keys changed and re-add. Shots: make shots VIEWS=spaces-list,space,members (spaces-list, space-home, space-new-locked, space-new, members-locked, members, members-number, members-add, members-remove, space-use, space-waiting, space-keys-changed, space-rekey, space-blocked, space-fork, with dark variants of most). Each view forgets the unlock of the tab and unlocks in the pane or the dialog. The views run at laptop-1440x900 and phone-390x844 only (SharedViewport), and only those two units seed the shared spaces. ShotsOptions.NewEnv gets the viewport for that.
  • The page collects JS exceptions, console.error, CSP violations, failed requests and responses with status 400 or more. A 4xx answer with the header X-Vault-Error is deliberate: it is kept in the list but does not count as an error.
CommandAction
make shotsFilters and speed: see below. Takes each key view (about 70: the screens, dark variants, the purge dialog, the devices page, the conflict notes, the approval page of a CLI sign-in and its results, the audit log, the empty and error states, and the Details views item-meta-closed, item-meta-open, item-meta-expired, item-meta-edit, e2e-item-meta-closed, e2e-item-meta-open, e2e-item-meta-edit, with dark variants of the open and edit views; the per-use approval views of DESIGN 10.21: approval-prompt, approval-prompt-settings, approval-prompt-e2e, approval-prompt-e2e-unlocked, item-approve-edit, e2e-item-approve-edit, with dark variants of the prompts and the edit form; the recovery kit views of DESIGN 8.1: kit-card, kit-shown, kit-made, recover, recover-error, recover-password, recover-kit, reset, reset-password, reset-bad; the rollback views of DESIGN 12.8: e2e-rollback, e2e-rollback-accept, e2e-rollback-item, e2e-rollback-restored, with dark variants of the first and the last, which roll back “Documents” and so run after the other views; and at the end reset-done and e2e-oldkeys, which do a real operator reset and so run after all other views) at 9 viewports. Before each view the harness denies the pending approvals of the seed user, so a prompt never covers another view. Output: .harness/shots/<viewport>/<view>.png, index.html (contact sheet: views are rows, viewports are columns), report.json. Exit code is not zero if there is any error.
pwharness shots --seed 1 --out DIR [--views a,b] [--viewports a,b] [--parallel N] [--wasm-dir DIR]Same, with fewer views or viewports. --mobile takes the phone presets.
pwharness serve --seed 1 [--addr 127.0.0.1:0]Starts a seeded server and prints the URL and login. Use your own browser.
pwharness run --script file.jsonRuns a JSON script (below).
make e2eBrowser tests with one desktop and one phone viewport.

Shot filters (make shots VIEWS=item-meta,keys VIEWPORTS=laptop,phone). VIEWS is a comma list. A name without *, ? or [ matches the view names that start with it, so item-meta takes item-meta-closed, item-meta-open-dark and the rest, and trash takes trash and trash-dark. A dark variant follows its base when you list the base name. A pattern with a wildcard is a glob on the whole name (*-dark). A pattern that matches nothing is an error. VIEWPORTS takes preset names or prefixes (laptop is both laptop sizes, phone both phones). A filtered run removes only the files that it writes again and rewrites index.html and report.json for the selected views. LASBOLT_SHOTS_ONLY is gone.

Parallel run. Each viewport is one unit with its own server, its own seeded data (same seed), its own Chrome process and its own login. Units run in a pool of --parallel workers (default half of the CPUs, at least 2). So a view never sees the changes of a view at another viewport, and the result does not depend on the number of workers. report.json is in a fixed order: views in the order of the list, then viewports in the order of the list. login-flow has one entry for each viewport.

Settle. Before a capture the page must be painted: document.fonts.ready, finite CSS animations ended, two animation frames, no htmx request, swap or settle left (at most 2.5 s). There is no fixed sleep.

Fast KDF for screenshots. Screenshots do not test the KDF. make shots builds the WASM with the insecurekdf tag into .harness/wasm/, starts pwharness with that tag, and the harness serves the two WASM files from that folder (--wasm-dir) and seeds the account with the weakest KDF parameters. The unlock then takes milliseconds. The embedded WASM (server/internal/web/static/) is never changed, so release binaries never get it. Guard: pwcrypto.InsecureBuildMarker is a string that only a build with the insecurekdf tag contains. scripts/check-wasm.sh fails if a file in static/ or bin/ has it. It runs in make wasm, make build, make build-bins and scripts/release.sh. TestEmbeddedStaticHasNoInsecureWASM (internal/web) checks the embedded files, and TestWASMMarker (core/pwcrypto, in make test-prod) builds both WASM variants and proves that the marker is in the weak one only. The harness refuses --wasm-dir in a binary without the insecurekdf tag.

Views with dark in the name run with an emulated dark system theme (item-login-cookie-dark has a light system theme and the dark theme cookie). The failed-login views run at the laptop and phone sizes only, because failed logins count against the server rate limit.

Viewports: desktop-1920x1080, laptop-1440x900, laptop-1280x800, small-1366x768, ultrawide-2560x1080, tablet-1024x1366, tablet-landscape-1366x1024, phone-390x844, phone-small-360x740. Tablets and phones use device scale factor 2.

Script format. Start a seeded server ("seed"), or use a running server ("base_url"). Each step has "do" and its fields:

{
  "seed": 1, "viewport": "laptop-1440x900", "out": ".harness/run",
  "steps": [
    {"do": "login"},
    {"do": "goto", "url": "/?v=trash"},
    {"do": "click", "selector": "button.small"},
    {"do": "type", "selector": "#q", "text": "git"},
    {"do": "wait", "selector": "#list li"},
    {"do": "wait_text", "selector": "h2", "text": "Trash"},
    {"do": "wait_js", "js": "document.title.length > 0"},
    {"do": "eval", "js": "document.title"},
    {"do": "sleep", "ms": 500},
    {"do": "screenshot", "name": "trash"}
  ]
}

Rule: before you finish a UI change, run make shots and look at the screenshots. Open .harness/shots/index.html, or read the PNG files. The folder .harness/ is not in git.

Test site

testsite/ is a mock login site. Use it as a target to test autofill and password save in the browser extension, the Android autofill service and, later, iOS and macOS AutoFill. It has 24 scenarios (testsite/README.md). It has no state and no database.

  • make testsite builds bin/lasbolt-testsite. make testsite-run builds it and runs it on 127.0.0.1:8097 (TESTSITE_ADDR=...; TESTSITE_FLAGS='--frame-origin ...').
  • Deployed on the tailnet only: https://logintest.ts.lyvik.se. The second name https://logintest-frame.ts.lyvik.se is for the cross-origin iframe scenario. Both go to the same process (--frame-origin https://logintest-frame.ts.lyvik.se). Run it behind nginx. The server trusts X-Forwarded-Proto only to set the Secure flag on cookies.
  • The module is in go.work and in MODULES, so make check, make test and make lint include it. Its tests use httptest and no sleep. make lint-js checks its scripts.
  • The server never logs a submitted value. A test checks this.

Firefox by hand

The harness tests the extension in headless Chrome only. Firefox is checked by hand (DESIGN 11.13):

  1. make extension, then lasbolt integrate firefox --extension-id lasbolt@lasbolt.com (or Settings › Browsers in the app). This writes the host manifest in ~/Library/Application Support/Mozilla/NativeMessagingHosts/.
  2. In Firefox, open about:debugging#/runtime/this-firefox, click “Load Temporary Add-on…” and choose dist/extension-firefox/manifest.json. The add-on stays until Firefox quits.
  3. Check: the toolbar icon (dark keyhole on a light theme, light keyhole on a dark theme: theme_icons), the popup with the matches of a login page, Fill, the footer “Fill: ⇧⌘L”, the inline icon and menu, and Lock.
  4. “Inspect” in about:debugging shows the console of the background.

A headless run needs a driver. The Firefox remote debugging protocol (--start-debugger-server, installTemporaryAddon) needs no new dependency, but a first try on 2026-10-17 did not open the port within 90 s while another Firefox ran. It is not built.

CI

Lean CI (user decision of 2026-10-20). The project is private on GitLab.com, so it has 400 runner minutes a month, and a full pipeline is about 13 min. A runner on rocky was too small (2 vCPU, 3.5 GB, shared with lasboltd).

PipelineWhenJobs
PushA push (or a merge request) that changes core/, server/, client/, extension/, scripts/, go.work, Makefile or .gitlab-ci.ymllint (make lint-go), test (make test, all modules in one job), lint-js (only when JavaScript changes)
FullThe weekly schedule (Monday 04:00 Europe/Stockholm), a tag, “Run pipeline” in GitLab, or glab ci run (the API)lint, lint-js, test-long (one job for each module), test-prod (one job for each module), e2e, cli-e2e, build; release for a v tag
  • A push that changes only docs, site/, apple/, android/ or .claude/ makes no pipeline. The iOS, macOS and Android targets are not in CI: they need macOS or the Android SDK. Run them locally.

  • Each job is interruptible, and workflow:auto_cancel cancels the jobs of an older pipeline when a new commit comes. Many pushes in a row cost one pipeline.

  • Before a change that touches the server, the web app or timing, start the full pipeline by hand (glab ci run -R lasbolt/lasbolt -b master) and do not wait for the weekly one.

  • Each job has needs: [], so all jobs start at once and the pipeline is as long as its slowest job. On failure e2e keeps .harness/ as an artifact. make test-long MODULES=server runs one module.

  • Caches (all in the project folder: GOPATH, GOCACHE and GOLANGCI_LINT_CACHE are in .go/). Module cache: key go-mod-v1, saved by lint only and read-only in the other jobs. Build cache: one for each job (<job>-build-v1), because tags and flags differ. lint also caches .tools and the golangci-lint cache. e2e also caches the browser (LASBOLT_CACHE_DIR=.go/lasbolt). The keys are fixed, not made from go.sum: Go caches are content-addressed. To reset a cache, change its -v1. The runner uses fastzip and a low compression level.

  • After a push, measure: the “Restoring cache” and “Saving cache” lines of each job, the time from make lint-go to the first 0 issues, the time of the make e2e step of e2e (the test log shows harness: lines only for a failed run; a cached browser saves the download), and the pipeline wall time. The first pipeline after a key change is fully cold (the keys do not exist yet). Measure the second one. The shared runner saas-linux-small-amd64 has 2 vCPUs, so test, e2e and cli-e2e run 5 to 10 times slower than on a laptop.

  • lint-js also runs the extension unit tests (node --test). Not in the pipeline yet: make e2e-extension (it needs the libraries of the full Chrome for Testing and a cache of <cache>/chrome; it waits for the runner decision).

  • CI minutes: make gates on the Mac stays the gate before each push (make gates && git commit). CI is the second check, on a slower runner. If the quota runs out (ci_quota_exceeded), pipelines stop until the monthly reset.

  • After you change .gitlab-ci.yml, run glab ci lint. An invalid file makes each pipeline fail at once, with no jobs and no log (this happened from #27 to #41).

  • After each push to master, check the pipeline (glab ci list -R lasbolt/lasbolt). A local make gates is not proof: the CI runner is slower, so it finds timing races.

  • A test must not depend on how fast the machine is. If a test needs a short timeout, check the property directly (a state or a timestamp), not “the next step finishes before the timeout”.

Docs site

docs-site/ is a Hugo site (Hextra theme, vendored in docs-site/_vendor/) for docs.lasbolt.com. The contract is DESIGN 18; the writer rules are on the page “Writing docs”. Hugo is pinned in docs-site/tools/go.mod (standard edition, no cgo, not in go.work). make docs builds the generated pages (docs-site/tools/cmd/refdoc: the reference section from the real --help output, the Go source and the MCP tools/list; the copies of the repository documents; the version menu from the git tags) and then the site into bin/docs-site/ (outside docs-site/, so a running hugo server cannot write into it). make docs-serve serves it on 127.0.0.1:8098. make docs-check also runs the gates: no Hugo warning, no remote asset, no broken internal link or anchor (linkcheck), no stub page (stubcheck; DOCS_ALLOW_STUBS=1 only lists them), and the coverage rule of refdoc (an item with no help text or purpose fails). It is part of make gates, not of CI (a cold Hugo build is too slow for the lean CI). make docs-shots copies the screenshots listed in docs-site/shots/*.txt. make docs-deploy DOCS_VERSION=vX.Y.Z builds and uploads one version folder. Theme colours come from brand/tokens.json through make brand (docs-site/assets/css/custom.css). To update Hextra: cd docs-site && GOWORK=off bin/hugo mod get -u github.com/imfing/hextra && hugo mod vendor.