Build and test
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-timeoutinlasboltd, tested inserver/cmd/lasboltd/timeouts_test.go). - Run independent work in parallel:
t.Parallel()in tests that own their state, parallel viewports inmake shots, parallel gates inmake 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
insecurekdftag.
Time budget (Apple Silicon, 14 cores, warm cache). Keep the numbers. If a change makes one worse, fix it or say why.
| Command | Budget |
|---|---|
make check | under 30 s (about 11 s, cold cache) |
make test | under 60 s (about 31 s, cold cache) |
make e2e | under 60 s (about 26 s; 50 s with TestTeamsBrowser, which holds 3 of the 4 browser slots for about 6 s) |
make cli-e2e | under 20 s (about 8 s) |
make e2e-extension | under 30 s (about 5 s warm; the first run downloads Chrome for Testing, about 15 s) |
make test-js | under 2 s (about 0.1 s) |
make shots | under 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=laptop | under 15 s |
make gates | under 2 min (about 41 s, cold cache) |
make apple-test | under 15 s (about 2 s warm, 10 s cold) |
make android | under 60 s warm (the helper about 3 s, Gradle about 5 s; the first run downloads Gradle and the libraries) |
make android-test | under 30 s (about 5 s warm) |
make android-e2e | under 60 s with a booted emulator (about 16 s; the first cold boot takes some minutes) |
make apple-e2e | under 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 toolsbuilds them once withgo installinto.tools/<tool>-<version>/(not in the repository). The version is in the path, so a new pin builds a new binary.make lintuses 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
| Command | Action |
|---|---|
make check | The fast loop: go vet and tests with the insecurekdf tag, without -race, all modules at the same time. Use it while you work. |
make version | Prints the version of this checkout (DESIGN 19, “Versions and releases”). |
make release-tag V=0.1.0 | Makes the release commit (CHANGELOG.md) and the annotated tag v0.1.0. It does not push. See “Versions and releases”. |
make gates | All 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 test | Unit and integration tests, with -race and the insecurekdf tag. The random tests use short counts. |
make test-long | make test with the full counts of the random tests (LASBOLT_TEST_LONG=1). CI runs it. |
make test-prod | Tests without insecurekdf. This proves that production KDF limits apply. |
make lint | lint-go (go vet, golangci-lint, govulncheck) and lint-js (node --check on the page scripts). |
make wasm | Builds 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 icons | Makes static/icons.css from the icon sprite static/lasbolt-icons.svg. Run it after you change the sprite. |
make build | Builds the WASM island, then the static binaries bin/lasboltd, bin/lasboltctl and bin/lasbolt. |
make e2e | Browser E2E tests (build tags e2e,insecurekdf) in parallel. They use the browser harness. TestCLI is not in it (see cli-e2e). |
make cli-e2e | CLI 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 shots | Screenshots 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-check | Write, 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 extension | Builds 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-js | The 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-extension | TestExtension: 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-extension | The same as make e2e-extension (the shots are a part of the test). |
make shots-quick | The same for the laptop and phone viewports (all views). |
make wasm-test | Builds the WASM with the insecurekdf tag into .harness/wasm/ (never into static/). make shots does it. |
make apple-helper | Builds 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-open | Make 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-test | The 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-release | The 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 |
make apple-e2e | Lasbolt --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 run | Runs lasboltd in dev mode on http://localhost:8080. |
make vectors | Writes 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 (
XcodeOpenWorkspacewith the absolute path ofapple/Lasbolt.xcodeproj, thenRenderPreview). Xcode cannot load the local packageLasboltKitfrom 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,lasboltdandlasboltctlinto$(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. TheMakefilereads it once and passes it to Go (-X .../core/buildinfo.Version),xcodebuild(MARKETING_VERSIONandCURRENT_PROJECT_VERSIONon the command line), Gradle (lasboltVersionName,lasboltBuildNumber) and the extension build (-version,-version-name). No generated file in the repository changes with the version.make versionprints the version of this checkout.make VERSION=1.2.3 <target>sets it by hand.scripts/version_test.sh(inmake check, about 2 s) testsversion.shandrelease-tag.shin temporary repositories.- To release: put each change that a user sees in
CHANGELOG.md(Unreleased), in the commit of the change. Then, on a cleanmaster:make release-tag V=0.1.0. It checks the tree, the branch and the order of the version, runsmake gates(RELEASE_GATES=skipskips it), editsCHANGELOG.md, makes the commitRelease v0.1.0and 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 jobrelease. 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);versionandversion_nameindist/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
lasboltthen replaces a running agent (thebuildinhellodiffers), 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 oflasboltd user reset-passwordand for the invitation links oflasboltctl.- Instance administration (DESIGN 17.16):
--ctl-socket PATH(envLASBOLT_CTL_SOCKET; empty is off; recommended/run/lasbolt/ctl.sock) opens the control socket oflasboltctl(mode 0600, no network; on Linux the peer user ID is checked).--user-purge-interval(envLASBOLT_USER_PURGE_INTERVAL, default 1h) is the time between runs of the job that removes deleted accounts after their grace period (settingdelete_grace, default 30 days); tests callservice.PurgeDueUsersand use a short grace.lasboltctlisserver/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-registrationonly seeds it once. The harness hasServerOptions.RegistrationandUserPurgeInterval,SeedOptions.Organdpwharness serve --org(more users, a disabled and a deleting user, open invitations), andharness.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.gotests 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:portoroff; 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 startlasboltdmany times pass--peer-udp off. The harness hasServerOptions.PeerUDP(empty is off;127.0.0.1:0takes a free port),PeerSyncOff,PeerListMaxAge,PeerPunchInterval,PeerUDPPublicandPeerRevokedKeep. - Test flags (production defaults stay,
cmd/lasboltd/timeouts_test.gotests 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 withServerOptions(E2EIdle,DevicePollInterval,ApprovalWait). - The database is
lasbolt.dbin the data directory. After unseal, the data key is the store sealer. Flag--allow-registration(envLASBOLT_ALLOW_REGISTRATION=1) sets the registration setting toopenonce (DESIGN 17.16); after that the setting rules. - Dev data in
./.devstays between runs. After the first user, registration needs an invitation (invite; Settings › Organisation). Runmake cleanto reset it.
Backups and restore
lasboltdmakes a backup every--backup-interval(default 24h) in--backup-dir(default<data-dir>/backups). It keeps--backup-keepbackups (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-dirand 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.gotests it.- To seal backups for storage away from the server: run
lasboltd backup keygen --out backup.key. Keepbackup.key(private) away from the server. Start the server with--backup-recipient backup.key.pub. Add--backup-identity backup.keytoverifyandrestorefor.pwbkfiles. - To restore: stop
lasboltd. Runlasboltd restore <file>. The command refuses to run if the database is in use. It keeps the old database aslasbolt.db.before-restore-<time>and copies the backup tolasbolt.db. It sets a newdb_epoch. Startlasboltd. Clients push their data again. Purges that were made after the backup can come back. - Test restore often: run
lasboltd backup verifyon the newest backup. The tests inserver/internal/backupandserver/cmd/lasboltdrun inmake test.
CLI
- Code:
client/(modulegitlab.com/lasbolt/lasbolt/client).make test,make test-prodandmake lintcover it. Usage:client/README.md. - The CLI test is in
server/internal/harness/cli_e2e_test.go, becauseclient/cannot importserver/internal. It buildslasboltfromclient/cmd/lasboltwithCGO_ENABLED=0, and uses the production KDF parameters on both sides (neither binary has theinsecurekdftag). Each login runs Argon2id once, so it takes some time. Usecaffeinate -i make cli-e2eon macOS for a long run. - The test sets
LASBOLT_HOMEto a temporary directory for each CLI. Use the same variable when you trylasboltby 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.jsonwrapped by a passphrase (DESIGN 10.16). Tests and CI useLASBOLT_KEYSTORE=fileandLASBOLT_KEY_PASSPHRASE=..., so they need no D-Bus session and never touch the real keychain.TestMacKeychainuses the real keychain only withLASBOLT_TEST_KEYCHAIN=1. TestRecoveryKitBrowserandTestOperatorResetBrowser(inmake 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(inmake e2e, it needs the browser) drives the reallasboltagainst 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 inserver/internal/syncapi/grants_test.go.TestCLIruns the CLI through the agent. Each CLI has its ownLASBOLT_HOMEand 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 ofcore/importer/bitwarden/testdatainto 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(inserver/) 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 ./...andGOOS=windows go vet ./...inclient/.
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
MakefilesetsJAVA_HOME(default/opt/homebrew/opt/openjdk@21) andANDROID_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 inandroid/gradle/libs.versions.toml. - The helper is
lasboltbuilt forandroid/arm64without cgo (make android-helper). It goes toandroid/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
| Command | Action |
|---|---|
make android-helper | Builds the helper into jniLibs. |
make android | The helper, then ./gradlew assembleDebug (android/app/build/outputs/apk/debug/app-debug.apk). |
make android-test | JVM 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-e2e | scripts/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-shots | The 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.
| Command | Action |
|---|---|
make android-devices | Lists the phones and emulators (adb devices -l). |
make android-emulator | Starts lasbolt-api36 with a window, or reuses a running emulator, and waits for the boot (ANDROID_AVD, ANDROID_BOOT_TIMEOUT). |
make android-emulator-stop | Stops 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-install | Builds the debug APK and installs it. The app data stays. |
make android-run | Installs and starts the app. |
make android-stop | Stops the app. |
make android-logs | The log of the running app only (logcat --pid). |
make android-screenshot | Saves 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-shell | adb shell. |
make android-reset | Deletes all app data on the device (the login, the local store, the wrapped store key). |
make android-uninstall | Removes the app. |
- Tests wait for conditions (
composeRule.waitUntil, pollinggetprop). 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 = falsefor the shots only.test1_secureFlagIsSetruns 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).
BiometricTestneeds a screen lock PIN and a fingerprint on the emulator.scripts/android-biometric.shmakes them (enroll: PIN 1234 and one fingerprint, withuiautomator dump,inputandadb 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 needsANDROID_SERIAL=emulator-NNNNand stops for any other serial: it never acts on a phone.make android-e2eruns the other test classes first (no PIN), then enrols, grantsPOST_NOTIFICATIONS, runsBiometricTest#test1_flow, adds the second fingerprint, runstest2andtest3, and clears the PIN at the end (KEEP_BIOMETRIC=1keeps it, for a fasterCLASS=BiometricTestrun; 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 linebiometric prompt shows:of the tagLasboltBioin logcat, waitsTOUCH_DELAYseconds (default 1, so the test can take a screenshot of the prompt), then sendsemu finger touch 1until the linebiometric prompt result:shows. All its adb calls use the emulator serial. - The test hook
POST /_harness/use-approval {"e2e": false}ofpwharness serve --test-hooksmakes 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: buildsdist/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=insecurekdfadds 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 withinsecurekdf.make ios-e2e(scripts/ios-e2e.sh): reuses a booted simulator or boots one (and shuts it down only if it booted it), startspwharness serve --test-hooks --seed 1, builds the core withinsecurekdf, runs the XCUITests and writes light and dark screenshots with anindex.htmlto.harness/shots/ios/. The tests approve the device code through the harness hook. Settings:IOS_SIM(defaultiPhone 17 Pro),IOS_RUNTIME(iOS-26-2),BOOT_TIMEOUT,TEST_TIMEOUT(each wait of the tests, s),ONLY=E2ETests/testName. A failed wait writesfail-*.png.make ios-run:make ios, then install and start on the simulator.AutoFill (DESIGN 14.8):
make ios-testalso tests the snapshot and the snapshot key.make ios-e2ehas 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-projectneedsapple/Local.xcconfig(an empty file is enough for the simulator),make apple-helperandmake extension. Memory of the unlock: start the app withsimctl launch, withSIMCTL_CHILD_LASBOLT_AF_HARNESS=1 SIMCTL_CHILD_LASBOLT_AF_PROBE=1, for a launch with a core built withIOS_TAGS=(empty), and readmemprobe.txtin the App Group folder (creatememprobe.onthere 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 hookPOST /_harness/use-approval {"e2e": false|true}ofpwharness serve --test-hooksmakes 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 opensapple/Lasbolt.xcodeproj. It is one project for macOS and iOS: pick theLasboltiOSscheme and the iPhone or a simulator in the toolbar. Do not change settings in Xcode:apple/project.ymlmakes the project, so a change in Xcode is lost.make ios-device: builds for a connected iPhone with the team ofapple/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, andIOS_TAGS= make ios-e2eruns the tests with it. WithLASBOLT_TRACE_FILEset (the UI tests set it), the core writes the time of each helper command and ofStartAgentto that file. A login takes about 0.2 s with the real KDF in the simulator (DESIGN 14.7).make apple-teststill runs the LasboltKit tests on macOS.While you work:
make check. For a UI change, add a filteredmake shots VIEWS=item-meta VIEWPORTS=laptop,phoneand look at the pictures.Before you report (agents):
make gatesonce, and one fullmake shotsif you changed UI. Do not run them after each small edit.make gatesincludestest-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 runstest-longandtest-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. Thent.Parallel()is safe, so add it.t.Setenvandos.Chdirforbid it. The tests of the harness package share a limit of Chrome processes (browserSlotsine2e_test.go). A test that needs more than two browsers takes them all at once withbrowserSlotsAll. 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.TestConvergenceandTestSubtreeScopeRandomrun 1 or 2 seeds by default and the full set withLASBOLT_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).TestLoopbackUDPandTestTwoEnginesExchangeOverQUIC(server/internal/syncapi) use real UDP on127.0.0.1only.TestBeaconsuses loopback multicast and skips when the host does not deliver it. To add a NAT case, add a line tonatCaseinnat_test.go. Timings areConfigfields (KeepAlive,IdleClose,PathTimeout,HandshakeTimeout) andReachOptions(Window,Interval).Peer loop of the agent (DESIGN 15.19).
go test -run TestPeer ./internal/cliinclient/(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 alasboltdthat 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 setLASBOLT_PEER=off(the default for a device with no setting), so the agents of the other tests do not peer;TestCLIPeersturns peer sync on withlasbolt peer onandpeer set networks 127.0.0.0/8(the only way to get loopback endpoints) andpeer 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/harnessinserver/.TestSimultaneousReach(core/peernet, DESIGN 15.19 item 14) callsReach(DialNow)on both sides of real loopback sockets at the same moment. 60 rounds by default (about 0.05 s), 1000 withLASBOLT_TEST_LONG=1;LASBOLT_SIMUL_ROUNDSsets the count. A change incore/peernetsessions,Reachor the filter: run it with-race -count=3in the long mode.TestPeerConvergence(core/sync, DESIGN 15.13) runs 6 seeds of 250 steps by default and 40 seeds of 500 steps withLASBOLT_TEST_LONG=1.LASBOLT_PEER_SEEDS,LASBOLT_PEER_SEEDandLASBOLT_PEER_STEPSchange 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 withcore/oprulesover random ops (6 seeds, 40 in the long mode). A change incore/oprules,core/syncor the store rules: runmake test-long.The SSE idle test (
TestBinaryTwoClientsSSE) startslasboltdwith 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
insecurekdfbuild tag lowers the Argon2id minimums for tests. Release builds never use it. Configuration cannot lower the minimums.Use
pwcrypto.TestKDFParamsin tests that run the KDF.Integration tests use
httptest, a SQLite database int.TempDir(), and theinsecure-statickey provider.Auth tests use a TLS
httptestserver withDev: false, a cookie jar, a fake clock (store.SetClockandauth.Config.Now), andcore/bind/keysas 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 --devuses theinsecure-statickey provider, a database in./.dev/, and plain HTTP on localhost.- In dev mode the session cookie has no
Secureflag 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) andserver/cmd/pwharness(CLI). The server uses the realweb,authandvaultcode (built byserver/internal/app, whichlasboltduses too). - Browser:
chrome-headless-shellfrom 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 thee2ebuild tag code and inpwharnessonly. Normal tests never use the network.pwharness chromedownloads the browser and prints its path. - Extension tests: the headless shell cannot load an extension, so
TestExtensionuses 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 inNativeMessagingHosts/, and the environment of the test CLI (LASBOLT_HOME,LASBOLT_AGENT_SOCKET, a temporaryHOME), 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 e2ebuilds the production WASM.make shotsbuilds a second WASM with theinsecurekdftag (see “Fast KDF for screenshots”). - Seed data:
--seed Ngives the same names and values each time. IDs, salts and times are random. The user isdemowith passwordcorrect 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), andSeedResult.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, andSeedResult.NewUseApprovalwrites a pending approval straight into the store (ClearUseApprovalsdenies them). Tests:TestCLIApproval(make cli-e2e, decisions as JSON posts) andTestApprovalBrowser(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, anexpiresdate 17 days ahead, links,authwithauth_header) and a free keyrotation_job, all from one writer. “OpenWeather” has anexpiresdate 5 days in the past and one value that the build-box device wrote (two writers). The E2E item “Tax portal” has metadata, made throughcore/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 bymake shots, not bypwharness 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 atlaptop-1440x900andphone-390x844only (SharedViewport), and only those two units seed the shared spaces.ShotsOptions.NewEnvgets 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 headerX-Vault-Erroris deliberate: it is kept in the list but does not count as an error.
| Command | Action |
|---|---|
make shots | Filters 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.json | Runs a JSON script (below). |
make e2e | Browser 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 testsitebuildsbin/lasbolt-testsite.make testsite-runbuilds it and runs it on127.0.0.1:8097(TESTSITE_ADDR=...;TESTSITE_FLAGS='--frame-origin ...').- Deployed on the tailnet only:
https://logintest.ts.lyvik.se. The second namehttps://logintest-frame.ts.lyvik.seis 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 trustsX-Forwarded-Protoonly to set the Secure flag on cookies. - The module is in
go.workand inMODULES, somake check,make testandmake lintinclude it. Its tests usehttptestand no sleep.make lint-jschecks 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):
make extension, thenlasbolt integrate firefox --extension-id lasbolt@lasbolt.com(or Settings › Browsers in the app). This writes the host manifest in~/Library/Application Support/Mozilla/NativeMessagingHosts/.- In Firefox, open
about:debugging#/runtime/this-firefox, click “Load Temporary Add-on…” and choosedist/extension-firefox/manifest.json. The add-on stays until Firefox quits. - 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. - “Inspect” in
about:debuggingshows 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).
| Pipeline | When | Jobs |
|---|---|---|
| Push | A push (or a merge request) that changes core/, server/, client/, extension/, scripts/, go.work, Makefile or .gitlab-ci.yml | lint (make lint-go), test (make test, all modules in one job), lint-js (only when JavaScript changes) |
| Full | The 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, andworkflow:auto_cancelcancels 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 failuree2ekeeps.harness/as an artifact.make test-long MODULES=serverruns one module.Caches (all in the project folder:
GOPATH,GOCACHEandGOLANGCI_LINT_CACHEare in.go/). Module cache: keygo-mod-v1, saved bylintonly and read-only in the other jobs. Build cache: one for each job (<job>-build-v1), because tags and flags differ.lintalso caches.toolsand the golangci-lint cache.e2ealso caches the browser (LASBOLT_CACHE_DIR=.go/lasbolt). The keys are fixed, not made fromgo.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-goto the first0 issues, the time of themake e2estep ofe2e(the test log showsharness: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 runnersaas-linux-small-amd64has 2 vCPUs, so test, e2e and cli-e2e run 5 to 10 times slower than on a laptop.lint-jsalso 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 gateson 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, runglab 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 localmake gatesis 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.