diff --git a/.gitattributes b/.gitattributes
index 7ec9352..0a3cf91 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -13,60 +13,27 @@
# Binary: never convert, never diff as text.
*.png binary
*.mp3 binary
+*.ogg binary
*.ico binary
*.exe binary
*.db binary
# POSIX tools read these byte-for-byte. A shell script checked out with CRLF hands bash a
# shebang ending in a carriage return; it fails as "bad interpreter", which reads like a
-# missing binary rather than a line-ending bug. Ubuntu CI runs tools/baseline/*.sh (plan 01-04).
+# missing binary rather than a line-ending bug. Ubuntu CI runs any .sh under tools/.
*.sh text eol=lf
.nvmrc text eol=lf
# Shebang scripts: a CRLF `#!` line breaks Unix `env` and vite's hashbang strip, so vitest can't import them.
tools/**/*.mjs text eol=lf
-# CUSTODY-09 baseline artifacts are written by tools/baseline/inventory.sh, a POSIX shell
-# script, and are re-generated and diffed in Phase 8 to prove behaviour parity. Under the
-# tree-wide `eol=crlf` rule they check out CRLF while the generator writes LF, which leaves
-# `git status` permanently reporting them as modified even though `git diff` is empty - the
-# exact "noise" the determinism requirement exists to avoid. Pin them to LF so the checked-out
-# bytes and the generated bytes are the same on Windows and on the Ubuntu CI runner.
-baselines/** text eol=lf
-
-# ...but the PIXEL baselines are PNGs, and `baselines/** text eol=lf` above is an EXPLICIT `text`,
-# not `text=auto`, so it forces end-of-line conversion with NO binary detection and OVERRIDES the
-# `*.png binary` rule near the top of this file - later rules win. Measured, not theorised: a
-# 16-byte file containing three CRLF pairs was staged under the rules above and stored as a
-# 13-byte blob. Every screenshot would be silently corrupted on commit, and nothing downstream
-# would notice - the file is still present, still non-zero, and the artifact test checks presence
-# and pairing, not decodability. It would surface in Phase 8, when v1.2.1 can no longer be run to
-# re-capture. Restore binary handling for the one artifact class under baselines/ that is not text.
-baselines/**/*.png binary
-
-# Same reasoning as baselines/** above, for the generated SQL fixtures (plan 01-04, extended
-# by plan 01-07). tests/fixtures/*.sql is written by tools/baseline/archive-real-db.mjs and by
-# the fixture generator, both of which emit LF. Under the tree-wide `eol=crlf` rule the file
-# checks out CRLF, so every regeneration leaves `git status` reporting a byte-identical file as
-# modified - noise around the one artifact whose regeneration must be unambiguous (D-05: plan
-# 01-07 diffs this extract against its own fixture to decide whether a second legacy variant
-# exists). Pin to LF so the checked-out bytes and the generated bytes match on Windows and on
-# the Ubuntu CI runner alike.
+# The SQL fixtures are hashed and replayed by the migration tests, so their bytes must not depend on the checkout.
tests/fixtures/*.sql text eol=lf
# Migration SQL is SHA-256 pinned (D-09), so its bytes must not depend on the checkout.
src/lib/db/migrations/** text eol=lf
-# Vendored capture assets (plan 01-05). These are not source: they are byte-exact copies of what
-# cdn.tailwindcss.com and fonts.googleapis.com served on a particular day, and their SHA-256 is
-# recorded in baselines/v1.2.1/MANIFEST.md so that a Phase 8 re-capture is diffed against a fixed
-# target rather than a moving one. Under the tree-wide `eol=crlf` rule, tailwind.js and the two
-# stylesheets would be fetched as LF, stored as LF and CHECKED OUT AS CRLF - so every digest in the
-# manifest would fail to verify on a fresh clone, for a file nobody had touched.
-# `-text` disables end-of-line conversion in both directions while leaving the files diffable.
-tools/baseline/vendor/** -text
-
# Fonts are binary. `text=auto` would very probably detect that on its own; saying so explicitly
# costs one line and removes the "very probably" from an artifact whose corruption would be
-# invisible until a baseline render came out wrong.
+# invisible until a render came out wrong.
*.woff2 binary
diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml
index 3327518..f840407 100644
--- a/.github/workflows/package.yml
+++ b/.github/workflows/package.yml
@@ -1,7 +1,7 @@
name: Package
# BUILD-10 / D-17: build the installers the way they will actually ship - Windows and macOS, x64
-# and arm64, four artifacts - launch the two the runners can execute, and prove what got packaged.
+# and arm64, plus Linux x64 - launch the three the runners can execute, and prove what got packaged.
#
# Exactly three triggers, and the two that are missing are missing on purpose.
# pull_request nothing merges without four packaged artifacts and two smoke launches.
@@ -89,6 +89,20 @@ jobs:
unpacked_dir: dist/mac-arm64
smoke: true
smoke_skip_reason: ''
+ # Linux ships x64 only: an AppImage and a .deb from one build, so this leg expects two files
+ # (artifact_glob is space-separated). ubuntu-latest is x64, so the smoke launch is real here.
+ - os: ubuntu-latest
+ arch: x64
+ script: build:linux
+ artifact_glob: dist/*-x86_64.AppImage dist/*-amd64.deb
+ artifact_count: 2
+ # upload-artifact wants one pattern per line, where the shell glob above is space-separated.
+ upload_path: |
+ dist/*-x86_64.AppImage
+ dist/*-amd64.deb
+ unpacked_dir: dist/linux-unpacked
+ smoke: true
+ smoke_skip_reason: ''
# bash on both operating systems (Git Bash on Windows), so every step below reads its inputs
# from env the same way and the glob expansion behaves identically on all four legs.
defaults:
@@ -157,22 +171,25 @@ jobs:
- name: Assert this leg produced exactly one plausible installer
env:
ARTIFACT_GLOB: ${{ matrix.artifact_glob }}
+ ARTIFACT_COUNT: ${{ matrix.artifact_count || 1 }}
MIN_ARTIFACT_BYTES: '20000000'
run: |
shopt -s nullglob
# Unquoted on purpose: the glob has to expand. nullglob makes no match an empty array.
matches=( $ARTIFACT_GLOB )
- if [ "${#matches[@]}" -ne 1 ]; then
- echo "::error::expected exactly one file matching $ARTIFACT_GLOB, found ${#matches[@]}: ${matches[*]:-none}"
+ if [ "${#matches[@]}" -ne "$ARTIFACT_COUNT" ]; then
+ echo "::error::expected exactly $ARTIFACT_COUNT file(s) matching $ARTIFACT_GLOB, found ${#matches[@]}: ${matches[*]:-none}"
ls -la dist || true
exit 1
fi
- bytes=$(wc -c < "${matches[0]}" | tr -d ' ')
- if [ "$bytes" -lt "$MIN_ARTIFACT_BYTES" ]; then
- echo "::error::${matches[0]} is $bytes bytes, below the $MIN_ARTIFACT_BYTES-byte floor - not a plausible installer"
- exit 1
- fi
- echo "ARTIFACT_OK ${matches[0]} $bytes bytes"
+ for match in "${matches[@]}"; do
+ bytes=$(wc -c < "$match" | tr -d ' ')
+ if [ "$bytes" -lt "$MIN_ARTIFACT_BYTES" ]; then
+ echo "::error::$match is $bytes bytes, below the $MIN_ARTIFACT_BYTES-byte floor - not a plausible installer"
+ exit 1
+ fi
+ echo "ARTIFACT_OK $match $bytes bytes"
+ done
# BUILD-08, on the artifact rather than on electron-builder.yml: every packaged path is read
# from the archive's own header and checked against the allowlist, and the region unpacked
@@ -194,57 +211,19 @@ jobs:
# and a child environment scrubbed of ELECTRON_RUN_AS_NODE and NODE_OPTIONS. None of that may
# be relaxed to make a runner happy.
- name: Launch the packaged app and open a database (BUILD-06)
- if: matrix.smoke
+ if: matrix.smoke && runner.os != 'Linux'
run: node tools/smoke-packaged.mjs
- # Phase 8 criterion 2's second gate, and 08-REVIEW-TIMER WR-07 / 08-REVIEW-SCREENS WR-05: until this step
- # existed, capture-v2.mjs and diff-computed.mjs were referenced by no script, no test and no workflow -
- # so a register that documents itself as exiting non-zero on any unexplained difference exited non-zero at
- # nobody, and the committed 29-difference report could not go stale in a way anything would notice. It
- # runs here because it photographs the PACKAGED app, which only this job has.
- #
- # Windows only: baselines/v1.2.1/computed was captured on Windows, and font rasterisation and the default
- # font stack differ enough on macOS to make a cross-platform diff a reading rather than a gate.
- - name: Diff the shipped UI against the Phase 1 computed-style baselines (criterion 2)
- if: matrix.smoke && runner.os == 'Windows'
- run: npm run parity:check
-
- # Phase 10 criteria 1-4: five window sizes x three Windows display scales, four routes in each of the
- # fifteen cells, plus the modals at the 600px floor, the Pomodoro panel and the fire canvas's frame loop
- # across a real unmount. It drives the packaged app, which only this job has.
- #
- # Windows only, for the same two reasons parity:check is: the criterion is about WINDOWS display scaling,
- # and the committed report under baselines/v2/ is a staleness guard that would have to hold on two
- # platforms at once to run on both.
- - name: Check all fifteen responsive cells against the packaged app (criteria 1-4)
- if: matrix.smoke && runner.os == 'Windows'
- run: npm run matrix:check
-
- # Phase 10 criterion 7: Chromium's network stack offline, every http/https/ws request cancelled and
- # recorded in main, then all four routes - fonts, glyph widths, filled variants, the bundled sound, and
- # zero CSP violations captured rather than assumed. Windows only for the staleness-guard reason above.
- - name: Render every route with the network off (criterion 7)
- if: matrix.smoke && runner.os == 'Windows'
- run: npm run offline:check
-
- # Phase 10 criterion 6, reproduced in CI: a populated v1.2.1 database migrated by the packaged app, read
- # back through the app's own bridge - every session, company, setting and the streak.
- #
- # The archived copy of a real user's database is the other half of this criterion and is NOT here: it is
- # owner-gated (tests/db-archive-proof.test.ts sits behind an approval environment variable for a reason -
- # it is one person's tracked time and it does not belong on a hosted runner).
- #
- # And IPC-07, which the Phase 5 verifier left open because no test started a SECOND packaged process
- # against a held lock: the binary is launched twice over one profile and the second is required to quit.
- #
- # Windows only. V2-QUAL-03 makes runtime acceptance Windows-only this milestone, and a macOS failure here
- # could not be investigated - there is no macOS hardware. The two macOS legs still build, assert and
- # allowlist-check, which is exactly what criterion 9 claims and no more.
- - name: Install over v1.2.1 and launch twice from one profile (criterion 6, IPC-07)
- if: matrix.smoke && runner.os == 'Windows'
+ # Linux needs a display, and Ubuntu 24.04 runners block the unprivileged user namespaces Chromium's
+ # sandbox uses (AppArmor), which would fail the launch for a reason that has nothing to do with the
+ # package. The sandbox stays ON: the sysctl is lifted for this runner instead of passing --no-sandbox,
+ # because a smoke launch that disables the sandbox proves less than the app users will run.
+ - name: Launch the packaged app and open a database, under a virtual display (BUILD-06)
+ if: matrix.smoke && runner.os == 'Linux'
run: |
- npm run upgrade:check
- npm run instance:check
+ sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
+ sudo apt-get install -y --no-install-recommends xvfb
+ xvfb-run --auto-servernum node tools/smoke-packaged.mjs
# A skip that prints its reason is a different thing from a check that silently did nothing.
# These two artifacts are built, size-checked and allowlist-checked, and never executed.
@@ -262,5 +241,5 @@ jobs:
with:
# Named after operating system and architecture, so release.yml can ask for all four by name.
name: ${{ matrix.os }}-${{ matrix.arch }}-build
- path: ${{ matrix.artifact_glob }}
+ path: ${{ matrix.upload_path || matrix.artifact_glob }}
if-no-files-found: error # a green build that produced nothing is a failure
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index d446565..ffb0894 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -83,17 +83,24 @@ jobs:
name: macos-latest-arm64-build
path: dist/
+ # Linux ships as an AppImage and a .deb from one x64 leg, which package.yml uploads as one artifact.
+ - name: Download Linux x64 packages
+ uses: actions/download-artifact@v4
+ with:
+ name: ubuntu-latest-x64-build
+ path: dist/
+
# BUILD-10, enforced at the moment it matters most. fail_on_unmatched_files below catches an
# empty glob, but it cannot tell that three of four architectures showed up - dist/* matches
# happily either way. So count what arrived: exactly one non-empty file per expected
# installer, and nothing else in dist/, because every file in dist/ is attached to the release.
# The suffixes follow the artifactName templates in electron-builder.yml.
- - name: Refuse to publish unless all four installers arrived
+ - name: Refuse to publish unless every installer arrived
shell: bash
run: |
shopt -s nullglob
failed=0
- for suffix in x64-setup.exe arm64-setup.exe x64.dmg arm64.dmg; do
+ for suffix in x64-setup.exe arm64-setup.exe x64.dmg arm64.dmg x86_64.AppImage amd64.deb; do
matches=( dist/*-"$suffix" )
if [ "${#matches[@]}" -ne 1 ]; then
echo "::error::expected exactly one installer ending in -$suffix, found ${#matches[@]}: ${matches[*]:-none}"
@@ -106,8 +113,8 @@ jobs:
fi
done
everything=( dist/* )
- if [ "${#everything[@]}" -ne 4 ]; then
- echo "::error::dist/ holds ${#everything[@]} entries, expected exactly the four installers - every file in dist/ is attached to the release"
+ if [ "${#everything[@]}" -ne 6 ]; then
+ echo "::error::dist/ holds ${#everything[@]} entries, expected exactly the six installers - every file in dist/ is attached to the release"
ls -la dist/ || true
failed=1
fi
@@ -115,7 +122,7 @@ jobs:
echo "::error::Refusing to create the release: it would ship without every platform and architecture (BUILD-10)."
exit 1
fi
- echo "FOUR_ARTIFACTS_OK"
+ echo "ALL_ARTIFACTS_OK"
# A draft release does not create its tag until a person publishes it - and a tag pushed by a
# person starts this workflow again, which would rebuild for twenty minutes and then set the
diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml
index 73ae8fd..3d11109 100644
--- a/.github/workflows/verify.yml
+++ b/.github/workflows/verify.yml
@@ -59,7 +59,7 @@ jobs:
# errors; the renormalise assertion catches the actual D-13 regression - a blob
# committed with line endings that disagree with .gitattributes, which surfaces
# later as "whole file changed" on a file nobody edited.
- # v121-sql.ts is excluded for the same reason as baselines/: it transcribes v1.2.1's SQL and its
+ # v121-sql.ts is excluded because it transcribes v1.2.1's SQL and its
# trailing spaces are the contract, because sqlite_master stores CREATE text verbatim. Nothing is
# lost by excluding it - db-v121-transcription.test.ts pins the whole file byte-for-byte, which is
# a stronger guard than this check.
@@ -86,7 +86,7 @@ jobs:
exit 1
fi
echo "Whitespace check over $(git rev-parse --short "$base")..$(git rev-parse --short HEAD) ($EVENT_NAME)"
- git diff --check --ignore-cr-at-eol "$base" HEAD -- . ':!baselines/**' ':!tools/baseline/vendor/**' ':!tests/helpers/v121-sql.ts'
+ git diff --check --ignore-cr-at-eol "$base" HEAD -- . ':!tests/helpers/v121-sql.ts'
git add --renormalize .
if ! git diff --cached --quiet; then
echo "::error::Committed line endings disagree with .gitattributes in:"
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index bdb2955..2187fb6 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -85,8 +85,8 @@ A change has to pass all of these. They are what CI runs, and you can run every
| Audit | `npm audit` | Zero vulnerabilities. |
CI additionally runs `npm test` in three timezones (UTC, America/New_York, Europe/Istanbul), builds
-all four installers, and on the Windows leg runs `parity:check`, `matrix:check`, `offline:check`,
-`upgrade:check` and `instance:check`.
+every installer (Windows, macOS and Linux), and launches the packaged app with `smoke:packaged` on the legs
+the runner can execute.
Some of the suite is *structural* — tests that read the source and fail on a misplaced import, an
unlinted file, a stale guard or a duplicated asset. If one of those fails, it is telling you about a
diff --git a/README.md b/README.md
index 1c81351..a2a9f96 100644
--- a/README.md
+++ b/README.md
@@ -4,11 +4,11 @@
-**Local-first work time tracking for Windows and macOS**
+**Local-first work time tracking for Windows, macOS and Linux**
[](https://opensource.org/licenses/MIT)
[](https://www.electronjs.org/)
-[](https://github.com/fleizean/workflow/releases/latest)
+[](https://github.com/fleizean/workflow/releases/latest)
[What it does](#what-it-does) • [Install](#install) • [Your data](#your-data) • [Build from source](#build-from-source)
@@ -49,9 +49,20 @@ Download the installer for your platform from the
| Windows 10/11, ARM64 | `*-arm64-setup.exe` | Built in CI, not runtime-verified |
| macOS, Intel | `*-x64.dmg` | Built in CI, not runtime-verified |
| macOS, Apple Silicon | `*-arm64.dmg` | Built in CI, not runtime-verified |
+| Linux (Ubuntu/Debian), x64 | `*-amd64.deb` | Launched from the packaged build on Ubuntu |
+| Linux, x64, any distribution | `*-x86_64.AppImage` | Same build as the `.deb` |
The builds are unsigned and unnotarised. On Windows, SmartScreen will warn you the first time.
+### Linux
+
+```bash
+sudo apt install ./workflow-timer-*-amd64.deb # Ubuntu / Debian: adds Workflow to the app menu
+chmod +x workflow-timer-*-x86_64.AppImage && ./workflow-timer-*-x86_64.AppImage # any distribution
+```
+
+Your data lives in `~/.config/workflow-timer/`.
+
### macOS: "App is damaged" or "Cannot be opened"
macOS quarantines downloads from an unsigned developer. To clear it:
@@ -136,7 +147,7 @@ setx WORKFLOW_NO_UPDATE_CHECK 1 # Windows, applies to new sessions
```
```bash
-export WORKFLOW_NO_UPDATE_CHECK=1 # macOS
+export WORKFLOW_NO_UPDATE_CHECK=1 # macOS and Linux
```
With it set, no network client is created and no request is made.
@@ -169,12 +180,8 @@ folder, so development never touches your real database.
| `npm run build:unpack` | Build and package into `dist/` without making an installer |
| `npm run build:win` / `build:win:arm64` | Windows installers |
| `npm run build:mac` / `build:mac:arm64` | macOS disk images |
-| `npm run smoke:packaged` | Drive the packaged binary and check what it really does |
-| `npm run parity:check` | Compare the v2 screens with the v1.2.1 baselines |
-| `npm run matrix:check` | Lay the app out at five sizes and three display scales |
-| `npm run offline:check` | Render every route with the network off |
-| `npm run upgrade:check` | Migrate a populated v1.2.1 database and read it back through the app |
-| `npm run instance:check` | Launch the packaged binary twice against one profile |
+| `npm run build:linux` / `build:linux:arm64` | Linux AppImage and .deb |
+| `npm run smoke:packaged` | Launch the packaged app: fresh install, v1.2.1 upgrade and a database from a newer version |
Installers land in `dist/`.
@@ -192,8 +199,7 @@ workflow/
│ ├── shared/ types, zod schemas and the IPC contract both processes import
│ └── assets/ icons and the notification sound, bundled into the app
├── tests/ vitest: services, database, and the structural gates
-├── tools/ baseline capture, the packaged smoke, CI assertions
-├── baselines/ v1.2.1 screenshots and computed styles, and the v2 records
+├── tools/ the packaged smoke and CI assertions
├── docs/ the GitHub Pages site
└── electron-builder.yml
```
diff --git a/baselines/v1.2.1/MANIFEST.md b/baselines/v1.2.1/MANIFEST.md
deleted file mode 100644
index c431ae0..0000000
--- a/baselines/v1.2.1/MANIFEST.md
+++ /dev/null
@@ -1,463 +0,0 @@
-# v1.2.1 Baseline Manifest
-
-The record of *which artifacts* the v1.2.1 baseline is made of, and how to obtain the ones that
-are too large — or too private — to live in this repository.
-
-Produced by plan `01-04` (CUSTODY-07, CUSTODY-08). Extended by plans `01-05` and `01-06`; see the
-reserved sections at the end, which are meant to be **filled in place rather than duplicated**.
-
----
-
-## 1. Pinned release (CUSTODY-08)
-
-The shipped v1.2.1 installers are the fixture Phase 10 / REL-04 tests against: it installs the new
-build over v1.2.1 on a machine with a populated database. That test is only meaningful against the
-binary users actually installed, so the release is pinned by tag **and** by content digest.
-
-| Field | Value |
-|---|---|
-| Release tag | `v2026.03.03-312cd77` |
-| Target | `main` @ `312cd77` (the `main` HEAD this restructure branched from) |
-| Published | `2026-03-03T06:39:44Z` |
-| Release page | |
-| Repository | `fleizean/workflow` (numeric id `1122837169`) |
-| Values retrieved | live from the GitHub API, 2026-09-05 |
-
-### Assets
-
-| Platform | Asset name | Bytes | SHA-256 |
-|---|---|---|---|
-| Windows (NSIS) | `Workflow.Setup.1.2.1.exe` | 87,594,571 | `e0b19426708728597f17088048a1ff5ad247fcf139ab4823710163f4d112c7f0` |
-| macOS (arm64) | `Workflow-1.2.1-arm64.dmg` | 111,250,721 | `d59b210678b1e8589c44da36088a6c509c8e1903e22859efa32ad926a50fa6f4` |
-
-Download URLs are the tag path under the asset name:
-
-```
-https://github.com/fleizean/workflow/releases/download/v2026.03.03-312cd77/Workflow.Setup.1.2.1.exe
-https://github.com/fleizean/workflow/releases/download/v2026.03.03-312cd77/Workflow-1.2.1-arm64.dmg
-```
-
-### Obtaining and verifying
-
-```bash
-# Windows installer, verified against the pinned digest. The destination must be outside
-# this repository; the script refuses an in-repo path.
-bash tools/baseline/fetch-installer.sh ~/workflow-timer-archive/installers/Workflow.Setup.1.2.1.exe
-
-# macOS disk image
-bash tools/baseline/fetch-installer.sh ~/workflow-timer-archive/installers/Workflow-1.2.1-arm64.dmg --mac
-
-# Cheap liveness check: HEAD only, compares the reported content-length against the pin.
-# Downloads no payload, so it is usable in CI and in a per-task gate.
-bash tools/baseline/fetch-installer.sh --check-pin
-bash tools/baseline/fetch-installer.sh --check-pin --mac
-```
-
-`tools/baseline/fetch-installer.sh` uses distinct exit codes because "I could not reach the
-network" and "the pin no longer describes the asset" demand opposite responses:
-
-| Exit | Meaning | What to do |
-|---|---|---|
-| `0` | verified | proceed |
-| `2` | usage error, or a destination inside this repository | choose a path outside the repo |
-| `3` | **COULD NOT CHECK** — DNS, proxy, TLS or timeout | says nothing about the pin; retry with network |
-| `4` | **PIN DRIFT** — the server answered and disagrees with this table | investigate; do **not** edit this table to match |
-| `5` | **CHECKSUM MISMATCH** — a payload arrived and is not the pinned binary | treat as hostile; delete the file |
-
-### Why the binaries are not committed
-
-83.5 MB and 106 MB. D-03 forbids putting the owner's real database in this public repository
-because the content cannot be un-published; the same repository-is-public reasoning applies to
-size, and a 190 MB pair of blobs in git history is equally permanent. The digest is the artifact;
-the binary is reproducible from it.
-
-`.gitignore` excludes `*.db`, `*.db-shm` and `*.db-wal` but **not** `*.exe` or `*.dmg`. Stated
-plainly, therefore: **the installer binaries must never be added to this repository.**
-`tests/custody-hygiene.test.ts` and the `verify` workflow both fail if one ever is.
-
-### Two caveats, so a future reader does not conclude the link is broken
-
-1. **This is an asset of an auto-generated release, and no newer equivalent will appear.** The old
- `build-release.yml` published a release on every push to `main`; CUSTODY-01 has stopped that
- (D-06 — releases now come only from a version tag or a manual dispatch, and are created as
- drafts). The absence of releases after `v2026.03.03-312cd77` is the fix working, not neglect.
-
-2. **CUSTODY-11 will mark superseded v1.x releases as pre-release in Phase 10.** Marking a release
- pre-release does not delete it and does not delete its assets — the tag, the download URLs and
- the digests above all remain valid. If the release page renders with a "Pre-release" badge, the
- pin is still good.
-
-3. **The repository was renamed.** `fleizean/workflow-timer` 301-redirects to `fleizean/workflow`.
- Every URL here uses the current name directly rather than depending on GitHub continuing to
- honour that redirect. Note that `package.json`'s `name` field is still `workflow-timer` and
- **must stay that way** — it determines `%APPDATA%\workflow-timer\`, where every user's database
- lives (CUSTODY-02, `tests/app-identity.test.ts`).
-
----
-
-## 2. Real user data (CUSTODY-07, D-03)
-
-**Not in this repository, and never will be.** The owner's populated `krono.db` contains real
-client names and real work notes. It is archived to a dated folder on the owner's machine, outside
-both `%APPDATA%\workflow-timer\` and this working tree, by:
-
-```bash
-node tools/baseline/archive-real-db.mjs # archive + extract schema
-node tools/baseline/archive-real-db.mjs --self-test # exercise the guards, no real data needed
-```
-
-What *is* committed is the schema and nothing else:
-
-| Artifact | Content |
-|---|---|
-| `tests/fixtures/v121-real-schema.sql` | the `sql` column of `sqlite_master` from the owner's real database — DDL only, zero rows |
-
-The archive script refuses, by resolved-absolute-path containment, to write inside this repository
-or inside the userData directory it reads from, and asserts its own output carries no `INSERT`
-before writing it. The refusal is exercised by `--self-test`, not merely written down.
-
-The archive location itself is deliberately **not** recorded here: this file is public.
-
----
-
-## 3. Vendored capture assets
-
-A running v1.2.1 loads three third-party hosts, all of them live URLs:
-
-| Host | Loaded by | What it decides |
-|---|---|---|
-| `cdn.tailwindcss.com` | `src/pages/*.html:17` | essentially every rendered pixel |
-| `fonts.googleapis.com` | `src/styles/common.css:4-5` | which woff2 files are requested |
-| `fonts.gstatic.com` | the two stylesheets above | Inter, and all 41 Material Symbols glyphs |
-
-The Play CDN is the sharp edge. `https://cdn.tailwindcss.com?plugins=forms,container-queries`
-currently 302s to `/3.4.17?plugins=forms@0.5.10,container-queries@0.1.1` — **verified by request on
-2026-09-06**, which settles research assumption A7 by execution rather than by carry-forward — but
-nothing holds it there. It also does not ship a stylesheet: it ships the JIT engine, which
-generates CSS from the live DOM at runtime. A Phase 8 re-capture against a different build would
-produce a diff, and that diff would be attributed to the rewrite.
-
-So all eleven files are vendored under `tools/baseline/vendor/` and pinned by digest here.
-`tools/baseline/capture.mjs` intercepts all three hosts, verifies each file's SHA-256 against this
-table before fulfilling a request from it, and **fails closed** if a digest does not match. The
-capture therefore needs no network at all.
-
-Re-fetch (needs network; overwrites the files below, so re-record the digests if they move):
-
-```bash
-node tools/baseline/seed-baseline-db.mjs --fetch-vendor
-node tools/baseline/seed-baseline-db.mjs --verify-vendor # disk vs index.json vs this table
-```
-
-Google Fonts serves a different stylesheet per User-Agent. The fetch pins
-`Chrome/120.0.0.0` — the Chromium version inside Electron 28.3.3, which is what the application
-itself would send. Changing the UA changes the vendored CSS.
-
-`tools/baseline/vendor/index.json` is the machine-readable form of this table (URL, file, bytes,
-digest, fetch date, User-Agent); it is what `capture.mjs` resolves requests through, and
-`--verify-vendor` cross-checks it against both the files on disk and the digests below.
-
-Fetched **2026-09-06**, 1.8 MB total:
-
-| Asset | Source URL | Bytes | SHA-256 |
-|---|---|---|---|
-| `tailwind.js` | | 418,973 | `a789ce5a73191759006b64a0c05f63afbf9aa43a86511bf798d688737429e60a` |
-| `fonts-css/inter.css` | | 9,884 | `5682df055e3bc3420ab5065274d8b14caeee02857f0af6c07d0995b8d6271077` |
-| `fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc.woff2` | | 25,960 | `ca157063339ac4ad418f214f3abfed119b0798ab4d377386ce5c9e5a7a435ebd` |
-| `fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc.woff2` | | 18,748 | `71d5ee93cc1e9f1d520a3a8b66456de18c7879d8df09d57fcd2eaff75fef0075` |
-| `fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc.woff2` | | 11,232 | `6e9e020a25f9b56d418f2c085b1d3c09725a4da23fe693a5b463064606732190` |
-| `fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc.woff2` | | 18,996 | `1be3448e292fbf05ffe176fe1e43f135013d50b1e7d324ad1a558f623d3bb6f6` |
-| `fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc.woff2` | | 10,252 | `5c66f9e07e90c6d4ac4922cc68d60de26c17b1858e677fb5e603fce3952b3ff2` |
-| `fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2` | | 85,068 | `34b9c504cab7a73e37b746343a449132e56cf7b5481af2cb81dc74dcff25c956` |
-| `fonts/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2` | | 48,256 | `3100e775e8616cd2611beecfa23a4263d7037586789b43f035236a2e6fbd4c62` |
-| `fonts-css/material-symbols.css` | | 688 | `f229b149a663e9f469a61a8977d0327b9534b8952999be3a09e7b1a9e389d709` |
-| `fonts/kJEPBvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzBwG-RpA6RzaxHMPdY40KH8nGzv3fzfVJO1Q.woff2` | | 1,130,004 | `48d81a1cab89b4f3106081e85fff323b218cce76daf4c2c542503c8b1ccc6072` |
-
-Two notes for a future reader:
-
-- **The Material Symbols woff2 is 1.13 MB and is the whole variable font.** The application uses 41
- distinct icons and never touches the `FILL` axis, so a subset would be far smaller — but a subset
- is a *different* font, and the point of this directory is byte-fidelity to what v1.2.1 actually
- rendered. Subsetting belongs to the Phase 2 bundle-size work, not here.
-- **`.gitattributes` marks `tools/baseline/vendor/**` as `-text`.** Without it the tree-wide
- `eol=crlf` rule would check `tailwind.js` and the two stylesheets out as CRLF, and every digest
- in the table above would fail on a fresh clone of a file nobody had touched.
-
----
-
-## 4. Capture provenance
-
-Phase 8 re-runs this capture and diffs the result. Everything it needs in order to launch the
-*same* v1.2.1 the same way is here. Plan `01-05` filled in the launch path and the fixture policy;
-plan `01-06` adds the machine, the OS build and the commit the capture was taken at.
-
-### Launch path
-
-`tools/baseline/probe-userdata.mjs` and `tools/baseline/capture.mjs` both read these rows. The
-`Executable` row is parsed, so its shape matters: it is a single backticked value in the second
-cell, and `%NAME%` environment variables in it are expanded at use.
-
-| Field | Value |
-|---|---|
-| Launch path | `installed` |
-| Executable | `%USERPROFILE%\workflow-timer-archive\app-v1.2.1\Workflow.exe` |
-| Launch args | `--no-sandbox` |
-| Payload source | the pinned `Workflow.Setup.1.2.1.exe` from section 1 |
-| Resolved | plan `01-05`, 2026-09-06 |
-
-**The installed-application path was chosen over run-from-source**, per research Open Question 2:
-it is literally what users run, it needs no `better-sqlite3` native rebuild, and it therefore does
-not depend on a toolchain this machine does not have (`better-sqlite3@9.6.0` fails to build here
-with `MSB8020`, a missing ClangCL platform toolset — see plan `01-02`'s summary). It also means the
-D-12 ordering barrier softens from an absolute to a preference: the capture no longer needs the
-source tree's `better-sqlite3` to work.
-
-**The installer is unpacked, not executed.** `Workflow.Setup.1.2.1.exe` is a one-click
-electron-builder NSIS installer (`package.json` declares no `nsis` block, so `oneClick` and
-`runAfterFinish` both take their default of `true`), and a one-click installer launches the
-application when it finishes — including under `/S`. That launch would open the owner's real
-`%APPDATA%\workflow-timer\krono.db` before the redirection probe had ever run, which is exactly the
-disclosure this phase exists to prevent. The installer's payload is a 7-Zip stream appended to the
-NSIS stub, so the identical application files are obtainable without executing anything:
-
-```bash
-# 7za.exe ships inside electron-builder's 7zip-bin dependency; no extra install is needed.
-node_modules/7zip-bin/win/x64/7za.exe x \
- "$USERPROFILE/workflow-timer-archive/installers/Workflow.Setup.1.2.1.exe" \
- -o"$USERPROFILE\workflow-timer-archive\app-v1.2.1" -y
-```
-
-The extraction reports `There are data after the end of archive` — that is the NSIS stub and the
-overlay either side of the payload, and is expected.
-
-| Extracted file | Bytes | SHA-256 |
-|---|---|---|
-| `Workflow.exe` | 176,903,168 | `4fdd357c655edec7aaa9906ad19ff9a761e016dfa7993b74b528cc74e80c7f4f` |
-| `resources/app.asar` | 21,543,827 | `c23e46b2089be581e585c2a1cb3035da274ebb778ed43fd8a20f446e41638de8` |
-
-The extraction directory is outside this repository and is deliberately recorded relative to
-`%USERPROFILE%` rather than as a literal path, for the same reason section 2 omits the archive
-location: this file is public and the literal path carries the owner's Windows account name.
-
-### The fixture-database redirection (assumption A1 — RESOLVED by execution)
-
-`database/db.js:9-10` opens `path.join(app.getPath('userData'), 'krono.db')` at module load, and
-`main.js:6` requires it at the top of the file, so no application-level hook runs early enough to
-redirect it. Electron's `--user-data-dir` switch does, because
-`ElectronMainDelegate::PreSandboxStartup()` overrides `chrome::DIR_USER_DATA` before the main
-module is evaluated. Research read that from Electron v28.3.3's source but never executed it.
-
-**It was executed on 2026-09-06 and it holds.** Launching the extracted `Workflow.exe` with
-`--no-sandbox --user-data-dir=` created `/krono.db` and left the real database
-byte-identical, mtime included. Re-verify at any time with:
-
-```bash
-node tools/baseline/probe-userdata.mjs
-```
-
-The probe is the capture driver's entry gate, not a comment: `tools/baseline/capture.mjs` calls it
-before it is structurally able to take a screenshot, and `--skip-probe` is defined to exit non-zero
-so the gate is provably capable of failing.
-
-### Fixture seeding policy
-
-Written by `tools/baseline/seed-baseline-db.mjs`. **Phase 8 must reproduce this exactly or its diff
-means nothing**, which is why the policy is recorded here and not only in the script.
-
-`database/db.js` reads the real system clock in `getTodaySessions()`, `getThisWeekTotal()`,
-`getLastWeekTotal()` and `calculateCurrentStreak()`, so today's date is rendered into the output.
-Research offered two responses: seed relative to the run date, or accept the drift and mask the date
-strip. **Option (a), seed relative to the run date, was taken** — masking hides a region that the
-parity diff would otherwise cover.
-
-The non-obvious part is that "relative to the run date" is not enough on its own. `getThisWeekTotal`
-and `getLastWeekTotal` slice by **calendar week, Monday to Sunday**, not by "N days ago". Seeding
-`today-1` and `today-2` would put a different number of sessions inside the current week depending
-on which weekday the capture ran on — none of them on a Monday, both on a Thursday. So the seed is
-anchored to the week boundary on purpose:
-
-| Rule | Consequence |
-|---|---|
-| All of the current week's work sits on **today**: 3 sessions, 14400 + 10800 + 5400 = **30,600 s** | This Week always contains exactly 3 sessions totalling 30,600 s |
-| The historical block sits on **last week's Monday, Tuesday and Wednesday**, derived as `mondayOf(today) - 7 days + {0,1,2}` | always entirely inside "last week", never inside "this week"; Last Week is always 66,600 s |
-| **Nothing** is seeded on `today-1` or `today-2` | those days cross the week boundary as the weekday changes |
-| Pomodoro rows are seeded on **today only** | `getWeeklyPomodoroStats()` uses a rolling `[today-7, today]` window, not a calendar week |
-| `daily_target` = 28,800 s; today's 30,600 s clears it; yesterday is empty | `calculateCurrentStreak()` returns **exactly 1** on every run date |
-
-The streak of 1 is deliberate and doubly safe: `src/pages/index.html:719-729` starts the
-`requestAnimationFrame` particle canvas at `streak > 10` and adds a pulsing CSS tier at `streak > 5`,
-so at 1 there is no tier class and no animation clock to photograph. The capture masks
-`#streakFireCanvas` as well; a fixture that never lights it is the sturdier of the two controls and
-using both is correct.
-
-Dates are formatted with the byte-for-byte copy of `database/db.js:16-18` `formatLocalDate` —
-**local**, never `toISOString()`, or the fixture would be a day out for anyone whose offset from UTC
-crosses midnight.
-
-Every row passes `created_at` explicitly; `DEFAULT CURRENT_TIMESTAMP` would stamp the wall clock
-into the fixture and two runs on the same date would then differ. Company and session names are
-invented (`Northwind Fixture`, `Contoso Fixture`, `Fabrikam Fixture` — Microsoft's canonical
-fictional companies, suffixed so no reader can mistake one for a real client), because every string
-in the fixture is rendered into a PNG in this public repository. `Unassigned` is the one name that is
-not invented: `initDatabase()` recreates it on every launch, so a fixture without it would be
-silently mutated by the app the moment the capture began.
-
-Verify all of the above without a database, a network or an app:
-
-```bash
-node tools/baseline/seed-baseline-db.mjs --self-test
-```
-
-### Capture run
-
-Executed by plan `01-06` on **2026-09-06**. This is the run that produced the 20 PNGs under
-`pixels/` and the 20 computed-style records under `computed/`.
-
-**A Phase 8 re-capture that does not reproduce every row below produces a diff that cannot be
-attributed** — a changed Chromium, a changed Tailwind build, a different scale factor or a
-different locale each move pixels and computed values on their own, and none of them is the rewrite.
-
-| Field | Value |
-|---|---|
-| Run date | 2026-09-06 |
-| Commit | `90eaedc` (`v2-restructure`); the artifacts are committed on the child of that commit |
-| Driver | `tools/baseline/capture.mjs`, invoked as `npm run baseline:capture` |
-| `playwright-core` | **1.63.0**, pinned exactly — Playwright's Electron support is experimental and outside its stability guarantees, so Phase 8 must compare against a capture taken by the same driver |
-| Launch path | `installed`, per the Launch path table above |
-| Executable | `%USERPROFILE%\workflow-timer-archive\app-v1.2.1\Workflow.exe` |
-| Installer the payload came from | `Workflow.Setup.1.2.1.exe`, `sha256 e0b19426708728597f17088048a1ff5ad247fcf139ab4823710163f4d112c7f0` (section 1) |
-| Electron reported by the app | **28.3.3** |
-| Chromium reported by the app | **120.0.6099.291** |
-| Tailwind actually served | **3.4.17** with `forms@0.5.10` and `container-queries@0.1.1`, from the vendored `tailwind.js` (section 3) |
-| Vendored assets | 11 files, all verified against the section 3 digest table before the first request was fulfilled |
-| Network | none reached the app; all three hosts were served from `tools/baseline/vendor/`, and zero un-vendored remote requests were attempted |
-| Timezone forced at launch | `Europe/Istanbul` |
-| Locale forced at launch | `tr-TR` |
-| Colour scheme forced at launch | `dark` |
-| Device scale factor | **1** (`--force-device-scale-factor=1`), plus `--disable-lcd-text` so subpixel antialiasing cannot vary with the host display |
-| Screenshot options | `animations: 'disabled'`, `caret: 'hide'`, `fullPage: false` |
-| Masked selectors | `#streakFireCanvas` |
-| Fixture date policy | the Fixture seeding policy above; the fixture's `today` for this run was **2026-09-06** |
-| Machine | Windows 11 Pro x64, build **10.0.26100.9168**; Node **v24.14.0** drove the capture |
-
-The Electron and Chromium versions are read out of the extracted `Workflow.exe` itself
-(`Electron/28.3.3`, `Chrome/120.0.6099.291`), which also confirms the choice in section 3 to pin the
-Google Fonts fetch to a `Chrome/120` User-Agent: Google Fonts serves a different stylesheet per
-User-Agent, so the vendored copies are the ones this Chromium would have been given.
-
-#### What was produced
-
-| Directory | Count | Naming |
-|---|---|---|
-| `pixels/` | 20 PNG | `@x.png` |
-| `computed/` | 20 JSON | `@x.json` |
-
-Pages `index`, `companies`, `work-history`, `settings`; sizes 380x600, 430x932, 768x1024, 1280x800,
-1920x1080. Element counts are stable across all five sizes of a page — 122 for `index`, 123 for
-`companies`, 421 for `work-history`, 206 for `settings` — which is the expected shape: v1.2.1 has no
-size-conditional rendering, so a size change must move geometry without adding or removing nodes. A
-future capture whose counts differ *within* a page has changed the DOM, not the layout.
-
-#### Why the frames are not unstyled
-
-The Play CDN is a JIT engine that generates CSS from the live DOM through a `MutationObserver`, so
-`load` fires long before a page is styled and a `waitForLoadState` capture would photograph an
-unstyled document. The driver therefore waits on a **computed value** instead:
-`getComputedStyle(document.body).backgroundColor === 'rgb(16, 28, 34)'` — the palette's
-`background-dark`, `#101c22`, which only exists once the CDN has generated it. It then awaits
-`document.fonts.ready`, and before each screenshot asserts `window.innerWidth === `,
-because `main.js:31` makes the window frameless so `setSize` and `setContentSize` differ and an
-off-by-one content size would otherwise be recorded under the wrong filename.
-
-All 20 computed-style records carry `rgb(16, 28, 34)` on `body:nth-child(2)`, asserted by
-`tests/baselines.test.ts` on every record rather than on a sample.
-
-#### The masked streak card (known, deliberate)
-
-`#streakFireCanvas` is `absolute inset-0` inside the streak card, so Playwright's mask — which
-covers the element's bounding box — covers **the whole card**, not just the canvas. In
-`index@*.png` this is the magenta rectangle. It is left in place on purpose: the fire animation is
-`requestAnimationFrame`-driven and non-deterministic, and unmasking it to get a prettier picture
-would make every future diff noisy. The card is not lost to the baseline — the computed-style
-records are unaffected by masking and cover it in full. Phase 8 should compare that card by computed
-style, not by pixels.
-
-#### Privacy, checked rather than assumed (D-03, T-01-24)
-
-- The entry probe ran first and proved the redirection in this run before a screenshot was possible.
-- `%APPDATA%\workflow-timer\krono.db` was asserted byte-identical before the first launch and after
- the last: 61,440 bytes, `sha256 ca9423fa…c56383`, mtime unchanged at nanosecond resolution
- (`1774296193022671200`). Both WAL sidecars likewise unchanged.
-- The `computed/` records contain **no text content at all**, which is a stronger property than
- "no real names": their keys are structural selector paths (`tag#id:nth-child(n)`, 63 distinct ids,
- every one a static markup id from `src/pages/*.html`) and their values are drawn only from the
- enumerated CSS property set. Grepping all 20 files for the fixture's own company names returns
- nothing, because no name of any kind is recorded.
-- Every string rendered in the PNGs traces to `tools/baseline/seed-baseline-db.mjs`, and four
- screenshots spanning both extremes of the size matrix were reviewed directly.
-
----
-
-## 5. The v1.2.1 source after SPA-14 (added by slice 08-F)
-
-Phase 8 slice F deleted the last of the v1.2.1 tree: `legacy/` (the renderer Phase 7 moved out of
-`src/`), and `main.js`, `preload.js` and `database/db.js`, which had stayed at the repository root
-because five guards pinned them by path. **Every citation of those paths in this repository — in
-this manifest, in `PARITY-CHECKLIST.md`, and in the comments throughout `src/` that name a file and
-a line number — still resolves.** Git has the files; only the worktree does not.
-
-### Reading any of them
-
-The commit to read from is not written down here, because a written-down SHA goes stale the moment
-anything is rebased. Ask git for it instead — `--diff-filter=d` drops the deletion itself, so `-1`
-lands on the last commit whose tree carried the path:
-
-```bash
-# One file, at the last commit that had it.
-git show "$(git log --format=%H --diff-filter=d -1 -- legacy):legacy/pages/index.html"
-git show "$(git log --format=%H --diff-filter=d -1 -- main.js):main.js"
-git show "$(git log --format=%H --diff-filter=d -1 -- database/db.js):database/db.js"
-
-# The whole v1.2.1 renderer into a directory, to grep or to read side by side.
-git archive "$(git log --format=%H --diff-filter=d -1 -- legacy)" legacy preload.js main.js \
- | tar -x -C /some/scratch/dir
-```
-
-`tests/helpers/v121-source.ts` is the same resolution in TypeScript (`readV121`, `listV121`), and
-`tools/baseline/inventory.sh` uses the `git archive` form above when `legacy/` is not on disk.
-
-### What each guard does now, and why none of them was weakened
-
-Four of the five protect the Core Value, so none was retired. Every one reads the same bytes it read
-before; only the source changed from the worktree to git.
-
-| Guard | Pinned | Now |
-|---|---|---|
-| `tests/db-v121-transcription.test.ts` — the v1.2.1 SQL transcription, pinned to its source by AST (D-13, D-26) | `database/db.js` | reads it through `readV121`. **Unchanged strength.** The Phase 7 note proposed degrading this to a SHA-256 pin of the transcription; that was not needed. On a shallow clone it now *skips* rather than passes. |
-| `tests/db-legacy-shapes.test.ts` — the git-history shape walk (D-14) | `database/db.js` | **untouched.** It already read every historical blob with `git cat-file`, and `--diff-filter=d` excludes the deletion commit, so it enumerates exactly the same commits. |
-| `tests/inventory.test.ts` — the CUSTODY-09 behaviour inventory, which is what authorises this deletion | `legacy/**`, `preload.js`, `main.js` | reads them through `listV121` / `readV121`. **Unchanged strength:** it still *recomputes* all five counts from the v1.2.1 source and still diffs the committed artifacts against the recomputation. Repointing it at those artifacts instead would have been a test that compares a file with itself. |
-| `tests/ipc-parity.test.ts` — the IPC parity map | `baselines/v1.2.1/preload-surface.txt`, `ipc-channels.txt` | **untouched.** It already read the committed artifacts, and greps only the v2 tree. |
-| `tests/sheets-retirement.test.ts` — the Sheets retirement scan's *control* | `main.js`, `database/db.js`, two `legacy/pages/*.html` | reads them through `readV121`. The control matters: without it the scan could pass because it looks nowhere. |
-| `tests/ipc-contract.test.ts` — D-20, the setting defaults | `database/db.js` | reads it through `readV121`. Not one of the five Phase 7 named, and it protects the Core Value too: a drifted default silently changes a user's daily target. |
-
-`tests/packaging.test.ts` keeps its `legacy/`, `main.js`, `preload.js` and `database/` deny rules.
-They match synthetic paths rather than the filesystem, so they still run — and a rule that would
-catch the files if they ever came back is worth more than one retired for tidiness.
-
-### Proof, rather than the claim
-
-Run after the deletion, with no `legacy/` on disk:
-
-```
-$ bash tools/baseline/inventory.sh
-inventory.sh: reading the v1.2.1 source from 7d70f55 (deleted by SPA-14)
-handler_sites=106
-api_names=21
-api_call_sites=67
-preload_apis=25
-ipc_channels=25
-```
-
-The five counts are the pinned ones, and the four regenerated artifacts are **byte-identical** to
-the committed ones — `git status` reports nothing under `baselines/`. The inventory did not survive
-the deletion in name only.
diff --git a/baselines/v1.2.1/PARITY-CHECKLIST.md b/baselines/v1.2.1/PARITY-CHECKLIST.md
deleted file mode 100644
index 2a23df0..0000000
--- a/baselines/v1.2.1/PARITY-CHECKLIST.md
+++ /dev/null
@@ -1,470 +0,0 @@
-# v1.2.1 Behaviour Parity Checklist
-
-**Requirement:** CUSTODY-09. **Ticked by:** REL-06. **Gates:** SPA-14 (deletion of the legacy pages).
-
-## Provenance
-
-| Field | Value |
-|---|---|
-| Generator | `bash tools/baseline/inventory.sh` |
-| Captured at commit | `22fe9a0fef3db6d122888e94f7025bf8504e589c` (branch `v2-restructure`) |
-| Captured on | 2026-09-06 |
-| Source of truth | `src/**/*.{html,js}`, `preload.js`, `main.js` — source text only. No database was opened and the application was not launched. |
-| Moved 2026-09-13 | Phase 7 moved the v1.2.1 renderer from `src/pages/` and `src/renderer/*.js` to `legacy/pages/` and `legacy/renderer/`, and `src/styles/` to `legacy/styles/`. Every `src/…` citation below names the file as v1.2.1 shipped it; read it at its `legacy/…` path in this tree. The counts are unchanged — the four deleted page fragments contributed none of them. |
-| Deleted 2026-09-16 | Slice 08-F deleted `legacy/`, `main.js`, `preload.js` and `database/db.js` (SPA-14). Every `src/…` and `legacy/…` citation in this document still resolves - git has the files. `MANIFEST.md` section 5 has the one-line recipe for reading any of them, and the table of what each guard that pinned them reads now. The counts below were regenerated from git history after the deletion and are byte-identical. |
-
-| Count | Value | Artifact |
-|---|---|---|
-| `addEventListener` registration sites | **106** | `handlers.tsv` |
-| Distinct `window.api.` names called | **21** | derived from `api-calls.tsv` |
-| Total `window.api.` call sites | **67** | `api-calls.tsv` |
-| APIs exposed by `preload.js` | **25** | `preload-surface.txt` |
-| `ipcMain.handle` / `ipcMain.on` channels in `main.js` | **25** | `ipc-channels.txt` |
-
-Re-run the generator in Phase 8 and diff `baselines/v1.2.1/` against the regenerated output. The
-counts above are also asserted by `tests/inventory.test.ts`, which recomputes them from the live tree
-on every CI run — so these artifacts cannot go stale while still looking authoritative.
-
-## Where the parity workload is
-
-Per-file `addEventListener` distribution. This is the size of the job, stated up front rather than
-discovered in Phase 8:
-
-| File | Sites |
-|---|---|
-| `src/pages/work-history.html` | 36 |
-| `src/pages/index.html` | 27 |
-| `src/pages/settings.html` | 24 |
-| `src/pages/companies.html` | 15 |
-| `src/renderer/bottom-nav.js` | 2 |
-| `src/renderer/shared.js` | 1 |
-| `src/renderer/titlebar.js` | 1 |
-| `src/renderer/timer.js` | 0 |
-| `src/pages/fragments/*.html` (4 files) | 0 — markup only, no script |
-| **Total** | **106** |
-
-`window.api` call sites by file: `settings.html` 25, `index.html` 20, `work-history.html` 13,
-`companies.html` 6, `titlebar.js` 2, `shared.js` 1 — 67 in total.
-
-## Corrections and findings recorded at capture time
-
-### 1. The preload surface is 25 APIs, not 24
-
-`.planning/research/PITFALLS.md` states 24. The live count is **25**
-(`grep -c "^ [a-zA-Z0-9_]*:" preload.js`). Phase 6 (IPC-01) reproduces this surface and Phase 8
-ticks against it, so 24 would understate the parity workload by one API. **25 is the denominator.**
-
-The derived claim that five APIs are dead is unaffected and still holds: 25 exposed minus 20
-exposed-and-called equals 5. Named explicitly, because "five are dead" is not actionable and a set is:
-
-| Dead preload API (exposed, never called from `src/`) | Disposition |
-|---|---|
-| `getSessionsByDateCompany` | SPA-15 — remove, do not port |
-| `getSessionsGrouped` | SPA-15 — remove, do not port |
-| `getTodaySessions` | SPA-15 — remove, do not port |
-| `getTodaysSessionsSummary` | SPA-15 — remove, do not port |
-| `updateCompanyExcelConfig` | SPA-15 — remove, do not port |
-
-### 2. `saveSetting` vs `setSetting` is a live defect (B4), not dead code
-
-Exactly one name is called by the UI and **not** exposed by the bridge:
-
-| Called by the UI | Exposed by `preload.js` | Result |
-|---|---|---|
-| `window.api.saveSetting` (`index.html:1434`, `index.html:1459`) | `setSetting` | `TypeError` at runtime — the write silently never happens |
-
-Both call sites are on the Pomodoro toggle path, persisting `pomodoro_enabled`. In v1.2.1 the toggle
-therefore updates the in-memory timer and `localStorage` but **never writes the setting to the
-database**. This is finding B4 and it is **broken behaviour that must not be reimplemented
-faithfully** — the correct parity outcome is a working persist, tracked by SET-03 / POMO-06.
-
-### 3. The 106 `addEventListener` sites are not the whole behaviour surface
-
-Six behaviours are attached through inline `onclick` attributes inside generated HTML strings and so
-do **not** appear in `handlers.tsv`:
-
-| File | Line | Attribute |
-|---|---|---|
-| `src/pages/companies.html` | 140 | `editCompany(, '')` |
-| `src/pages/companies.html` | 145 | `deleteCompanyConfirm(, '', )` |
-| `src/pages/work-history.html` | 683 | `event.stopPropagation(); editSession()` |
-| `src/pages/work-history.html` | 688 | `event.stopPropagation(); deleteSessionWithConfirm(, '')` |
-| `src/renderer/titlebar.js` | 16 | `window.api.minimizeWindow()` |
-| `src/renderer/titlebar.js` | 19 | `window.api.closeWindow()` |
-
-The count stays 106 — that is what the grep measures and what the test pins. This note records what
-106 does **not** cover, so Phase 8 does not read a fully ticked checklist as "everything was found".
-The four interpolating attributes are the S2 injection hole (COMP-03, HIST-06); they are given rows
-below so the behaviour is not lost together with the pages.
-
-## How to use this checklist
-
-- One row per **behaviour**, not per grep hit. A modal's close button and its click-outside-to-dismiss
- are several sites and one behaviour.
-- `Reimplemented` = the behaviour exists in the new SPA. `Verified` = it was exercised and observed,
- not merely written.
-- Every box starts unticked. Phase 8 earns the ticks. REL-06 requires all of them before SPA-14
- deletes `src/pages/*.html`.
-- A blank `Req` means the row is UI plumbing with no requirement of its own; it still has to work.
-
-### Marks, as slice 08-F filled them in
-
-| Mark | Meaning |
-|---|---|
-| `[x]` under Reimplemented | the behaviour exists in the SPA. Where what it does differs from v1.2.1, the Evidence cell says how and why. |
-| `[-]` under Reimplemented | **deliberately absent**, and the row is ticked by confirming the absence rather than the presence - the Google Sheets export the owner removed, the `navigate` channel deleted with its path-traversal hole, and the streak card's debug leftover. |
-| `[x]` under Verified | exercised and observed by something that runs: a test in `tests/`, a check in the packaged smoke, or the four-route capture behind `baselines/v1.2.1/VISUAL-PARITY-DIFF.md`. The Evidence cell names which. |
-| `[ ]` under Verified | **not exercised**, and the Evidence cell says what does exist and what is left. This is never a shrug: it is the UAT list. |
-
-### Where it stands
-
-| | Count |
-|---|---|
-| Reimplemented, present | 96 |
-| Reimplemented, deliberately absent and confirmed absent | 14 |
-| **Reimplemented, total settled** | **110 / 110** |
-| Verified - exercised and observed | 52 |
-| Verified - not exercised | 58 |
-
-**The 58 are one shape, not an assortment.** Every one of them is a pointer landing on a control:
-a press, a dismissal, a typed field, a backdrop click. This repository has **no jsdom and renders no
-component in any test, by decision** (`08-SCOPE.md`), so there is nothing here that can press a
-button. What each of those rows *can* be held to - the arithmetic, the wording, the query keys, the
-class names against compiled CSS, the refusal below IPC - is held to it, and the Evidence cell names
-the file. What is left in all 58 cases is a human using the app.
-
-Three rows are worth reading on their own rather than as part of that 58: **#56** is the only row
-in the document that is *not fully met* - About denies in-app navigation but does not hand the URL
-to the OS browser, because no channel opens one - and **#98, #103, #104** record that Phase 1's
-*do not port* disposition was revised during implementation, so the five names actually dropped are
-not the five this document predicted.
-
----
-
-## Shell — all screens (`bottom-nav.js`, `titlebar.js`, `shared.js`)
-
-| # | Screen | Element | Event | Behavior | Req | Reimplemented | Verified | Evidence, or why not |
-|---|---|---|---|---|---|---|------|
-| 1 | Shell | Bottom-nav link | click | Prevent default and navigate to the target page through the loading overlay | SPA-01, SPA-02 | [x] | [x] | `BottomNav` renders a `NavLink` per route; smoke *changing route loaded no document, and did change the page in place*. **Deviation:** no loading overlay - a route change is one React commit, so there is nothing left to cover. |
-| 2 | Shell | Bottom-nav container | DOMContentLoaded | Build the nav from the current pathname and mark the active item | SPA-02 | [x] | [x] | `BottomNav` marks the active item from the current path. Rendered in all 20 captures behind `VISUAL-PARITY-DIFF.md`. |
-| 3 | Shell | Custom titlebar | DOMContentLoaded | Insert the frameless titlebar as the first child of `.app-container` when one exists | | [x] | [x] | `AppShell` renders `TitleBar` as its first child. Present in all 20 captures. |
-| 4 | Shell | Titlebar minimize / close | click (inline `onclick`) | `window.api.minimizeWindow()` / `window.api.closeWindow()` — window controls; close must actually quit rather than re-hide | IPC-08 | [x] | [x] | `window:hide` and `app:quit`. Smoke: *a system close hides the window instead of ending the app*, *with no tray to hide to the window really closes, so the app stays quittable* (WR-03), *quitting lets the window go instead of re-hiding it*. **IPC-08 closed** - v1.2.1's close only ever re-hid. |
-| 5 | Shell | Toast close button | click | Fade out and remove; the toast also self-dismisses on a timer | SPA-04 | [x] | [ ] | `ToastStack`: a close button, and auto-dismiss at v1.2.1's own 3000 ms. **Not exercised** - no test renders a component and the capture raises no toast. Closes by raising one in the running app. |
-| 6 | Shell | Alert modal close / backdrop | click | Dismiss the shared alert. In v1.2.1 every page carries its own copy of `showAlert` / `createModernModal` | SPA-03, SPA-04 | [x] | [ ] | One `AlertDialog` over the shared `Modal`, mounted once in `AppShell`; close button, backdrop and Escape are one answer. **Not exercised** - as row 5. |
-| 7 | Shell | Page navigation | — | `window.api.navigateTo({ page })` loads another HTML file. **Not ported** — the channel is deleted with its path-traversal hole (S1) and replaced by client-side routing | IPC-02, SPA-01 | [-] | [x] | Confirmed **absent**. `tests/ipc-parity.test.ts` finds no channel, no event and no string literal naming it anywhere in the v2 source but the `will-navigate` guard. `HashRouter` changes route with no main-process involvement, so the path-traversal hole (S1) has nothing to reach. |
-
-## Home / Timer (`src/pages/index.html` — 27 handler sites, 20 API call sites)
-
-| # | Screen | Element | Event | Behavior | Req | Reimplemented | Verified | Evidence, or why not |
-|---|---|---|---|---|---|---|------|
-| 8 | Home | Play/pause button | click | Toggle run/pause, swap the `play_arrow` / `pause` icon, show or hide the status badge, persist timer state | TIMER-01 | [x] | [ ] | `TimerControls` to `useTimerCommands` start/pause; the icon, the badge and the digits are `describeWorkDial`'s, run in `tests/timer-screen.test.ts`. Persistence is main's (`tests/timer-service.test.ts`, packaged `timer` case). **The press itself is not exercised.** |
-| 9 | Home | Adjust button | click | Open the manual time-adjust modal | TIMER-01 | [x] | [ ] | `AdjustTimeForm` in the shared `Modal`. Not exercised. |
-| 10 | Home | Adjust presets (`.adjust-btn`) | click | Add `data-seconds` to elapsed and persist | TIMER-01 | [x] | [ ] | **Deviation (08-C):** the six +/-30, +/-5, +/-1 steps are ported, but they change the duration of the session about to be written, not the counted clock - main has no channel that adds seconds to the accumulator, deliberately, because a channel that invents work is what this milestone exists to close. The arithmetic runs in `tests/timer-screen.test.ts`; the press is not exercised. |
-| 11 | Home | Apply-manual-minutes button | click | Add N minutes to elapsed, clear the input, persist | TIMER-01 | [x] | [ ] | As row 10: the manual-minutes field applies to the pending session's duration, not to the clock. Not exercised. |
-| 12 | Home | Adjust modal close / backdrop | click | Dismiss without applying | SPA-03 | [x] | [ ] | Shared `Modal`: dismiss button, backdrop and Escape. Not exercised. |
-| 13 | Home | Save button | click | Refuse with "No time to save!" when elapsed is 0; otherwise open the save-session modal populated with companies | TIMER-02 | [x] | [x] | **Deviation:** v2 disables SAVE while nothing is counted instead of accepting the press and refusing it with *No time to save!*. The refusal is the same; the control says so before it is pressed. **Observed:** the capture records that button at `opacity: 0.4` with zero counted time (`VISUAL-PARITY-DIFF.md`). The populated path is not exercised. |
-| 14 | Home | Confirm-save button | click | `saveSession` with name, duration, company and note for the selected date, then reset the timer. **v1.2.1 demands a note for every company regardless of the company's note-required flag** — B11; the correct behaviour is per-company | TIMER-02, TIMER-03, TIMER-04 | [x] | [ ] | `timer:stopAndSave` writes the session and clears the accumulator in **one transaction** (WR-06) - two invokes cannot be atomic, and either order loses or duplicates a session on a kill. **B11 closed:** the note is demanded per company, refused below IPC (`tests/sessions-service.test.ts`). The form's own path is not exercised. |
-| 15 | Home | Save-session name input | keypress `Enter` | Forward Enter to the confirm-save button | TIMER-02 | [x] | [ ] | Native form semantics rather than a keypress listener: `SaveSessionForm` is a `
-
+
*-arm64.dmg
Built in CI, not runtime-verified
+
+ Linux x64
+ *-amd64.deb
+ *-x86_64.AppImage
+ Ubuntu, Debian and others
+
diff --git a/electron-builder.yml b/electron-builder.yml
index 7017c44..0cb846f 100644
--- a/electron-builder.yml
+++ b/electron-builder.yml
@@ -76,3 +76,17 @@ mac:
dmg:
artifactName: '${name}-${version}-${arch}.${ext}'
+
+linux:
+ # AppImage runs anywhere with no install step; deb is the native Ubuntu/Debian package with a menu
+ # entry and `apt install ./file.deb` support. Both come from one build of the unpacked app.
+ target:
+ - AppImage
+ - deb
+ icon: build/icon.png
+ category: Utility
+ # A .deb refuses to build without a maintainer carrying an email, and package.json's author has none.
+ # This is deb control metadata only - it does not touch the manifest inside the app (see the header).
+ maintainer: Workflow Team
+ # Unsigned, like the other platforms; nothing here needs a signing identity.
+ artifactName: '${name}-${version}-${arch}.${ext}'
diff --git a/electron.vite.config.ts b/electron.vite.config.ts
index 19aa3d2..55b9c4a 100644
--- a/electron.vite.config.ts
+++ b/electron.vite.config.ts
@@ -34,7 +34,7 @@ export default defineConfig({
alias: {
'@renderer': resolve(__dirname, 'src/renderer/src'),
// The runtime assets the renderer and the tray share one copy of: src/assets/icon.png and
- // src/assets/notification.mp3. Aliased rather than reached by a five-deep relative path.
+ // src/assets/sounds/ (the notification sounds). Aliased rather than reached by a five-deep relative path.
'@assets': resolve(__dirname, 'src/assets'),
'@shared': resolve(__dirname, 'src/shared')
}
diff --git a/eslint.config.js b/eslint.config.js
index 660ff9e..39015e5 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -135,9 +135,8 @@ const SQL_BANS = [
'CallExpression[callee.property.name=\'exec\'][arguments.0.type=\'Literal\']',
'CallExpression[callee.property.name=\'exec\'][arguments.0.type=\'TemplateLiteral\']'
].map((selector) => ({ selector, message: SQL_MESSAGE }));
-// The two bootstrap files that operate the database file itself: D-32's wal_checkpoint before the close, and
-// BUILD-06's packaged proof, which writes and reads one row in a brand-new injected database.
-const SQL_BOOTSTRAP_EXEMPT = ['src/main/database-startup.ts', 'src/main/smoke.ts'];
+// The bootstrap file that operates the database file itself: D-32's wal_checkpoint before the close.
+const SQL_BOOTSTRAP_EXEMPT = ['src/main/database-startup.ts'];
/*
* SPA-11 (trap C3): a class name that exists only after a concatenation is a class name the build never saw.
@@ -442,12 +441,10 @@ const TYPE_IMPORT_RULES = {
module.exports = [
// .claude/, .gsd/ and .planning/ hold the gitignored GSD runtime, not this repository's source.
- // tools/baseline/vendor/** holds third-party bytes pinned by SHA-256 in tools/baseline/vendor/index.json.
{
ignores: [
'dist/**', 'build/**', 'node_modules/**', 'out/**',
'docs/**',
- 'baselines/**', 'tools/baseline/vendor/**',
'.claude/**', '.gsd/**', '.planning/**'
]
},
diff --git a/package.json b/package.json
index 7776e7e..0cb4b5a 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "workflow-timer",
- "version": "2.0.0",
- "description": "Workflow - local-first work time tracking for Windows and macOS",
+ "version": "2.0.1",
+ "description": "Workflow - local-first work time tracking for Windows, macOS and Linux",
"main": "./out/main/index.js",
"scripts": {
"dev": "install-electron && electron-vite dev",
@@ -12,20 +12,14 @@
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"test": "vitest run",
"test:watch": "vitest",
- "baseline:capture": "node tools/baseline/capture.mjs",
- "baseline:capture-v2": "node tools/baseline/capture-v2.mjs",
- "baseline:diff": "node tools/baseline/diff-computed.mjs",
- "parity:check": "node tools/baseline/parity-check.mjs",
- "matrix:check": "node tools/baseline/responsive-matrix.mjs",
- "offline:check": "node tools/offline-routes.mjs",
- "upgrade:check": "node tools/upgrade-over-v121.mjs",
- "instance:check": "node tools/second-instance.mjs",
"build": "npm run typecheck && electron-vite build",
"build:unpack": "npm run build && electron-builder --dir --publish never",
"build:win": "npm run build && electron-builder --win --x64 --publish never",
"build:win:arm64": "npm run build && electron-builder --win --arm64 --publish never",
"build:mac": "npm run build && electron-builder --mac --x64 --publish never",
"build:mac:arm64": "npm run build && electron-builder --mac --arm64 --publish never",
+ "build:linux": "npm run build && electron-builder --linux --x64 --publish never",
+ "build:linux:arm64": "npm run build && electron-builder --linux --arm64 --publish never",
"smoke:packaged": "node tools/smoke-packaged.mjs"
},
"keywords": [
diff --git a/src/assets/notification.mp3 b/src/assets/notification.mp3
deleted file mode 100644
index e8db158..0000000
Binary files a/src/assets/notification.mp3 and /dev/null differ
diff --git a/src/assets/sounds/LICENSE.txt b/src/assets/sounds/LICENSE.txt
new file mode 100644
index 0000000..54b3ef3
--- /dev/null
+++ b/src/assets/sounds/LICENSE.txt
@@ -0,0 +1,11 @@
+Notification sounds
+===================
+
+All sounds in this folder come from Kenney's CC0 packs "Interface Sounds" and
+"Digital Audio" (https://kenney.nl), re-encoded to Ogg Vorbis and level-matched.
+
+License: Creative Commons Zero (CC0 1.0)
+https://creativecommons.org/publicdomain/zero/1.0/
+
+The content is free to use in personal, educational and commercial projects.
+Crediting Kenney (www.kenney.nl) is appreciated but not required.
diff --git a/src/assets/sounds/chime.ogg b/src/assets/sounds/chime.ogg
new file mode 100644
index 0000000..8c8918b
Binary files /dev/null and b/src/assets/sounds/chime.ogg differ
diff --git a/src/assets/sounds/classic.ogg b/src/assets/sounds/classic.ogg
new file mode 100644
index 0000000..3aa0186
Binary files /dev/null and b/src/assets/sounds/classic.ogg differ
diff --git a/src/assets/sounds/confirm.ogg b/src/assets/sounds/confirm.ogg
new file mode 100644
index 0000000..62b93c2
Binary files /dev/null and b/src/assets/sounds/confirm.ogg differ
diff --git a/src/assets/sounds/glass.ogg b/src/assets/sounds/glass.ogg
new file mode 100644
index 0000000..7009570
Binary files /dev/null and b/src/assets/sounds/glass.ogg differ
diff --git a/src/assets/sounds/pep.ogg b/src/assets/sounds/pep.ogg
new file mode 100644
index 0000000..19abf07
Binary files /dev/null and b/src/assets/sounds/pep.ogg differ
diff --git a/src/assets/sounds/question.ogg b/src/assets/sounds/question.ogg
new file mode 100644
index 0000000..13f37f0
Binary files /dev/null and b/src/assets/sounds/question.ogg differ
diff --git a/src/assets/sounds/rise.ogg b/src/assets/sounds/rise.ogg
new file mode 100644
index 0000000..52e853b
Binary files /dev/null and b/src/assets/sounds/rise.ogg differ
diff --git a/src/assets/sounds/three-tone.ogg b/src/assets/sounds/three-tone.ogg
new file mode 100644
index 0000000..9ee8e1d
Binary files /dev/null and b/src/assets/sounds/three-tone.ogg differ
diff --git a/src/assets/sounds/two-tone.ogg b/src/assets/sounds/two-tone.ogg
new file mode 100644
index 0000000..ab8b946
Binary files /dev/null and b/src/assets/sounds/two-tone.ogg differ
diff --git a/src/lib/db/repositories/settings.repository.ts b/src/lib/db/repositories/settings.repository.ts
index 1af300f..f1f80d2 100644
--- a/src/lib/db/repositories/settings.repository.ts
+++ b/src/lib/db/repositories/settings.repository.ts
@@ -2,7 +2,7 @@
// Rows outside DOMAIN_KEYS are read by nobody and written by nobody, so what v1.2.1 stored there stays there.
import { sql } from 'drizzle-orm';
-import { DEFAULT_SETTINGS } from '@shared/constants/settings';
+import { DEFAULT_SETTINGS, SOUND_CHOICE_IDS } from '@shared/constants/settings';
import { settings } from '../schema';
import type { DbHandle } from '../handle';
import type { Settings } from '@shared/types';
@@ -15,6 +15,8 @@ type BooleanKey = {
const DOMAIN_KEYS: Readonly> = {
dailyTargetSeconds: 'daily_target',
goalNotification: 'goal_notification',
+ soundEnabled: 'sound_enabled',
+ notificationSound: 'notification_sound',
excludeWeekendsFromStreak: 'exclude_weekends_from_streak',
pomodoroEnabled: 'pomodoro_enabled',
pomodoroWorkSeconds: 'pomodoro_work_duration',
@@ -26,7 +28,7 @@ const DOMAIN_KEYS: Readonly> = {
};
const BOOLEAN_KEYS: readonly BooleanKey[] = [
- 'goalNotification', 'excludeWeekendsFromStreak', 'pomodoroEnabled', 'pomodoroAutoStartBreaks',
+ 'goalNotification', 'soundEnabled', 'excludeWeekendsFromStreak', 'pomodoroEnabled', 'pomodoroAutoStartBreaks',
'pomodoroAutoStartWork'
];
@@ -45,6 +47,11 @@ function parseBoolean(stored: string | undefined, fallback: boolean): boolean {
return fallback;
}
+/** A sound name this build does not ship - a downgrade, or a hand-edited row - reads as the seed, never as silence. */
+function parseSoundChoice(stored: string | undefined, fallback: Settings['notificationSound']): Settings['notificationSound'] {
+ return SOUND_CHOICE_IDS.find((id) => id === stored) ?? fallback;
+}
+
function parsePositiveInt(stored: string | undefined, fallback: number): number {
if (stored === undefined) return fallback;
const value = Number(stored);
@@ -64,9 +71,11 @@ export function createSettingsRepository(handle: DbHandle): SettingsRepository {
const read = (key: K): Settings[K] => {
const raw = stored.get(DOMAIN_KEYS[key]);
const fallback = DEFAULT_SETTINGS[key];
- const value = isBooleanKey(key)
- ? parseBoolean(raw, fallback as boolean)
- : parsePositiveInt(raw, fallback as number);
+ const value = key === 'notificationSound'
+ ? parseSoundChoice(raw, fallback as Settings['notificationSound'])
+ : isBooleanKey(key)
+ ? parseBoolean(raw, fallback as boolean)
+ : parsePositiveInt(raw, fallback as number);
return value as Settings[K];
};
diff --git a/src/main/adapters/renderer-sound.adapter.ts b/src/main/adapters/renderer-sound.adapter.ts
index b70ac30..66b1a19 100644
--- a/src/main/adapters/renderer-sound.adapter.ts
+++ b/src/main/adapters/renderer-sound.adapter.ts
@@ -4,6 +4,6 @@ import type { RendererBusPort, SoundPort } from '../ports';
export function createRendererSound(bus: RendererBusPort): SoundPort {
return {
- play: (sound) => { bus.emit('app:playSound', { sound }); }
+ play: (sound, choice) => { bus.emit('app:playSound', { sound, choice }); }
};
}
diff --git a/src/main/config.ts b/src/main/config.ts
index 1875ce9..bd61a44 100644
--- a/src/main/config.ts
+++ b/src/main/config.ts
@@ -3,8 +3,6 @@
export const SMOKE_FLAG = '--smoke';
export const SMOKE_DB_ENV = 'WORKFLOW_SMOKE_DB';
-// D-37: a v1.2.1 timerState the smoke writes into its own temp profile's localStorage, so the next launch imports it.
-export const SMOKE_SEED_TIMER_STATE_ENV = 'WORKFLOW_SMOKE_SEED_TIMER_STATE';
// Set by `electron-vite dev` to the renderer dev server's address.
export const RENDERER_URL_ENV = 'ELECTRON_RENDERER_URL';
/*
@@ -15,24 +13,6 @@ export const RENDERER_MARKER_TEXT = 'Daily Target';
// SPA-01: the second route the smoke visits, and the heading it must find there. Reaching it changes no document.
export const RENDERER_SECOND_ROUTE_HASH = '#/settings';
export const RENDERER_SECOND_ROUTE_TEXT = 'Settings';
-/*
- * Criterion 1 / S2: the brief's XSS payload verbatim. v1.2.1 built the row with innerHTML and hung an onclick= off
- * the name, escaping the apostrophe and nothing else, so this exact string ran.
- */
-export const RENDERER_COMPANIES_ROUTE_HASH = '#/companies';
-export const SMOKE_XSS_COMPANY_NAME = '';
-/*
- * Criterion 4: v1.2.1 reached the delete-all-data button with document.querySelector('.mt-8.mb-8 button')
- * (legacy/pages/settings.html:659), so a spacing tweak detached it. The smoke probes for this identifier, and for
- * that selector finding nothing.
- */
-export const RENDERER_DESTRUCTIVE_TESTID = 'reset-all-data';
-export const RENDERER_LEGACY_DESTRUCTIVE_SELECTOR = '.mt-8.mb-8 button';
-// Written before the window loads, not after: the form seeds its fields once, from what the database held when it opened.
-export const SMOKE_SETTINGS_TARGET_SECONDS = 27_000;
-export const SMOKE_SETTINGS_TARGET_TEXT = '07:30';
-/** The four numbers the pomodoro section offers once it is enabled (features/settings/settings-view.ts). */
-export const SMOKE_SETTINGS_NUMBER_FIELDS = 4;
export const SMOKE_RENDER_TIMEOUT_MS = 20_000;
export const SMOKE_POLL_INTERVAL_MS = 100;
// If stdout never reports the write as flushed, exit anyway rather than hang.
@@ -43,26 +23,9 @@ export const SMOKE_EXIT_FALLBACK_MS = 3_000;
* DEFAULT_TIMEOUT_MS) so a stuck run names its own reason; tests/smoke-harness.test.ts holds the two in that order.
*/
export const SMOKE_WATCHDOG_MS = 60_000;
-// WR-01 smoke target: the .invalid TLD never resolves, so even a failed guard loads nothing remote.
-export const SMOKE_ESCAPE_URL = 'https://example.invalid/';
-export const SMOKE_NAVIGATION_TIMEOUT_MS = 5_000;
-// Long enough for at least one main-process tick to cross the bus, the preload and the page (IPC-06 smoke check).
-export const SMOKE_TICK_WAIT_MS = 1_500;
-/*
- * SPA-08/SPA-09: the icon must measure about this wide as a GLYPH. The same name rendered as literal text is several
- * times wider - the failure self-hosting only the woff2 produces. The max leaves room for hinting.
- */
-export const SMOKE_ICON_NAME = 'local_fire_department';
-export const SMOKE_ICON_FONT_SIZE_PX = 24;
-export const SMOKE_ICON_MAX_WIDTH_PX = 32;
-export const SMOKE_ICON_TEXT_MIN_WIDTH_PX = 100;
-/** The two families that must come from inside the bundle, spelled as their @font-face declares them (S4). */
-export const SMOKE_BUNDLED_FONTS = Object.freeze(['Material Symbols Outlined', 'Inter Variable']);
/** The sound main asks the renderer to play in the smoke; it must resolve to a file inside the app (SPA-10). */
export const SMOKE_SOUND_ID = 'goalReached';
-// flushStorageData is asynchronous; app.exit must not race it (D-37 seed mode).
-export const SMOKE_STORAGE_FLUSH_MS = 1_000;
export const DEVELOPMENT_USER_DATA_SUFFIX = '-dev';
export const USER_DATA_DIR_SWITCH = 'user-data-dir';
@@ -149,7 +112,6 @@ export const UPDATE_CHECK_DISABLED_ENV = 'WORKFLOW_NO_UPDATE_CHECK';
export interface MainConfig {
readonly smoke: boolean;
readonly smokeDbPath: string | undefined;
- readonly smokeSeedTimerState: string | undefined;
readonly rendererDevUrl: string | undefined;
/** REPO-06: false when the user has set UPDATE_CHECK_DISABLED_ENV, and nothing is ever sent. */
readonly updateCheck: boolean;
@@ -162,7 +124,6 @@ export function parseMainConfig(env: Readonly
return Object.freeze({
smoke: argv.includes(SMOKE_FLAG),
smokeDbPath: env[SMOKE_DB_ENV],
- smokeSeedTimerState: env[SMOKE_SEED_TIMER_STATE_ENV],
rendererDevUrl: typeof devUrl === 'string' && devUrl !== '' ? devUrl : undefined,
updateCheck: optedOut === undefined || optedOut === ''
});
diff --git a/src/main/container.ts b/src/main/container.ts
index 7e52bb0..1b169e5 100644
--- a/src/main/container.ts
+++ b/src/main/container.ts
@@ -25,7 +25,7 @@ import { createStatsService } from './services/stats.service';
import type { StatsService } from './services/stats.service';
import { createTimerService } from './services/timer.service';
import type { CountedDay, TimerService, TimerStateStore } from './services/timer.service';
-import type { PomodoroSnapshot, TimerSnapshot, WorkSession } from '@shared/types';
+import type { PomodoroSnapshot, SoundId, TimerSnapshot, WorkSession } from '@shared/types';
import type {
CompaniesRepository, PomodoroRepository, RepositoryOptions, SessionsRepository, SettingsRepository, SkippedRowReport
} from '../lib/db';
@@ -231,6 +231,19 @@ export function createContainer(input: ContainerInput): AppContainer {
announceGoalIfReached(snapshot.mode === 'pomodoro' ? null : counted);
}
+ /*
+ * The one door every sound goes through, so the Sound switch is honoured in one place: off means nothing is
+ * sent at all, and on carries the user's chosen file. Read at the moment the sound is due rather than cached,
+ * so a change saved a second ago applies to the very next one. Notifications are a separate switch and are not
+ * affected - a silent notification is still a notification.
+ */
+ function playSound(sound: SoundId): void {
+ const { soundEnabled, notificationSound } = settings.get();
+ if (soundEnabled) {
+ ports.sound.play(sound, notificationSound);
+ }
+ }
+
/*
* The day's own total: seconds already written to a row plus the part of the running accumulation counted on that
* same local day (CORE-08, B7). Scoping the second term is CR-01 - stats.today() is day-scoped and the timer's
@@ -267,7 +280,7 @@ export function createContainer(input: ContainerInput): AppContainer {
// Both, and from main: v1.2.1 raised the notification from a renderer that is not running while the
// window is hidden in the tray, which is the half of B1 the user never saw at all.
ports.notifier.notify(GOAL_NOTIFICATION);
- ports.sound.play('goalReached');
+ playSound('goalReached');
}
} catch (error) {
// A day that cannot be measured must never cost the caller its write, or the clock its tick.
@@ -320,7 +333,7 @@ export function createContainer(input: ContainerInput): AppContainer {
// not reported as an interval that could not be recorded.
try {
ports.notifier.notify(notificationForCompletion(completion.interval));
- ports.sound.play('pomodoroCompleted');
+ playSound('pomodoroCompleted');
} catch (error) {
log('pomodoro: the completed interval was recorded but could not be announced - ' + describeError(error));
}
diff --git a/src/main/ports/sound.port.ts b/src/main/ports/sound.port.ts
index 89ef248..fdc5c2a 100644
--- a/src/main/ports/sound.port.ts
+++ b/src/main/ports/sound.port.ts
@@ -1,8 +1,8 @@
// Playing audio needs a document, so only the renderer can do it. The decision is main's; this is how it asks.
-import type { SoundId } from '@shared/types';
+import type { SoundChoice, SoundId } from '@shared/types';
export interface SoundPort {
/** Best effort: with no window on screen there is nothing to play on, and that is not a failure. */
- play(sound: SoundId): void;
+ play(sound: SoundId, choice: SoundChoice): void;
}
diff --git a/src/main/services/settings.service.ts b/src/main/services/settings.service.ts
index ee2cad7..db64be0 100644
--- a/src/main/services/settings.service.ts
+++ b/src/main/services/settings.service.ts
@@ -2,7 +2,7 @@
// once; the ten-key Settings shape and the v1.2.1 key strings stay where they already live.
import { ServiceError } from './service-errors';
-import { SETTINGS_BOUNDS } from '@shared/constants/settings';
+import { SETTINGS_BOUNDS, SOUND_CHOICE_IDS } from '@shared/constants/settings';
import type { BooleanSettingKey, NumericSettingKey, SettingBound } from '@shared/constants/settings';
import type { Settings } from '@shared/types';
@@ -36,7 +36,7 @@ type NumericKey = NumericSettingKey;
type BooleanKey = BooleanSettingKey;
export const BOOLEAN_SETTING_KEYS: readonly BooleanKey[] = Object.freeze([
- 'goalNotification', 'excludeWeekendsFromStreak', 'pomodoroEnabled', 'pomodoroAutoStartBreaks',
+ 'goalNotification', 'soundEnabled', 'excludeWeekendsFromStreak', 'pomodoroEnabled', 'pomodoroAutoStartBreaks',
'pomodoroAutoStartWork'
]);
@@ -44,7 +44,7 @@ const NUMERIC_SETTING_KEYS = Object.keys(SETTINGS_BOUNDS) as NumericKey[];
/** Every key the domain has, so an unknown one is refused rather than written to a row nobody reads. */
export const SETTING_KEYS: readonly (keyof Settings)[] =
- Object.freeze([...NUMERIC_SETTING_KEYS, ...BOOLEAN_SETTING_KEYS]);
+ Object.freeze([...NUMERIC_SETTING_KEYS, ...BOOLEAN_SETTING_KEYS, 'notificationSound']);
/** A refused write, named by the key that caused it. Durations and targets are not user data, so the value is said. */
export class SettingsValidationError extends ServiceError {
@@ -72,7 +72,7 @@ export function validateSettingsPatch(patch: Partial): Partial> = {};
+ const checked: Partial> = {};
for (const key of BOOLEAN_SETTING_KEYS) {
const value: unknown = patch[key];
@@ -83,6 +83,16 @@ export function validateSettingsPatch(patch: Partial): Partial id === sound)) {
+ throw new SettingsValidationError('notificationSound', 'settings: notificationSound must be one of ' +
+ SOUND_CHOICE_IDS.join(', ') + ', got ' + describe(sound) + '.');
+ }
+ checked.notificationSound = sound as Settings['notificationSound'];
+ }
+
for (const key of NUMERIC_SETTING_KEYS) {
const value: unknown = patch[key];
if (value === undefined) continue;
diff --git a/src/main/smoke.ts b/src/main/smoke.ts
index b897b82..f366e7d 100644
--- a/src/main/smoke.ts
+++ b/src/main/smoke.ts
@@ -1,40 +1,27 @@
-// The --smoke launch for tools/smoke-packaged.mjs: the real D-30 bootstrap, reporting on stdout instead of a modal
-// (Pitfall 6) and recording exits rather than taking them (D-37). The database layer arrives as runSmoke's argument.
+// The --smoke launch for tools/smoke-packaged.mjs: the real database bootstrap, reporting on stdout instead of a modal
+// and recording exits rather than taking them. Four things are proved, and only those: the database opens (or is
+// refused) the way startup decides, the window renders Home and Settings, main can ask the renderer for a sound, and
+// the packaged app can build its services over the database it opened.
-import { app, BrowserWindow, nativeImage, screen, session } from 'electron';
+import { app, BrowserWindow } from 'electron';
import { isAbsolute, join } from 'node:path';
import fs from 'node:fs';
-import { API_BRIDGE_KEY, SHELL_BRIDGE_KEY } from '@shared/constants/bridge';
-import { IPC_CHANNELS } from '@shared/ipc/channels';
import type * as DatabaseLayerModule from '../lib/db';
import {
- DATABASE_RENAME_RELEASED, EXIT_CODES, PRODUCTION_DATA_DOOR_OPEN, RENDERER_COMPANIES_ROUTE_HASH,
- RENDERER_DESTRUCTIVE_TESTID, RENDERER_LEGACY_DESTRUCTIVE_SELECTOR,
- RENDERER_MARKER_TEXT, RENDERER_SECOND_ROUTE_HASH,
- RENDERER_SECOND_ROUTE_TEXT, SMOKE_BUNDLED_FONTS, SMOKE_DB_ENV, SMOKE_ESCAPE_URL, SMOKE_EXIT_FALLBACK_MS,
- SMOKE_ICON_FONT_SIZE_PX, SMOKE_ICON_NAME, SMOKE_NAVIGATION_TIMEOUT_MS, SMOKE_POLL_INTERVAL_MS,
- SMOKE_RENDER_TIMEOUT_MS, SMOKE_SETTINGS_NUMBER_FIELDS, SMOKE_SETTINGS_TARGET_SECONDS,
- SMOKE_SETTINGS_TARGET_TEXT, SMOKE_SOUND_ID, SMOKE_STORAGE_FLUSH_MS, SMOKE_TICK_WAIT_MS, SMOKE_WATCHDOG_MS,
- SMOKE_XSS_COMPANY_NAME, mainConfig
+ DATABASE_RENAME_RELEASED, EXIT_CODES, PRODUCTION_DATA_DOOR_OPEN, RENDERER_MARKER_TEXT,
+ RENDERER_SECOND_ROUTE_HASH, RENDERER_SECOND_ROUTE_TEXT, SMOKE_DB_ENV, SMOKE_EXIT_FALLBACK_MS,
+ SMOKE_POLL_INTERVAL_MS, SMOKE_RENDER_TIMEOUT_MS, SMOKE_SOUND_ID, SMOKE_WATCHDOG_MS, mainConfig
} from './config';
-import { NOTIFICATION_ICON_PATH } from './adapters/electron-notifier.adapter';
-import { appUserModelIdApplied } from './app-identity';
import { clearActiveContainer, createContainer, setActiveContainer } from './container';
import type { AppContainer } from './container';
import { registerIpcHandlers, removeIpcHandlers } from './ipc';
-import { decideWindowClose, markQuitting } from './quit';
-import { RESET_POSITION_LABEL, appTrayMenu, createAppTray, destroyAppTray, hasAppTray } from './tray';
import { startDatabase } from './database-startup';
-import type { LegacyImportStatus, StartedDatabase } from './database-startup';
+import type { StartedDatabase } from './database-startup';
import { describeError } from './errors';
import { createHideNoticeStore } from './lifecycle';
-import { LEGACY_STORAGE_PAGE, readLegacyStorage } from './legacy-storage';
+import { readLegacyStorage } from './legacy-storage';
import { isSameOrInside } from './userdata-path';
-import {
- createMainWindow, loadRenderer, registerHideNoticeStore, registerWindowBoundsStore, resetWindowPosition,
- shellControls
-} from './window';
-import { isOnAnyDisplay } from './window-bounds';
+import { createMainWindow, loadRenderer, registerHideNoticeStore, shellControls } from './window';
export type SmokeDatabase = typeof DatabaseLayerModule;
@@ -45,41 +32,14 @@ interface SmokeOutcome {
code?: number;
}
-function describeLegacyImport(status: LegacyImportStatus): string {
- return 'failed' in status
- ? 'failed'
- : 'timerState=' + status.timerState + ' goalDate=' + status.goalDate;
-}
-
-/*
- * The notification icon judged as bytes, not as a path that looks right. `?asset` resolves against the build output,
- * so dev and packaged can differ, and an icon that silently fails to load draws nothing while every source-level
- * assertion passes. nativeImage decoding it is what proves the file is there and is an image.
- */
-function describeNotificationIcon(): string[] {
- try {
- const image = nativeImage.createFromPath(NOTIFICATION_ICON_PATH);
- const size = image.getSize();
- return [
- 'SMOKE_NOTIFY_ICON=' + NOTIFICATION_ICON_PATH,
- 'SMOKE_NOTIFY_ICON_BYTES=' + String(fs.existsSync(NOTIFICATION_ICON_PATH)
- ? fs.statSync(NOTIFICATION_ICON_PATH).size : 0),
- 'SMOKE_NOTIFY_ICON_SIZE=' + String(size.width) + 'x' + String(size.height),
- 'SMOKE_NOTIFY_ICON_EMPTY=' + String(image.isEmpty())
- ];
- } catch (error) {
- return ['SMOKE_NOTIFY_ICON_ERROR=' + describeError(error)];
- }
-}
-
let watchdog: NodeJS.Timeout | undefined;
// The report so far, so a run killed by the watchdog still says how far it got rather than only that it stopped.
let reportedSoFar: readonly string[] = [];
/*
- * A hung smoke launch is a process with no window and no tray, which only Task Manager can end. It ends itself
- * instead, and it ends FAILING: a watchdog that exited 0 would hide a real failure. unref'd, so it can never itself
- * be the reason the process is still alive.
+ * A hung smoke launch is a process with no window and no tray, which nothing but a task manager can end. It ends
+ * itself instead, and it ends FAILING: a watchdog that exited 0 would hide a real failure. unref'd, so it can never
+ * itself be the reason the process is still alive.
*/
function armWatchdog(lines: readonly string[]): void {
reportedSoFar = lines;
@@ -101,18 +61,14 @@ export async function runSmoke(layer: SmokeDatabase): Promise {
const productionUserData = join(appData, appName);
// Reported first, so a failure below still tells the harness what the app resolved.
- const lines = [
- 'SMOKE_APP_NAME=' + appName,
- 'SMOKE_APP_DATA=' + appData,
- 'SMOKE_USER_DATA=' + userData,
- // The identity this process really applied, and the icon the notifier really passes. Both read from the
- // modules that own them rather than re-derived here, so a mismatch is a failure rather than a duplicate.
- 'SMOKE_APP_USER_MODEL_ID=' + String(appUserModelIdApplied() ?? ''),
- ...describeNotificationIcon()
- ];
+ const lines = ['SMOKE_APP_NAME=' + appName, 'SMOKE_USER_DATA=' + userData];
armWatchdog(lines);
const fail = (reason: string): SmokeOutcome => ({ ok: false, lines: [...lines, 'SMOKE_FAIL=' + reason] });
+ /*
+ * The safety half: a smoke launch must never be able to touch a real database. It opens only a path it was handed,
+ * that path must be new, and neither it nor userData may sit inside the production directory.
+ */
const dbPath = mainConfig.smokeDbPath;
if (dbPath === undefined || dbPath.trim() === '') {
return fail(SMOKE_DB_ENV + ' is not set; smoke mode opens only an injected database path');
@@ -131,24 +87,8 @@ export async function runSmoke(layer: SmokeDatabase): Promise {
return fail('refusing to open ' + dbPath + ', which already exists: smoke mode only ever ' +
'creates a new database, so it can never touch an existing one');
}
- lines.push('SMOKE_DB=' + dbPath);
- // SPA-08: networking off before the first document loads, so nothing below could have been fetched remotely.
- const remoteRequests = goOffline(lines);
-
- // D-37: seed this temp profile's localStorage and stop. Only reachable under --smoke, and only after the
- // refusals above have proved userData is not the production directory (T-04-48).
- const seed = mainConfig.smokeSeedTimerState;
- if (seed !== undefined) {
- const seedFailure = await seedLegacyTimerState(seed);
- if (seedFailure !== null) {
- return fail(seedFailure);
- }
- lines.push('SMOKE_SEEDED=timerState', 'SMOKE_OK');
- return { ok: true, lines };
- }
-
- // D-37: the real bootstrap, over ports that print instead of showing a dialog or exiting.
+ // The real bootstrap, over ports that print instead of showing a dialog or exiting.
let exitCode: number | undefined;
let win: BrowserWindow | undefined;
const started = await startDatabase(
@@ -169,17 +109,11 @@ export async function runSmoke(layer: SmokeDatabase): Promise {
log: (line) => { lines.push('SMOKE_DB_MIGRATION=' + line); },
readLegacyStorage: () => readLegacyStorage(),
openMainWindow: (connection) => {
- // The same store the app registers, over the injected database, so the claim below is the real one.
registerHideNoticeStore(createHideNoticeStore(layer, connection));
win = createMainWindow({ show: false });
- /*
- * The smoke plays the bundled notification to prove the file is in the bundle and decodes; every run
- * used to come out of the developer's speakers. Muted here rather than in the sound path - what
- * checkSound reads is decode state, which muting does not touch.
- */
+ // Muted, so a smoke run never comes out of the speakers; the sound check reads decode state.
win.webContents.setAudioMuted(true);
lines.push('SMOKE_WINDOW_CREATED=true');
- lines.push('SMOKE_AUDIO_MUTED=' + String(win.webContents.isAudioMuted()));
}
}
);
@@ -188,73 +122,35 @@ export async function runSmoke(layer: SmokeDatabase): Promise {
}
lines.push('SMOKE_DB_CLASS=' + started.report.dbClass);
lines.push('SMOKE_DB_VERSION=' + String(started.report.toVersion));
- lines.push('SMOKE_TIMER_IMPORT=' + describeLegacyImport(started.legacyImport));
let container: AppContainer | undefined;
try {
- // BUILD-06: the composition root, the adapters and one Drizzle read, inside the packaged app. Nothing else
- // here exercises the bundled drizzle-orm chunk, so a bundling fault in it would otherwise wait for Phase 7.
+ // The composition root and one read through the bundled drizzle chunk, inside the packaged app.
const built = buildContainer(layer, started.db, lines);
if (typeof built === 'string') {
return fail(built);
}
container = built;
- // IPC-01: the real registration, over the real container, so the page below calls the app rather than a stub.
setActiveContainer(container);
registerIpcHandlers({
context: () => ({ ...built.services, shell: shellControls }),
log: (line) => lines.push('SMOKE_IPC_LOG=' + line)
});
-
- // BUILD-06: the injected, brand-new database, through the same driver the bootstrap just used.
- const databaseFailure = checkInjectedDatabase(layer, dbPath, lines);
- if (databaseFailure !== null) {
- return fail(databaseFailure);
- }
if (win === undefined) {
return fail('startup returned a database without ever opening a main window');
}
- // Before the first render, because the Settings form seeds its fields once from what the database held when
- // it opened. Restored below, so the launch leaves the injected database as it found it.
- const settingsBefore = container.services.settings.get();
- container.services.settings.update({
- dailyTargetSeconds: SMOKE_SETTINGS_TARGET_SECONDS,
- pomodoroEnabled: true
- });
- const rendererFailure = await checkRenderer(win, lines);
- if (rendererFailure !== null) {
- return fail(rendererFailure);
- }
- const bridgeFailure = await checkBridge(win, container, lines);
- if (bridgeFailure !== null) {
- return fail(bridgeFailure);
+ const screensFailure = await checkScreens(win, lines);
+ if (screensFailure !== null) {
+ return fail(screensFailure);
}
const soundFailure = await checkSound(win, container, lines);
if (soundFailure !== null) {
return fail(soundFailure);
}
- const escapingFailure = await checkEscaping(win, container, lines);
- if (escapingFailure !== null) {
- return fail(escapingFailure);
- }
- const settingsFailure = await checkSettingsScreen(win, lines);
- container.services.settings.update({
- dailyTargetSeconds: settingsBefore.dailyTargetSeconds,
- pomodoroEnabled: settingsBefore.pomodoroEnabled
- });
- if (settingsFailure !== null) {
- return fail(settingsFailure);
- }
- lines.push('SMOKE_OFFLINE_REQUESTS=' + String(remoteRequests()));
- // Last, because it ends by telling the app it is quitting - which is the state being proved.
- const shellFailure = await checkShell(lines);
- if (shellFailure !== null) {
- return fail(shellFailure);
- }
} finally {
removeIpcHandlers();
clearActiveContainer();
- // D-32: before the window goes, so the -wal is folded back in even if a check threw.
+ // Before the window goes, so the -wal is folded back in even if a check threw.
container?.dispose();
started.close();
win?.destroy();
@@ -264,29 +160,6 @@ export async function runSmoke(layer: SmokeDatabase): Promise {
return { ok: true, lines };
}
-// D-37: written through the page the extractor reads, so the seed lands in the same file:// origin. Sandboxed and
-// offscreen, like the extractor: the page gets no capability beyond its own origin's storage.
-async function seedLegacyTimerState(raw: string): Promise {
- const win = new BrowserWindow({
- show: false,
- webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, offscreen: true }
- });
- try {
- await win.loadFile(LEGACY_STORAGE_PAGE);
- await win.webContents.executeJavaScript(
- 'localStorage.setItem("timerState", ' + JSON.stringify(raw) + '); true'
- );
- // app.exit skips the usual teardown, so the value has to be on disk before this launch ends (Pitfall 5).
- session.defaultSession.flushStorageData();
- await new Promise((done) => setTimeout(done, SMOKE_STORAGE_FLUSH_MS));
- } catch (error) {
- return 'seed: ' + describeError(error);
- } finally {
- win.destroy();
- }
- return null;
-}
-
/** The caller disposes it. */
function buildContainer(
layer: SmokeDatabase,
@@ -299,98 +172,37 @@ function buildContainer(
connection,
log: (line) => lines.push('SMOKE_CONTAINER_LOG=' + line)
});
- lines.push('SMOKE_CONTAINER_PORTS=' + Object.keys(container.ports).sort().join(','));
- lines.push('SMOKE_CONTAINER_SERVICES=' + Object.keys(container.services).sort().join(','));
- lines.push('SMOKE_CONTAINER_COMPANIES=' + String(container.repositories.companies.list().length));
lines.push('SMOKE_CONTAINER_TARGET=' + String(container.repositories.settings.get().dailyTargetSeconds));
- const timer = container.services.timer.snapshot();
- lines.push('SMOKE_CONTAINER_TIMER=' + [
- timer.status, timer.mode, String(timer.elapsedSeconds), String(timer.restoredFromPreviousLaunch)
- ].join('/'));
return container;
} catch (error) {
return 'container: ' + describeError(error);
}
}
-/*
- * Criteria 6, 7 and 10 inside the packaged app: the page calls through the generated bridge, a malformed payload is
- * refused before any service runs, a main-process tick reaches a subscription, and the disposer it returned stops
- * the next one.
- */
-async function checkBridge(win: BrowserWindow, container: AppContainer, lines: string[]): Promise {
+/** Home renders, and a route change reaches Settings without loading a new document. */
+async function checkScreens(win: BrowserWindow, lines: string[]): Promise {
try {
- const opened: unknown = await win.webContents.executeJavaScript(SUBSCRIBE_SCRIPT);
- const surface = asRecord(opened);
- lines.push('SMOKE_BRIDGE_CHANNELS=' + text(surface.channels));
- lines.push('SMOKE_BRIDGE_CALL=' + text(surface.call));
- lines.push('SMOKE_BRIDGE_REFUSAL=' + text(surface.refusal));
- lines.push('SMOKE_BRIDGE_DISPOSER=' + text(surface.disposer));
-
- if (surface.channels !== IPC_CHANNELS.length) {
- return 'the bridge exposed ' + text(surface.channels) + ' channels, expected ' +
- String(IPC_CHANNELS.length);
- }
- if (surface.call !== 'ok') {
- return 'companies:list through the bridge answered ' + text(surface.call);
- }
- if (surface.refusal !== 'INVALID_INPUT') {
- return 'a malformed timer:setMode was answered with ' + text(surface.refusal);
- }
- if (surface.disposer !== 'function') {
- return 'a subscription returned ' + text(surface.disposer) + ' instead of a disposer';
- }
-
- // One second of the real clock, delivered by the real bus to the real preload subscription.
- container.services.timer.start();
- await new Promise((done) => setTimeout(done, SMOKE_TICK_WAIT_MS));
- container.services.timer.pause();
- const delivered = asRecord(await win.webContents.executeJavaScript(READ_TICKS_SCRIPT));
- lines.push('SMOKE_BRIDGE_TICKS=' + text(delivered.ticks));
- lines.push('SMOKE_BRIDGE_TICK_KEYS=' + text(delivered.keys));
- if (typeof delivered.ticks !== 'number' || delivered.ticks < 1) {
- return 'no timer:tick reached the page in ' + String(SMOKE_TICK_WAIT_MS) + ' ms';
- }
- if (delivered.keys !== 'elapsedSeconds,mode,persistFailing,restoredFromPreviousLaunch,status') {
- return 'a tick arrived shaped as ' + text(delivered.keys);
- }
-
- // Criterion 10: after the disposer, the next tick must reach nobody.
- await win.webContents.executeJavaScript(DISPOSE_SCRIPT);
- container.services.timer.start();
- await new Promise((done) => setTimeout(done, SMOKE_TICK_WAIT_MS));
- container.services.timer.pause();
- const after = asRecord(await win.webContents.executeJavaScript(READ_TICKS_SCRIPT));
- lines.push('SMOKE_BRIDGE_TICKS_AFTER_DISPOSE=' + text(after.ticks));
- if (after.ticks !== delivered.ticks) {
- return 'a disposed subscription still received ' +
- String(Number(after.ticks) - Number(delivered.ticks)) + ' events';
+ await loadRenderer(win);
+ const home = await waitForRendererText(win, RENDERER_MARKER_TEXT);
+ lines.push('SMOKE_HOME_RENDERED=' + String(home.includes(RENDERER_MARKER_TEXT)));
+ if (!home.includes(RENDERER_MARKER_TEXT)) {
+ return 'the renderer never rendered "' + RENDERER_MARKER_TEXT + '"';
}
- container.services.timer.reset();
- const notice = asRecord(await win.webContents.executeJavaScript(CLAIM_HIDE_NOTICE_SCRIPT));
- lines.push('SMOKE_HIDE_NOTICE_FIRST=' + text(notice.first));
- lines.push('SMOKE_HIDE_NOTICE_SECOND=' + text(notice.second));
- if (notice.first !== 'true' || notice.second !== 'false') {
- return 'the hide notice claimed ' + text(notice.first) + ' then ' + text(notice.second) +
- '; it must be due exactly once';
+ await win.webContents.executeJavaScript(
+ 'window.location.hash = ' + JSON.stringify(RENDERER_SECOND_ROUTE_HASH) + '; true'
+ );
+ const settings = await waitForRendererText(win, RENDERER_SECOND_ROUTE_TEXT);
+ lines.push('SMOKE_SETTINGS_RENDERED=' + String(settings.includes(RENDERER_SECOND_ROUTE_TEXT)));
+ if (!settings.includes(RENDERER_SECOND_ROUTE_TEXT)) {
+ return 'the ' + RENDERER_SECOND_ROUTE_HASH + ' route never rendered "' + RENDERER_SECOND_ROUTE_TEXT + '"';
}
} catch (error) {
- return 'bridge: ' + describeError(error);
+ return 'renderer: ' + describeError(error);
}
return null;
}
-/** Resolves when the window has actually gone, or once the wait is over - a window that stayed is the finding. */
-const settled = (win: BrowserWindow): Promise =>
- new Promise((done) => {
- const timer = setTimeout(() => done(), SMOKE_POLL_INTERVAL_MS * 5);
- win.once('closed', () => {
- clearTimeout(timer);
- done();
- });
- });
-
const asRecord = (value: unknown): Record =>
typeof value === 'object' && value !== null ? value as Record : {};
@@ -400,49 +212,9 @@ const text = (value: unknown): string =>
: typeof value === 'number' || typeof value === 'boolean' ? String(value)
: JSON.stringify(value) ?? 'undefined';
-// Written as strings because they run in the page, not here. Each returns a plain object, so it crosses as JSON.
-const SUBSCRIBE_SCRIPT = `(async () => {
- const api = window.${API_BRIDGE_KEY};
- if (typeof api !== 'object' || api === null) return { channels: -1 };
- const list = await api['companies:list']();
- const refused = await api['timer:setMode']({ mode: 'sideways' });
- window.__smokeBridge = { ticks: [], last: null };
- const dispose = api.on['timer:tick']((payload) => {
- window.__smokeBridge.ticks.push(1);
- window.__smokeBridge.last = payload;
- });
- window.__smokeBridge.dispose = dispose;
- return {
- channels: Object.keys(api).filter((key) => key !== 'on').length,
- call: list && list.ok === true && Array.isArray(list.data) ? 'ok' : JSON.stringify(list),
- refusal: refused && refused.ok === false ? refused.error.code : 'accepted',
- disposer: typeof dispose
- };
-})()`;
-
-/*
- * Owner decision 2026-09-13, end to end: the hide notice is claimed through the bridge, answered by main against the
- * app_state row, and the second ask gets nothing. Two calls in one script, so nothing between them can explain a
- * false second answer.
- */
-const CLAIM_HIDE_NOTICE_SCRIPT = `(async () => {
- const api = window.${API_BRIDGE_KEY};
- const first = await api['window:claimHideNotice']();
- const second = await api['window:claimHideNotice']();
- return {
- first: first && first.ok === true ? String(first.data.due) : JSON.stringify(first),
- second: second && second.ok === true ? String(second.data.due) : JSON.stringify(second)
- };
-})()`;
-
-const READ_TICKS_SCRIPT = `({
- ticks: window.__smokeBridge.ticks.length,
- keys: window.__smokeBridge.last === null ? '' : Object.keys(window.__smokeBridge.last).sort().join(',')
-})`;
-
-const DISPOSE_SCRIPT = 'window.__smokeBridge.dispose(); window.__smokeBridge.dispose(); true';
-
-// SPA-10: which src the renderer actually asked to play, and whether the file behind it decoded.
+// Written as strings because they run in the page, not here. HTMLMediaElement.play is wrapped before the event is
+// raised, because the element the renderer creates is private to it - the wrapper is how the page reports which src
+// was asked for and whether the bytes decoded.
const WATCH_AUDIO_SCRIPT = `(() => {
window.__smokeAudio = { count: 0, last: null, errors: [] };
const realPlay = HTMLMediaElement.prototype.play;
@@ -470,334 +242,27 @@ const READ_AUDIO_SCRIPT = `(() => {
};
})()`;
-/** Whether a rectangle lands usefully on one of the displays this machine really has right now. */
-function landsOnARealDisplay(bounds: Electron.Rectangle): boolean {
- return isOnAnyDisplay(bounds, screen.getAllDisplays().map((display) => ({ workArea: display.workArea })));
-}
-
-const describeBounds = (b: Electron.Rectangle): string =>
- String(b.x) + ',' + String(b.y) + ' ' + String(b.width) + 'x' + String(b.height);
-
-/*
- * Phase 10 criterion 5, first half, against the REAL display list. Phase 5 proved chooseWindowBounds' arithmetic over
- * displays a test invented; what it could not do was unplug a monitor. A saved rectangle 30,000 pixels off the origin
- * lands on no display that could exist, so the fallback is exercised on whatever hardware runs the smoke - and the
- * window Electron opens is measured against screen.getAllDisplays(), which nothing here fakes.
- */
-function checkOffScreenRecovery(lines: string[]): void {
- const OFF_SCREEN = { x: -30_000, y: -30_000, width: 500, height: 700 };
- const written: Electron.Rectangle[] = [];
- registerWindowBoundsStore({
- read: () => OFF_SCREEN,
- write: (bounds) => { written.push(bounds); }
- });
- const probe = createMainWindow({ show: false });
- try {
- const opened = probe.getBounds();
- lines.push('SMOKE_OFFSCREEN_SAVED=' + describeBounds(OFF_SCREEN));
- lines.push('SMOKE_OFFSCREEN_OPENED=' + describeBounds(opened));
- lines.push('SMOKE_OFFSCREEN_ON_DISPLAY=' + String(landsOnARealDisplay(opened)));
- // The control: the saved rectangle must be one this machine really would refuse, or the claim is vacuous.
- lines.push('SMOKE_OFFSCREEN_SAVED_ON_DISPLAY=' + String(landsOnARealDisplay(OFF_SCREEN)));
- lines.push('SMOKE_DISPLAYS=' + String(screen.getAllDisplays().length));
- } finally {
- probe.destroy();
- registerWindowBoundsStore(undefined);
- void written;
- }
-}
-
-/** The disabled line that states the installed version, read off the menu the tray is showing (REPO-06). */
-function versionItemLabel(): string | undefined {
- return appTrayMenu()?.items.find((entry) => entry.label?.startsWith('Workflow ') === true)?.label;
-}
-
-/*
- * The update item, which must be ABSENT in a smoke launch: no check has run, so an item offering an update the app
- * has no reason to believe in is the defect this probe exists to catch.
- */
-function updateItemLabel(): string | undefined {
- return appTrayMenu()?.items.find((entry) => entry.label?.startsWith('Update available') === true)?.label;
-}
-
-/*
- * Phase 10 criterion 5, second half: the tray item, invoked through the menu the tray is actually showing. The
- * window is pushed off-screen first - the state a user reaches by unplugging the monitor it was on - and then the
- * item's own click handler is run. What this does not prove is that Windows draws the menu and dispatches the
- * click; that is the same gap the titlebar's X has.
- */
-function checkTrayReset(lines: string[], resetCalls: () => number, aim: (win: BrowserWindow) => void): void {
- const menu = appTrayMenu();
- const item = menu?.items.find((entry) => entry.label === RESET_POSITION_LABEL);
- lines.push('SMOKE_TRAY_MENU=' + (menu?.items.map((entry) => entry.label || entry.type).join('|') ?? ''));
- if (item === undefined) {
- lines.push('SMOKE_TRAY_RESET=absent');
- return;
- }
- const written: Electron.Rectangle[] = [];
- registerWindowBoundsStore({ read: () => null, write: (bounds) => { written.push(bounds); } });
- const probe = createMainWindow({ show: false });
- aim(probe);
- try {
- probe.setBounds({ x: -30_000, y: -30_000, width: 500, height: 700 });
- const before = probe.getBounds();
- lines.push('SMOKE_TRAY_RESET_BEFORE=' + describeBounds(before));
- lines.push('SMOKE_TRAY_RESET_BEFORE_ON_DISPLAY=' + String(landsOnARealDisplay(before)));
- // Electron types MenuItem.click as a bare Function, so it is narrowed here rather than called through `any`.
- const invoke = item.click as () => void;
- invoke();
- const after = probe.getBounds();
- lines.push('SMOKE_TRAY_RESET_CALLS=' + String(resetCalls()));
- lines.push('SMOKE_TRAY_RESET_AFTER=' + describeBounds(after));
- lines.push('SMOKE_TRAY_RESET_AFTER_ON_DISPLAY=' + String(landsOnARealDisplay(after)));
- lines.push('SMOKE_TRAY_RESET_PERSISTED=' + String(written.length > 0));
- } finally {
- // resetWindowPosition shows and focuses, which is its job; the probe is put away again immediately.
- probe.hide();
- probe.destroy();
- registerWindowBoundsStore(undefined);
- }
-}
-
-/*
- * Criterion 8 in the packaged app: one tray icon however often it is asked for, a system-raised close - Alt+F4, a
- * session ending - that hides the window while the app runs, and a close that lets it go once the app is quitting.
- * The titlebar's X is a different path as of 2026-09-13: it asks app:quit, which the smoke cannot take without
- * ending itself, so what is proved is the consequence - that once quitting, nothing re-hides the window. The
- * quitting flag is set at the very end on purpose: nothing runs after it but the report.
- */
-async function checkShell(lines: string[]): Promise {
+/* Main asks for a sound; the renderer must play a file from inside the bundle, and it must decode. */
+async function checkSound(win: BrowserWindow, container: AppContainer, lines: string[]): Promise {
try {
- let resetCalls = 0;
+ await win.webContents.executeJavaScript(WATCH_AUDIO_SCRIPT);
+ container.ports.sound.play(SMOKE_SOUND_ID, 'classic');
/*
- * Which window the reset drives. In the app this is always mainWindows()[0]; here the smoke has already
- * opened one of its own, so the probe has to be named or the click would show that one instead.
+ * Poll rather than wait a fixed time: on a cold first launch the element can still be loading its metadata
+ * (duration 0, no error) when a fixed wait ends, which reads as an undecodable file. Done when it has a
+ * duration or an error, or when the render timeout passes and the last read is judged as it stands.
*/
- let resetTarget: BrowserWindow | undefined;
- // REPO-06: the smoke never opens a browser, but the action has to exist or the update item would have no
- // handler; the harness asserts the count stays 0, because nothing here may reach the network.
- let releaseOpens = 0;
- const actions = {
- show: () => undefined, hide: () => undefined, isVisible: () => false,
- resetPosition: () => { resetCalls += 1; resetWindowPosition(resetTarget); },
- openReleases: () => { releaseOpens += 1; }
- };
- const log = (line: string): void => { lines.push('SMOKE_TRAY_LOG=' + line); };
- const first = createAppTray(actions, app.getVersion(), log);
- const second = createAppTray(actions, app.getVersion(), log);
- lines.push('SMOKE_TRAY_CREATED=' + String(first !== undefined));
- lines.push('SMOKE_TRAY_SINGLETON=' + String(first === second));
- // REPO-06: what the packaged binary says it is, and what its tray draws for it. Read from the app rather
- // than from a constant, so the harness compares the built manifest with the menu the user would open.
- lines.push('SMOKE_APP_VERSION=' + app.getVersion());
- lines.push('SMOKE_TRAY_VERSION_ITEM=' + (versionItemLabel() ?? ''));
- lines.push('SMOKE_TRAY_UPDATE_ITEM=' + (updateItemLabel() ?? ''));
- lines.push('SMOKE_TRAY_RELEASE_OPENS=' + String(releaseOpens));
- checkOffScreenRecovery(lines);
- checkTrayReset(lines, () => resetCalls, (win) => { resetTarget = win; });
-
- // A window of its own, so the one the renderer checks ran against is left alone. The finally is IN-07: a
- // throw between the two closes used to leave it in `created`, where the renderer bus would still deliver to it.
- const probe = createMainWindow({ show: false });
- try {
- lines.push('SMOKE_SYSTEM_CLOSE_DECISION=' + decideWindowClose({ quitting: false, hasTray: hasAppTray() }));
- // WR-03: the same question in the state a failed new Tray() leaves the app in, where hiding would be a
- // process only Task Manager can end.
- lines.push('SMOKE_NO_TRAY_CLOSE_DECISION=' + decideWindowClose({ quitting: false, hasTray: false }));
- probe.close();
- // A close that is allowed through destroys the window on a later turn of the loop, so both readings wait.
- await settled(probe);
- lines.push('SMOKE_WINDOW_AFTER_SYSTEM_CLOSE=' + (probe.isDestroyed() ? 'destroyed' : 'alive'));
-
- markQuitting();
- lines.push('SMOKE_QUIT_DECISION=' + decideWindowClose({ quitting: true, hasTray: hasAppTray() }));
- if (!probe.isDestroyed()) {
- probe.close();
- await settled(probe);
- }
- lines.push('SMOKE_WINDOW_AFTER_QUIT=' + (probe.isDestroyed() ? 'destroyed' : 'alive'));
- } finally {
- if (!probe.isDestroyed()) {
- probe.destroy();
- }
- destroyAppTray();
- }
- } catch (error) {
- return 'shell: ' + describeError(error);
- }
- return null;
-}
-
-function checkInjectedDatabase(layer: SmokeDatabase, dbPath: string, lines: string[]): string | null {
- const { openDatabase, closeDatabase } = layer;
- try {
- const db = openDatabase(dbPath);
- try {
- lines.push('SMOKE_JOURNAL_MODE=' + String(db.pragma('journal_mode', { simple: true })));
- const token = 'smoke-' + String(process.pid) + '-' + String(Date.now());
- db.exec('CREATE TABLE smoke (value TEXT NOT NULL)');
- db.prepare('INSERT INTO smoke (value) VALUES (?)').run(token);
- const row = db.prepare<[], { value: string }>('SELECT value FROM smoke').get();
- if (row === undefined || row.value !== token) {
- return 'read back ' + JSON.stringify(row) + ', expected the row just written';
- }
- lines.push('SMOKE_ROW=' + row.value);
- } finally {
- closeDatabase(db);
- }
- } catch (error) {
- return 'database: ' + describeError(error);
- }
- return null;
-}
-
-/** The window is the one startup opened. */
-async function checkRenderer(win: BrowserWindow, lines: string[]): Promise {
- try {
- await loadRenderer(win);
- const rendered = await waitForRendererText(win, RENDERER_MARKER_TEXT);
- lines.push('SMOKE_RENDERER_TEXT=' + rendered);
- if (!rendered.includes(RENDERER_MARKER_TEXT)) {
- return 'the renderer never rendered "' + RENDERER_MARKER_TEXT + '"';
- }
- const bridgeVersion: unknown = await win.webContents.executeJavaScript(
- 'typeof window.' + SHELL_BRIDGE_KEY + ' === "object" ? String(window.' +
- SHELL_BRIDGE_KEY + '.version) : ""'
- );
- const version = typeof bridgeVersion === 'string' ? bridgeVersion : '';
- lines.push('SMOKE_PRELOAD_VERSION=' + version);
- if (version === '') {
- return 'the sandboxed preload did not expose window.' + SHELL_BRIDGE_KEY;
- }
-
- // WR-01: the guard installed on every web contents, exercised from inside the page.
- const containment = await probeContainment(win);
- lines.push('SMOKE_WINDOW_OPEN_BLOCKED=' + String(containment.windowOpenBlocked));
- lines.push('SMOKE_NAVIGATION_BLOCKED=' + String(containment.navigationBlocked));
- if (!containment.windowOpenBlocked) {
- return 'window.open from the page was not refused';
- }
- if (!containment.navigationBlocked) {
- return 'a navigation to ' + SMOKE_ESCAPE_URL + ' was not refused';
- }
-
- // SPA-01, last because it leaves the page on Home again and the checks above expect to find it there.
- const routing = await probeRouting(win, lines);
- if (routing !== null) {
- return routing;
- }
-
- const assets = await probeAssets(win, lines);
- if (assets !== null) {
- return assets;
- }
- } catch (error) {
- return 'renderer: ' + describeError(error);
- }
- return null;
-}
-
-/*
- * SPA-08: networking off for the whole launch, and every remote request counted. enableNetworkEmulation is the
- * switch; the webRequest filter is the witness, answering "did anything try to leave the machine" rather than "does
- * it look right online". file:// never matches the filter, so the app's own documents are untouched.
- */
-function goOffline(lines: string[]): () => number {
- let attempts = 0;
- const ses = session.defaultSession;
- ses.enableNetworkEmulation({ offline: true });
- ses.webRequest.onBeforeRequest(
- { urls: ['http://*/*', 'https://*/*', 'ws://*/*', 'wss://*/*'] },
- (details, callback) => {
- attempts += 1;
- lines.push('SMOKE_OFFLINE_REQUEST=' + details.url);
- callback({ cancel: true });
- }
- );
- return () => attempts;
-}
-
-/*
- * SPA-08/SPA-09 inside the packaged app, networking already off. A .material-symbols-outlined span holding an icon
- * name renders as ONE glyph roughly as wide as the font size; the same name without the icon font renders as the
- * literal words, several times wider - the C4 failure. A control span at the same size is measured beside it, so the
- * assertion is a comparison rather than a magic number.
- *
- * The FILL reading is the other half: the bottom nav marks its active tab with a Tailwind arbitrary-property utility,
- * and a utility whose CSS was never emitted leaves the computed value empty - which is how C3 shows up.
- */
-async function probeAssets(win: BrowserWindow, lines: string[]): Promise {
- try {
- const measured = asRecord(await win.webContents.executeJavaScript(assetProbeScript()));
- lines.push('SMOKE_ICON_WIDTH=' + text(measured.iconWidth));
- lines.push('SMOKE_ICON_TEXT_WIDTH=' + text(measured.textWidth));
- lines.push('SMOKE_ICON_FILL=' + text(measured.fill));
- lines.push('SMOKE_FONTS_LOADED=' + text(measured.fonts));
-
- const loaded = String(measured.fonts);
- const missing = SMOKE_BUNDLED_FONTS.filter((family) => !loaded.includes(family));
- if (missing.length > 0) {
- return 'these font families never loaded with networking off: ' + missing.join(', ');
- }
- if (typeof measured.iconWidth !== 'number' || typeof measured.textWidth !== 'number') {
- return 'the icon probe measured nothing: ' + text(measured);
+ const deadline = Date.now() + SMOKE_RENDER_TIMEOUT_MS;
+ let played = asRecord(await win.webContents.executeJavaScript(READ_AUDIO_SCRIPT));
+ while (Date.now() < deadline && !(Number(played.duration) > 0) && Number(played.errorCode) === 0) {
+ await new Promise((done) => setTimeout(done, SMOKE_POLL_INTERVAL_MS));
+ played = asRecord(await win.webContents.executeJavaScript(READ_AUDIO_SCRIPT));
}
- } catch (error) {
- return 'assets: ' + describeError(error);
- }
- return null;
-}
-
-/** The icon name and the size are configuration, so the script that uses them is built from the constants. */
-function assetProbeScript(): string {
- const size = String(SMOKE_ICON_FONT_SIZE_PX);
- const name = JSON.stringify(SMOKE_ICON_NAME);
- return [
- '(async () => {',
- ' await document.fonts.ready;',
- ' const host = document.createElement("div");',
- ' host.style.cssText = "position:absolute;left:-9999px;top:0";',
- ' const icon = document.createElement("span");',
- ' icon.className = "material-symbols-outlined";',
- ' icon.style.fontSize = "' + size + 'px";',
- ' icon.textContent = ' + name + ';',
- ' const control = document.createElement("span");',
- ' control.style.fontSize = "' + size + 'px";',
- ' control.textContent = ' + name + ';',
- ' host.appendChild(icon);',
- ' host.appendChild(control);',
- ' document.body.appendChild(host);',
- ' const active = document.querySelector(' + JSON.stringify('[aria-current="page"] .material-symbols-outlined') + ');',
- ' const measured = {',
- ' iconWidth: icon.offsetWidth,',
- ' textWidth: control.offsetWidth,',
- ' fill: active ? getComputedStyle(active).fontVariationSettings : "",',
- ' fonts: Array.from(document.fonts)',
- ' .filter((face) => face.status === "loaded").map((face) => face.family).join("|")',
- ' };',
- ' host.remove();',
- ' return measured;',
- '})()'
- ].join('\n');
-}
-
-/*
- * SPA-10 end to end: main decides a sound is due, the renderer plays one, and what it plays is a file inside the app.
- * HTMLMediaElement.play is wrapped before the event is raised, because the element the renderer creates is private to
- * it - the wrapper is how the page reports which src was asked for and whether the bytes decoded.
- */
-async function checkSound(win: BrowserWindow, container: AppContainer, lines: string[]): Promise {
- try {
- await win.webContents.executeJavaScript(WATCH_AUDIO_SCRIPT);
- container.ports.sound.play(SMOKE_SOUND_ID);
- await new Promise((done) => setTimeout(done, SMOKE_TICK_WAIT_MS));
- const played = asRecord(await win.webContents.executeJavaScript(READ_AUDIO_SCRIPT));
lines.push('SMOKE_SOUND_PLAYS=' + text(played.count));
lines.push('SMOKE_SOUND_SRC=' + text(played.src));
lines.push('SMOKE_SOUND_DURATION=' + text(played.duration));
lines.push('SMOKE_SOUND_ERROR=' + text(played.errorCode));
- lines.push('SMOKE_SOUND_REJECTIONS=' + text(played.errors));
if (typeof played.count !== 'number' || played.count < 1) {
return 'main asked for a sound and the renderer played none';
@@ -812,257 +277,21 @@ async function checkSound(win: BrowserWindow, container: AppContainer, lines: st
return null;
}
-/**
- * The text serialisation of a value, as a browser writes it into a text node: `&`, `<` and `>` and nothing else.
- * Quotes are left alone there, which is why this is not an attribute escape.
- */
-const asTextNode = (value: string): string =>
- value.replace(/&/g, '&').replace(//g, '>');
-
-/*
- * Criterion 1 / S2, in the packaged app: a company whose NAME is an image tag with an onerror on it, shown on the
- * screen that shows companies.
- *
- * This is the only place in the repository that can watch the escaping happen. There is no jsdom here and no test
- * renders a component, so every other assertion about S2 is about the source: that no renderer file reaches a
- * markup sink, and that lint would refuse one. Neither says what the browser did with the string.
- *
- * Three answers, because each alone can be true for the wrong reason. The name reads back as TEXT (so it was
- * rendered at all). No element with that src or that onerror exists inside #root (so it was not parsed as markup -
- * the app's own logo is an , which is why the filter is on the attributes rather than on the tag). And the
- * markup carries the ESCAPED form (so the first two are not both true because the row is missing).
- */
-async function checkEscaping(win: BrowserWindow, container: AppContainer, lines: string[]): Promise {
- // Removed again at the end: the legacy-migration case counts the companies this launch leaves behind, and a
- // probe that changes what another check measures is a probe that breaks it.
- let probeCompanyId: number | null = null;
- try {
- probeCompanyId = container.services.companies
- .create({ name: SMOKE_XSS_COMPANY_NAME, noteRequired: false }).id;
- await win.webContents.executeJavaScript(
- 'window.location.hash = ' + JSON.stringify(RENDERER_COMPANIES_ROUTE_HASH) + '; true'
- );
- const rendered = await waitForRendererText(win, SMOKE_XSS_COMPANY_NAME);
- const asText = rendered.includes(SMOKE_XSS_COMPANY_NAME);
- const probe = asRecord(await win.webContents.executeJavaScript(
- escapingProbeScript(asTextNode(SMOKE_XSS_COMPANY_NAME))
- ));
-
- // The name itself, so the harness checks the payload it thinks it is checking (D-24).
- lines.push('SMOKE_XSS_NAME=' + SMOKE_XSS_COMPANY_NAME);
- lines.push('SMOKE_XSS_AS_TEXT=' + String(asText));
- lines.push('SMOKE_XSS_ELEMENTS=' + text(probe.elements));
- lines.push('SMOKE_XSS_ESCAPED=' + text(probe.escaped));
-
- if (!asText) {
- return 'the company name never reached the screen as text, so nothing about escaping was observed';
- }
- if (probe.elements !== 0) {
- return 'the company name was parsed as markup: ' + text(probe.elements) + ' injected element(s) (S2)';
- }
- if (probe.escaped !== true) {
- return 'the page does not carry the escaped name, so the two answers above are about something else';
- }
-
- // Home again, so the rest of the launch finds the marker where it expects it.
- await win.webContents.executeJavaScript('window.location.hash = "#/"; true');
- await waitForRendererText(win, RENDERER_MARKER_TEXT);
- } catch (error) {
- return 'escaping: ' + describeError(error);
- } finally {
- if (probeCompanyId !== null) {
- container.services.companies.remove(probeCompanyId);
- }
- }
- return null;
-}
-
-const escapingProbeScript = (escaped: string): string => `(() => {
- const root = document.getElementById('root');
- if (root === null) return { elements: -1, escaped: false };
- const suspects = [...root.querySelectorAll('img, script, object, iframe')]
- .filter((el) => el.getAttribute('src') === 'x' || el.hasAttribute('onerror'));
- return { elements: suspects.length, escaped: root.innerHTML.includes(${JSON.stringify(escaped)}) };
-})()`;
-
-/*
- * SPA-01 in the packaged app: the second route is reached, and no document is loaded to reach it.
- *
- * v1.2.1 changed screens by asking main to loadFile() a different HTML file, so every screen change was a fresh
- * document, a fresh script evaluation and a fresh set of globals. The claim now is that there is one document and
- * the route lives after the #. did-navigate fires for a document navigation and did-navigate-in-page for a
- * same-document one, so the two counters say which kind actually happened - and the URL is compared with its
- * fragment stripped, which is the part a loadFile would have changed.
- */
-async function probeRouting(win: BrowserWindow, lines: string[]): Promise {
- const documentOf = (url: string): string => url.split('#')[0] ?? url;
-
- let documentLoads = 0;
- let inPageChanges = 0;
- const onNavigate = (): void => { documentLoads += 1; };
- const onNavigateInPage = (): void => { inPageChanges += 1; };
- win.webContents.on('did-navigate', onNavigate);
- win.webContents.on('did-navigate-in-page', onNavigateInPage);
-
- try {
- const before = documentOf(win.webContents.getURL());
- await win.webContents.executeJavaScript(
- 'window.location.hash = ' + JSON.stringify(RENDERER_SECOND_ROUTE_HASH) + '; true'
- );
- const rendered = await waitForRendererText(win, RENDERER_SECOND_ROUTE_TEXT);
- const after = documentOf(win.webContents.getURL());
-
- lines.push('SMOKE_ROUTE_TEXT=' + rendered);
- lines.push('SMOKE_ROUTE_HASH=' + (win.webContents.getURL().split('#')[1] ?? ''));
- lines.push('SMOKE_ROUTE_SAME_DOCUMENT=' + String(before === after));
- lines.push('SMOKE_ROUTE_DOCUMENT_LOADS=' + String(documentLoads));
- lines.push('SMOKE_ROUTE_IN_PAGE=' + String(inPageChanges));
-
- if (!rendered.includes(RENDERER_SECOND_ROUTE_TEXT)) {
- return 'the ' + RENDERER_SECOND_ROUTE_HASH + ' route never rendered "' + RENDERER_SECOND_ROUTE_TEXT + '"';
- }
- if (before !== after) {
- return 'the route change replaced the document: ' + before + ' -> ' + after;
- }
- if (documentLoads > 0) {
- return 'the route change loaded ' + String(documentLoads) + ' document(s); a screen change must load none';
- }
-
- // Back to Home, so the marker the rest of the smoke waits for is on screen again.
- await win.webContents.executeJavaScript('window.location.hash = "#/"; true');
- const home = await waitForRendererText(win, RENDERER_MARKER_TEXT);
- if (!home.includes(RENDERER_MARKER_TEXT)) {
- return 'the app did not route back to "' + RENDERER_MARKER_TEXT + '"';
- }
- } finally {
- win.webContents.off('did-navigate', onNavigate);
- win.webContents.off('did-navigate-in-page', onNavigateInPage);
- }
- return null;
-}
-
-/*
- * Criterion 4 in the packaged app: the settings the screen shows are the ones on disk, and the one irreversible
- * button on it is reached by an identifier rather than by where it happens to sit.
- *
- * v1.2.1 found the delete-all-data button with document.querySelector('.mt-8.mb-8 button'), so a spacing tweak
- * detached the handler - or hung it on whatever button a later edit put first inside those margins. Both halves are
- * asked here: the handle resolves to exactly one element, and the selector that used to find it resolves to none.
- *
- * The switch is asked about too, because its markup changed. v1.2.1 drew `has-[:checked]:`; the row is one
diff --git a/src/renderer/src/features/settings/components/SoundPicker.tsx b/src/renderer/src/features/settings/components/SoundPicker.tsx
new file mode 100644
index 0000000..7ee422a
--- /dev/null
+++ b/src/renderer/src/features/settings/components/SoundPicker.tsx
@@ -0,0 +1,69 @@
+/*
+ * Which of the bundled notification sounds to play, with a button to hear it. Only the sounds the app ships are
+ * offered (SOUND_CHOICES) - there is no way to point it at a file. Choosing one plays it straight away, and the
+ * play button repeats it; both go through the same renderer-side element the real notification uses, so what is
+ * heard here is exactly what will be heard later. Nothing is stored until Save, like every other field.
+ */
+
+import { useId } from 'react';
+import type { ReactElement } from 'react';
+import { playSound } from '@renderer/app/providers';
+import { SOUND_CHOICES } from '@shared/constants/settings';
+import type { SoundChoice } from '@shared/types';
+
+const ROW_CLASS = 'flex items-center gap-4 justify-between px-4 py-4';
+const TITLE_CLASS = 'text-slate-900 dark:text-white text-base font-medium leading-normal';
+const CAPTION_CLASS = 'text-slate-500 dark:text-[#92b7c9] text-xs';
+const SELECT_CLASS = 'rounded-xl border-none bg-slate-100 dark:bg-[#233c48] text-slate-900 dark:text-white ' +
+ 'focus:ring-2 focus:ring-primary/50 h-10 pl-3 pr-8 text-sm font-medium disabled:opacity-50';
+const PLAY_CLASS = 'flex items-center justify-center w-10 h-10 rounded-full bg-slate-100 dark:bg-[#233c48] ' +
+ 'text-primary hover:bg-slate-200 dark:hover:bg-white/10 transition-colors disabled:opacity-50';
+
+interface SoundPickerProps {
+ readonly value: SoundChoice;
+ /** Off greys the picker out: there is nothing to choose between while nothing plays. */
+ readonly disabled: boolean;
+ readonly onChange: (next: SoundChoice) => void;
+}
+
+const isChoice = (id: string): id is SoundChoice => SOUND_CHOICES.some((choice) => choice.id === id);
+
+export default function SoundPicker({ value, disabled, onChange }: SoundPickerProps): ReactElement {
+ const selectId = useId();
+ return (
+
+
+
+ Played for the goal and for each finished pomodoro
+
+
+
+
+
+
+ );
+}
diff --git a/src/renderer/src/features/settings/settings-view.ts b/src/renderer/src/features/settings/settings-view.ts
index f8168a7..7e12e1c 100644
--- a/src/renderer/src/features/settings/settings-view.ts
+++ b/src/renderer/src/features/settings/settings-view.ts
@@ -9,7 +9,7 @@
import { SETTINGS_BOUNDS } from '@shared/constants/settings';
import type { NumericSettingKey } from '@shared/constants/settings';
-import type { Settings } from '@shared/types';
+import type { Settings, SoundChoice } from '@shared/types';
/** The identifier the danger-zone button is reached by. See the note on DANGER_NOTE below. */
export const DESTRUCTIVE_ACTION_ID = 'reset-all-data';
@@ -92,6 +92,8 @@ export interface SettingsDraft {
readonly dailyTargetSeconds: number;
readonly numbers: Readonly>;
readonly goalNotification: boolean;
+ readonly soundEnabled: boolean;
+ readonly notificationSound: SoundChoice;
readonly excludeWeekendsFromStreak: boolean;
readonly pomodoroAutoStartBreaks: boolean;
readonly pomodoroAutoStartWork: boolean;
@@ -106,6 +108,8 @@ export function draftFrom(settings: Settings): SettingsDraft {
dailyTargetSeconds: settings.dailyTargetSeconds,
numbers,
goalNotification: settings.goalNotification,
+ soundEnabled: settings.soundEnabled,
+ notificationSound: settings.notificationSound,
excludeWeekendsFromStreak: settings.excludeWeekendsFromStreak,
pomodoroAutoStartBreaks: settings.pomodoroAutoStartBreaks,
pomodoroAutoStartWork: settings.pomodoroAutoStartWork
@@ -131,7 +135,7 @@ const WHOLE_NUMBER = /^\d+$/;
*/
export function reviewDraft(draft: SettingsDraft, stored: Settings): DraftReview {
const errors: Partial> = {};
- const patch: Record = {};
+ const patch: Record = {};
/*
* A field is compared as TEXT against what the stored value reads as, not as a number: 90 stored seconds show as
* 2 minutes, and writing 120 back over a value nobody touched is the silent edit the service refuses to make.
@@ -168,7 +172,11 @@ export function reviewDraft(draft: SettingsDraft, stored: Settings): DraftReview
patch.dailyTargetSeconds = target;
}
- for (const key of ['goalNotification', 'excludeWeekendsFromStreak', 'pomodoroAutoStartBreaks',
+ if (draft.notificationSound !== stored.notificationSound) {
+ patch.notificationSound = draft.notificationSound;
+ }
+
+ for (const key of ['goalNotification', 'soundEnabled', 'excludeWeekendsFromStreak', 'pomodoroAutoStartBreaks',
'pomodoroAutoStartWork'] as const) {
if (draft[key] !== stored[key]) {
patch[key] = draft[key];
diff --git a/src/shared/constants/settings.ts b/src/shared/constants/settings.ts
index 87911f9..92c118d 100644
--- a/src/shared/constants/settings.ts
+++ b/src/shared/constants/settings.ts
@@ -1,9 +1,32 @@
// v1.2.1's settings seeds as typed defaults; zod-free, so the renderer and fresh-install seeding can import it.
import type { Settings } from '@shared/types';
+/*
+ * The notification sounds the app ships, and the only ones a user can pick between. Bundled files, never a path or
+ * a URL the user supplies: the Settings screen offers this list and main refuses anything outside it. Each one is
+ * under a second long, and level-matched to each other (src/assets/sounds/, see LICENSE.txt there). 'classic' is the default: a soft, short confirmation tone chosen to be easy to live with all day.
+ */
+export const SOUND_CHOICES = Object.freeze([
+ { id: 'classic', label: 'Classic' },
+ { id: 'chime', label: 'Chime' },
+ { id: 'glass', label: 'Glass' },
+ { id: 'confirm', label: 'Confirm' },
+ { id: 'question', label: 'Question' },
+ { id: 'two-tone', label: 'Two Tone' },
+ { id: 'three-tone', label: 'Three Tone' },
+ { id: 'rise', label: 'Rise' },
+ { id: 'pep', label: 'Pep' }
+] as const);
+
+export const SOUND_CHOICE_IDS = SOUND_CHOICES.map((choice) => choice.id) as unknown as readonly [
+ (typeof SOUND_CHOICES)[number]['id'], ...(typeof SOUND_CHOICES)[number]['id'][]
+];
+
export const DEFAULT_SETTINGS: Readonly = Object.freeze({
dailyTargetSeconds: 28800,
goalNotification: true,
+ soundEnabled: true,
+ notificationSound: 'classic',
excludeWeekendsFromStreak: false,
pomodoroEnabled: false,
pomodoroWorkSeconds: 1500,
diff --git a/src/shared/ipc/contract.ts b/src/shared/ipc/contract.ts
index 2e65299..e4f1574 100644
--- a/src/shared/ipc/contract.ts
+++ b/src/shared/ipc/contract.ts
@@ -3,7 +3,7 @@ import { z } from 'zod';
import {
CompanySchema, DayProgressSchema, IdSchema, LocalDateSchema, PomodoroCountsSchema,
PomodoroSnapshotSchema, SessionDurationSecondsSchema, SessionNameSchema, SessionNoteSchema, SettingsSchema,
- SoundIdSchema, StreakSchema, TimerModeSchema, TimerSnapshotSchema, WeekTotalsSchema, WorkSessionSchema
+ SoundChoiceSchema, SoundIdSchema, StreakSchema, TimerModeSchema, TimerSnapshotSchema, WeekTotalsSchema, WorkSessionSchema
} from '@shared/schemas';
import { IPC_CHANNELS, IPC_EVENT_CHANNELS } from '@shared/ipc/channels';
import type { DeclaredIpcChannel, DeclaredIpcEventChannel } from '@shared/ipc/channels';
@@ -198,7 +198,7 @@ export const ipcWrites = {
// Main-to-renderer events; each entry arrives with the phase that designs the event (D-20).
export const ipcEvents = {
// The main process decides when a sound is due; only the renderer can play one.
- 'app:playSound': z.strictObject({ sound: SoundIdSchema }),
+ 'app:playSound': z.strictObject({ sound: SoundIdSchema, choice: SoundChoiceSchema }),
// One message per second carrying a whole snapshot, so the renderer stores what it is told and computes nothing.
'timer:tick': TimerSnapshotSchema,
// The same for the cycle: every state change, including each tick, arrives as a whole snapshot.
diff --git a/src/shared/schemas/index.ts b/src/shared/schemas/index.ts
index cc9b89c..ccd7a2c 100644
--- a/src/shared/schemas/index.ts
+++ b/src/shared/schemas/index.ts
@@ -4,6 +4,7 @@ import { z } from 'zod';
import {
MAX_SESSION_DURATION_SECONDS, MAX_SESSION_NAME_LENGTH, MAX_SESSION_NOTE_LENGTH
} from '@shared/constants/sessions';
+import { SOUND_CHOICE_IDS } from '@shared/constants/settings';
import { isLocalDate } from '@shared/utils/date';
import type { LocalDate } from '@shared/utils/date';
@@ -56,6 +57,8 @@ export const PomodoroSessionSchema = z.strictObject({
export const SettingsSchema = z.strictObject({
dailyTargetSeconds: z.int().positive(),
goalNotification: z.boolean(),
+ soundEnabled: z.boolean(),
+ notificationSound: z.enum(SOUND_CHOICE_IDS),
excludeWeekendsFromStreak: z.boolean(),
pomodoroEnabled: z.boolean(),
pomodoroWorkSeconds: z.int().positive(),
@@ -70,6 +73,9 @@ export const SettingsSchema = z.strictObject({
// element, as v1.2.1's `new Audio(...)` did.
export const SoundIdSchema = z.enum(['goalReached', 'pomodoroCompleted']);
+// Which of the bundled files to play for it: the user's choice, read by main at the moment the sound is due.
+export const SoundChoiceSchema = z.enum(SOUND_CHOICE_IDS);
+
// The timer's own vocabulary. Status and mode are separate axes, which is what lets a mode change leave what has
// already been counted alone (CORE-14, CB-1).
export const TimerStatusSchema = z.enum(['idle', 'running', 'paused']);
diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts
index aa027ee..e579fe6 100644
--- a/src/shared/types/index.ts
+++ b/src/shared/types/index.ts
@@ -2,7 +2,7 @@
import type { z } from 'zod';
import type {
CompanySchema, DayProgressSchema, PomodoroCountsSchema, PomodoroIntervalSchema, PomodoroSessionSchema,
- PomodoroSnapshotSchema, PomodoroStatusSchema, SettingsSchema, SoundIdSchema, StreakSchema, TimerModeSchema,
+ PomodoroSnapshotSchema, PomodoroStatusSchema, SettingsSchema, SoundChoiceSchema, SoundIdSchema, StreakSchema, TimerModeSchema,
TimerSnapshotSchema, TimerStatusSchema, WeekTotalsSchema, WorkSessionSchema
} from '@shared/schemas';
import type { LocalDate } from '@shared/utils/date';
@@ -12,6 +12,7 @@ export type WorkSession = z.infer;
export type PomodoroSession = z.infer;
export type Settings = z.infer;
export type SoundId = z.infer;
+export type SoundChoice = z.infer;
// The authoritative timer's shape. It lives in main (X1); this is the value the renderer mirrors.
export type TimerStatus = z.infer;
diff --git a/tests/app-identity.test.ts b/tests/app-identity.test.ts
index 4a73dad..36cc0b0 100644
--- a/tests/app-identity.test.ts
+++ b/tests/app-identity.test.ts
@@ -5,7 +5,9 @@ import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { APP_USER_MODEL_ID } from '../src/main/config';
import { applyAppUserModelId, appUserModelIdApplied } from '../src/main/app-identity';
-import { EXPECTED_APP_USER_MODEL_ID, MAX_NOTIFY_ICON_BYTES } from '../tools/smoke-packaged.mjs';
+
+// The notification icon is a small dedicated asset; this ceiling is what keeps a big logo from replacing it.
+const MAX_NOTIFY_ICON_BYTES = 64_000;
/*
* Why a JSON assertion is load-bearing here.
@@ -191,13 +193,12 @@ describe('CUSTODY-02: the userData path cannot silently move', () => {
* setAppUserModelId was never called. Setting it to the WRONG value is worse than not setting it - the
* toast then points at an application Windows has no installed shortcut for, so it cannot resolve a name or
* a logo and has no reason to fall back. So the three places that spell the identity are held equal:
- * electron-builder.yml, src/main/config.ts and tools/smoke-packaged.mjs.
+ * electron-builder.yml and src/main/config.ts.
*/
const declared = /^appId:\s*(\S+)\s*$/m.exec(builderConfig);
expect(declared, BUILDER_CONFIG + ' declares no appId').not.toBeNull();
const appId = declared?.[1];
expect(APP_USER_MODEL_ID, 'src/main/config.ts disagrees with ' + BUILDER_CONFIG).toBe(appId);
- expect(EXPECTED_APP_USER_MODEL_ID, 'tools/smoke-packaged.mjs disagrees with ' + BUILDER_CONFIG).toBe(appId);
});
it('applyAppUserModelId sets the identity on Windows and nothing anywhere else', () => {
diff --git a/tests/baselines.test.ts b/tests/baselines.test.ts
deleted file mode 100644
index c387be9..0000000
--- a/tests/baselines.test.ts
+++ /dev/null
@@ -1,249 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import fs from 'node:fs';
-import path from 'node:path';
-import { fileURLToPath } from 'node:url';
-
-/*
- * Why this file exists.
- *
- * baselines/v1.2.1/pixels/ and computed/ are the only record of what v1.2.1 looked like. SPA-14 deletes
- * legacy/pages/*.html once the parity checklist is ticked, and after that the app cannot be re-rendered at all. So
- * a capture that was partial, or that photographed an unstyled frame, is not a recoverable mistake - it is a
- * baseline that quietly lies for the rest of the project.
- *
- * The failure this guards against is specifically the QUIET one. A capture that writes 19 of 20 files, or that
- * raced the Tailwind Play CDN on one page out of four, leaves artifacts that look entirely normal: the first
- * silently narrows the parity check rather than failing it, and the second surfaces as a diff on exactly one page,
- * which reads like a rewrite regression - the most expensive possible way to discover a capture bug.
- *
- * Scope: presence, pairing, decodability, parseability and applied styling. This file compares NO pixels.
- */
-
-const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
-const pixelsDir = path.join(repoRoot, 'baselines', 'v1.2.1', 'pixels');
-const computedDir = path.join(repoRoot, 'baselines', 'v1.2.1', 'computed');
-const manifestPath = path.join(repoRoot, 'baselines', 'v1.2.1', 'MANIFEST.md');
-
-// The four pages v1.2.1 has and the five window sizes RESP-02 cares about.
-const PAGES = ['index', 'companies', 'work-history', 'settings'];
-const SIZES: ReadonlyArray = [
- [380, 600],
- [430, 932],
- [768, 1024],
- [1280, 800],
- [1920, 1080]
-];
-
-// The palette's background-dark, #101c22, as Chromium reports it. tools/baseline/capture.mjs waits
-// on this exact computed value before every screenshot rather than on a load event, because the
-// Play CDN is a JIT engine that generates CSS from the live DOM through a MutationObserver - `load`
-// fires long before a page is styled. Asserting the same value here is what turns that wait from a
-// hope into a checked property of the committed artifact.
-const PALETTE_BACKGROUND = 'rgb(16, 28, 34)';
-
-/*
- * Duplicated from COMPUTED_PROPS in tools/baseline/capture.mjs ON PURPOSE - importing it would make this assertion
- * tautological. The point is that the COMMITTED baselines carry this exact property set: if someone widens or
- * narrows the driver's list, the artifacts on disk become stale and the Phase 8 diff would compare different
- * properties on either side. Duplication makes that a test failure that says "re-capture".
- */
-const COMPUTED_PROPS = [
- 'display', 'position', 'width', 'height', 'margin', 'padding', 'color',
- 'background-color', 'border', 'border-radius', 'box-shadow', 'font-family', 'font-size',
- 'font-weight', 'line-height', 'letter-spacing', 'flex', 'grid-template-columns', 'gap',
- 'opacity', 'overflow', 'outline', 'z-index', 'transform'
-];
-
-/** Every `@x` the capture is required to have produced. */
-const EXPECTED_STEMS: string[] = PAGES.flatMap(
- (page) => SIZES.map(([width, height]) => `${page}@${width}x${height}`)
-);
-
-const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
-
-type StyleRecord = Record>;
-
-function listDir(dir: string): string[] {
- return fs.existsSync(dir) ? fs.readdirSync(dir).sort() : [];
-}
-
-function stemsOf(dir: string, extension: string): string[] {
- return listDir(dir)
- .filter((name) => name.endsWith(extension))
- .map((name) => name.slice(0, -extension.length))
- .sort();
-}
-
-function readRecord(stem: string): StyleRecord {
- const raw = fs.readFileSync(path.join(computedDir, `${stem}.json`), 'utf8');
- return JSON.parse(raw) as StyleRecord;
-}
-
-/*
- * The element's key. tools/baseline/capture.mjs keys every record by a structural selector path built from
- * the element up to (but excluding) document.documentElement, so is a single top-level segment. Matched by
- * shape rather than by that literal string so a future with a different child order still resolves, while
- * anything nested (which would contain ' > ') still cannot match.
- */
-function bodyKey(record: StyleRecord): string | undefined {
- return Object.keys(record).find((key) => /^body(:nth-child\(\d+\))?$/.test(key));
-}
-
-describe('CUSTODY-10: the v1.2.1 baselines are complete and were captured styled', () => {
- it('pixels/ holds exactly 20 non-empty .png files and nothing else', () => {
- const entries = listDir(pixelsDir);
- expect(entries.filter((name) => !name.endsWith('.png'))).toEqual([]);
- expect(entries).toHaveLength(EXPECTED_STEMS.length);
-
- const empty = entries.filter((name) => fs.statSync(path.join(pixelsDir, name)).size === 0);
- expect(empty).toEqual([]);
- });
-
- it('computed/ holds exactly 20 non-empty .json files and nothing else', () => {
- const entries = listDir(computedDir);
- expect(entries.filter((name) => !name.endsWith('.json'))).toEqual([]);
- expect(entries).toHaveLength(EXPECTED_STEMS.length);
-
- const empty = entries.filter((name) => fs.statSync(path.join(computedDir, name)).size === 0);
- expect(empty).toEqual([]);
- });
-
- /*
- * Compared as SETS, not as counts. In Phase 8 this is the assertion that has to say WHICH page
- * at WHICH size is unaccounted for; "expected 20, got 19" would send someone diffing directory
- * listings by hand at exactly the moment the app can no longer be re-run.
- */
- it('the screenshot stems are exactly the 4 pages x 5 sizes cross product, by name', () => {
- const actual = stemsOf(pixelsDir, '.png');
- expect(actual.filter((stem) => !EXPECTED_STEMS.includes(stem))).toEqual([]);
- expect(EXPECTED_STEMS.filter((stem) => !actual.includes(stem))).toEqual([]);
- });
-
- it('every screenshot has a matching computed-style record, and every record a screenshot', () => {
- const pixels = stemsOf(pixelsDir, '.png');
- const computed = stemsOf(computedDir, '.json');
- expect(computed.filter((stem) => !pixels.includes(stem))).toEqual([]);
- expect(pixels.filter((stem) => !computed.includes(stem))).toEqual([]);
- expect(computed).toEqual(pixels);
- });
-
- /*
- * Not in the plan's behaviour list; added because a real defect made it necessary. The rule `baselines/** text
- * eol=lf` in .gitattributes is an explicit `text`, not `text=auto`, so it forces end-of-line conversion with NO
- * binary detection and overrides the `*.png binary` rule earlier in that file - later rules win. Under it, git
- * stripped the 0x0D of every CRLF pair inside the PNG byte stream on commit (measured: a 16-byte probe stored
- * as a 13-byte blob). Every check above would still have passed; only decoding catches it.
- *
- * Checking the IHDR dimensions against the filename is the same assertion from the other side: the driver waits
- * for `window.innerWidth === ` before each shot precisely so a wrong size cannot be misfiled.
- */
- it('every screenshot is a decodable PNG whose pixel dimensions match its filename', () => {
- const wrong: string[] = [];
- for (const stem of EXPECTED_STEMS) {
- const bytes = fs.readFileSync(path.join(pixelsDir, `${stem}.png`));
- const match = /@(\d+)x(\d+)$/.exec(stem);
- const width = Number(match?.[1]);
- const height = Number(match?.[2]);
-
- if (!bytes.subarray(0, 8).equals(PNG_SIGNATURE)) {
- wrong.push(`${stem}: not a PNG - signature is ${bytes.subarray(0, 8).toString('hex')}`);
- continue;
- }
- if (bytes.subarray(bytes.length - 8, bytes.length - 4).toString('ascii') !== 'IEND') {
- wrong.push(`${stem}: truncated - no IEND chunk`);
- continue;
- }
- const actual = `${bytes.readUInt32BE(16)}x${bytes.readUInt32BE(20)}`;
- if (actual !== `${width}x${height}`) {
- wrong.push(`${stem}: IHDR says ${actual}, filename says ${width}x${height}`);
- }
- }
- expect(wrong).toEqual([]);
- });
-
- it('every computed-style record parses as JSON and yields a non-empty object', () => {
- const bad: string[] = [];
- for (const stem of EXPECTED_STEMS) {
- let record: StyleRecord;
- try {
- record = readRecord(stem);
- } catch (error) {
- bad.push(`${stem}: ${(error as Error).message}`);
- continue;
- }
- if (record === null || typeof record !== 'object' || Array.isArray(record)) {
- bad.push(`${stem}: not a JSON object`);
- continue;
- }
- if (Object.keys(record).length === 0) {
- bad.push(`${stem}: empty object - the capture wrote a file it never populated`);
- }
- }
- expect(bad).toEqual([]);
- });
-
- /*
- * On ALL 20 records, never on a sample. A capture that raced the runtime-generated CSS on one
- * page out of four would pass a sampled check, and the resulting Phase 8 diff would look like a
- * rewrite regression confined to that page.
- */
- it(`every record has the body element at ${PALETTE_BACKGROUND}, so no frame was captured unstyled`, () => {
- const unstyled: string[] = [];
- for (const stem of EXPECTED_STEMS) {
- const record = readRecord(stem);
- const key = bodyKey(record);
- if (key === undefined) {
- unstyled.push(`${stem}: no body element recorded`);
- continue;
- }
- const background = record[key]?.['background-color'];
- if (background !== PALETTE_BACKGROUND) {
- unstyled.push(`${stem}: body background is ${String(background)}, expected ${PALETTE_BACKGROUND}`);
- }
- }
- expect(unstyled).toEqual([]);
- });
-
- it('every element in every record carries exactly the enumerated property set', () => {
- const drifted: string[] = [];
- const expected = [...COMPUTED_PROPS].sort();
- for (const stem of EXPECTED_STEMS) {
- const record = readRecord(stem);
- for (const [selector, properties] of Object.entries(record)) {
- const actual = Object.keys(properties).sort();
- if (actual.length !== expected.length || actual.some((prop, i) => prop !== expected[i])) {
- const missing = expected.filter((prop) => !actual.includes(prop));
- const extra = actual.filter((prop) => !expected.includes(prop));
- drifted.push(
- `${stem} :: ${selector || ''}: ` +
- `missing [${missing.join(', ')}], extra [${extra.join(', ')}]`
- );
- }
- }
- if (drifted.length > 0) break;
- }
- expect(drifted).toEqual([]);
- });
-
- /*
- * The manifest is what a Phase 8 re-capture is reconstructed from. A capture taken by a
- * different playwright-core is not comparable to this one - Playwright's Electron support is
- * experimental and sits outside its stability guarantees - so the version has to be recorded,
- * not merely pinned in package.json where a dependency bump would erase the evidence.
- */
- it('MANIFEST.md records every matrix size and the playwright-core version used', () => {
- const manifest = fs.readFileSync(manifestPath, 'utf8');
-
- const missing = SIZES
- .map(([width, height]) => `${width}x${height}`)
- .filter((size) => !manifest.includes(size));
- expect(missing).toEqual([]);
-
- const pkg = JSON.parse(
- fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')
- ) as { devDependencies: Record };
- const pinned = pkg.devDependencies['playwright-core'];
- expect(pinned).toBe('1.63.0');
- expect(manifest).toContain(pinned);
- });
-});
diff --git a/tests/container.test.ts b/tests/container.test.ts
index 2ea3c85..9a5c6ad 100644
--- a/tests/container.test.ts
+++ b/tests/container.test.ts
@@ -105,6 +105,8 @@ interface DrivenPorts {
readonly ports: AppPorts;
readonly notifications: NotificationRequest[];
readonly sounds: SoundId[];
+ /** The file each sound was asked to play with, parallel to `sounds`. */
+ readonly choices: string[];
readonly events: IpcEventChannel[];
/** Advances the monotonic and wall clocks together and runs every scheduled repeat once per whole second. */
tick(seconds: number): void;
@@ -119,6 +121,7 @@ interface DrivenPorts {
function drivenPorts(wallOriginMs: number = WALL_ORIGIN_MS): DrivenPorts {
const notifications: NotificationRequest[] = [];
const sounds: SoundId[] = [];
+ const choices: string[] = [];
const events: IpcEventChannel[] = [];
const repeats: (() => void)[] = [];
let monotonic = 0;
@@ -126,7 +129,7 @@ function drivenPorts(wallOriginMs: number = WALL_ORIGIN_MS): DrivenPorts {
const ports: AppPorts = {
clock: { now: () => wallOriginMs + monotonic, monotonicNow: () => monotonic },
notifier: { notify: (request) => notifications.push(request) },
- sound: { play: (sound) => sounds.push(sound) },
+ sound: { play: (sound, choice) => { sounds.push(sound); choices.push(choice); } },
bus: { emit: (channel) => events.push(channel) },
scheduler: {
every: (_intervalMs, run) => {
@@ -141,6 +144,7 @@ function drivenPorts(wallOriginMs: number = WALL_ORIGIN_MS): DrivenPorts {
ports,
notifications,
sounds,
+ choices,
events,
tick(seconds) {
this.tickEvery(1000, seconds);
@@ -674,6 +678,30 @@ describe('the services the container composes', () => {
expect(driver.sounds).toHaveLength(1);
});
+ it('plays the sound the user chose', async () => {
+ const { container, driver } = await driven(makeCleanFixture());
+ const alreadyToday = container.services.stats.today().totalSeconds;
+ container.services.settings.update({
+ dailyTargetSeconds: alreadyToday + 60, goalNotification: true, notificationSound: 'glass'
+ });
+ container.services.timer.start();
+ driver.tick(60);
+ expect(driver.sounds).toEqual(['goalReached']);
+ expect(driver.choices, 'the chosen file did not reach the renderer').toEqual(['glass']);
+ });
+
+ it('plays no sound with the switch off, and still raises the notification', async () => {
+ const { container, driver } = await driven(makeCleanFixture());
+ const alreadyToday = container.services.stats.today().totalSeconds;
+ container.services.settings.update({
+ dailyTargetSeconds: alreadyToday + 60, goalNotification: true, soundEnabled: false
+ });
+ container.services.timer.start();
+ driver.tick(60);
+ expect(driver.notifications, 'switching the sound off swallowed the notification').toEqual([GOAL_NOTIFICATION]);
+ expect(driver.sounds, 'a sound played with the switch off').toEqual([]);
+ });
+
/*
* WR-08: this phase put database work inside the per-second tick callback, and MAX_CREDIT_MS means any stall
* over two seconds permanently destroys the excess - so the cost of the tick is a correctness question, not a
diff --git a/tests/custody-hygiene.test.ts b/tests/custody-hygiene.test.ts
deleted file mode 100644
index d65e924..0000000
--- a/tests/custody-hygiene.test.ts
+++ /dev/null
@@ -1,248 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import fs from 'node:fs';
-import path from 'node:path';
-import { execFileSync } from 'node:child_process';
-import { fileURLToPath } from 'node:url';
-
-/*
- * Why this file exists, and why it duplicates a CI step.
- *
- * This repository is public. %APPDATA%\workflow-timer\krono.db holds real client names and real work notes.
- * Committing it is the one mistake in this whole restructure that cannot be undone: a history rewrite does not
- * reach the clones already taken, and there is no server through which anything could be recalled. D-03 says
- * schema only, and this file turns that from a thing someone remembers into a thing the suite checks.
- *
- * Plan 01-02 added an equivalent step to verify.yml. The duplication is deliberate: the CI step catches the
- * mistake before a push reaches the remote, this test catches it before the commit exists at all.
- *
- * The same reasoning covers the installer binaries. They are 83.5 MB and 106 MB, .gitignore does not exclude *.exe
- * or *.dmg, and 190 MB of blobs in a public git history is equally permanent. They are pinned by digest in
- * baselines/v1.2.1/MANIFEST.md instead (CUSTODY-08).
- *
- * Failure messages name the offending paths on purpose: the removal is urgent.
- */
-
-const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
-
-const SCHEMA_EXTRACT = 'tests/fixtures/v121-real-schema.sql';
-const MANIFEST = 'baselines/v1.2.1/MANIFEST.md';
-const FETCH_SCRIPT = 'tools/baseline/fetch-installer.sh';
-
-const V121_TABLES = ['companies', 'work_sessions', 'settings', 'pomodoro_sessions'];
-
-const read = (rel: string): string => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
-
-/*
- * git, not a filesystem walk. The question is not "is there a database on this disk" - there
- * always is, that is the point of the archive - but "is one TRACKED", which only git can answer.
- * execFileSync with an argument array rather than a shell string: no shell, no quoting surface.
- */
-const trackedFiles = (): string[] =>
- execFileSync('git', ['ls-files'], { cwd: repoRoot, encoding: 'utf8' })
- .split('\n')
- .map((line) => line.trim())
- .filter((line) => line.length > 0);
-
-const trackedMatching = (extensions: string[]): string[] =>
- trackedFiles().filter((file) => extensions.some((ext) => file.toLowerCase().endsWith(ext)));
-
-/*
- * Every unbroken run of 32 or more hex characters in the manifest is a digest candidate. A
- * correctly pasted SHA-256 is exactly 64; a truncated one is shorter; one wrapped across two
- * lines by an editor becomes two shorter runs. All three failure shapes surface here, at commit
- * time, rather than at the moment someone tries to verify an 87 MB download against it.
- */
-const digestCandidates = (text: string): string[] => text.match(/\b[0-9a-fA-F]{32,}\b/g) ?? [];
-
-/*
- * The manifest grew a second and a third kind of pin in plan 01-05, and "record vs enforcement" has to be checked
- * per kind or not at all:
- *
- * section 1 the release installers enforced by tools/baseline/fetch-installer.sh
- * section 3 the vendored CDN + font assets enforced by tools/baseline/vendor/index.json
- * section 4 the unpacked application files a provenance record; nothing re-derives them
- *
- * Before this existed, the section-1 assertion scanned the WHOLE file, so the first vendored digest added in
- * section 3 read as "documented but not enforced" and reddened a test about installers.
- */
-const manifestSection = (n: number): string => {
- const text = read(MANIFEST);
- const start = text.search(new RegExp('^## ' + n + '\\. ', 'm'));
- if (start === -1) return '';
- const rest = text.slice(start + 1);
- const end = rest.search(/^## \d+\. /m);
- return end === -1 ? text.slice(start) : text.slice(start, start + 1 + end);
-};
-
-describe('CUSTODY-07 / CUSTODY-08: no real user data and no large binary enters this repository', () => {
- it('tracks no database file - .db, .db-wal or .db-shm (D-03)', () => {
- const offenders = trackedMatching(['.db', '.db-wal', '.db-shm']);
- expect(
- offenders,
- 'Database files are TRACKED BY GIT:\n ' + offenders.join('\n ') +
- '\nRemove with: git rm --cached \n' +
- 'A database pushed to this public repository cannot be un-published - clones taken ' +
- 'before any history rewrite keep the user\'s real client names and work notes (D-03).'
- ).toEqual([]);
- });
-
- it('tracks no installer binary - .exe or .dmg (CUSTODY-08)', () => {
- const offenders = trackedMatching(['.exe', '.dmg']);
- expect(
- offenders,
- 'Installer binaries are TRACKED BY GIT:\n ' + offenders.join('\n ') +
- '\nRemove with: git rm --cached \n' +
- 'The v1.2.1 installers are 83.5 MB and 106 MB and are pinned by SHA-256 in ' +
- MANIFEST + ' instead. Fetch them with tools/baseline/fetch-installer.sh.'
- ).toEqual([]);
- });
-
- it('.gitignore still excludes *.db, *.db-shm and *.db-wal', () => {
- const lines = read('.gitignore')
- .split('\n')
- .map((line) => line.replace(/\r$/, '').trim());
- const required = ['*.db', '*.db-shm', '*.db-wal'];
- const missing = required.filter((pattern) => !lines.includes(pattern));
- expect(
- missing,
- 'These .gitignore patterns have been removed: ' + missing.join(', ') +
- '\nThey are the first of three layers stopping a real krono.db from being committed ' +
- '(the others are this test and the verify workflow). Restore them.'
- ).toEqual([]);
- });
-
- it('the committed v1.2.1 schema extract exists and declares all four tables', () => {
- const extractPath = path.join(repoRoot, SCHEMA_EXTRACT);
- expect(
- fs.existsSync(extractPath),
- SCHEMA_EXTRACT + ' is missing. Regenerate it with: ' +
- 'node tools/baseline/archive-real-db.mjs'
- ).toBe(true);
-
- const sql = read(SCHEMA_EXTRACT);
- const missing = V121_TABLES.filter(
- (table) => !new RegExp('CREATE TABLE ' + table + '\\b').test(sql)
- );
- expect(
- missing,
- 'The schema extract has no CREATE TABLE for: ' + missing.join(', ') +
- '\nD-05 makes this extract load-bearing: plan 01-07 diffs it against the DDL in ' +
- 'database/db.js to decide whether a second legacy-variant fixture is needed.'
- ).toEqual([]);
- });
-
- it('the committed v1.2.1 schema extract carries no row data (D-03)', () => {
- const sql = read(SCHEMA_EXTRACT);
-
- expect(
- /\bINSERT\b/i.test(sql),
- SCHEMA_EXTRACT + ' contains an insert statement. This file is DDL ONLY. Any row in ' +
- 'it is a real work session belonging to a real client, in a public repository.'
- ).toBe(false);
-
- expect(
- /\bVALUES\s*\(/i.test(sql),
- SCHEMA_EXTRACT + ' contains a VALUES clause. This file is DDL ONLY (D-03).'
- ).toBe(false);
-
- /*
- * DEFAULT '...' is the only legitimate string literal in a CREATE TABLE. Anything else
- * quoted is a value, and a value in this file is a client name or a work note.
- */
- const withoutComments = sql.replace(/^--.*$/gm, '');
- const withoutDefaults = withoutComments.replace(
- /DEFAULT\s+'(?:[^']|'')*'/gi,
- 'DEFAULT '
- );
- const stray = withoutDefaults.match(/'(?:[^']|'')*'/g) ?? [];
- expect(
- stray,
- SCHEMA_EXTRACT + ' contains string literals that are not column defaults: ' +
- stray.slice(0, 5).join(', ') + '\nThese may be real user data (D-03).'
- ).toEqual([]);
- });
-
- it('the installer manifest pins at least two distinct SHA-256 digests', () => {
- const manifest = read(MANIFEST);
- const digests = manifest.match(/\b[0-9a-f]{64}\b/g) ?? [];
- const distinct = new Set(digests);
- expect(
- distinct.size,
- MANIFEST + ' must pin a digest for both the Windows .exe and the macOS .dmg. ' +
- 'Found ' + distinct.size + ' distinct 64-character lowercase hex digests. ' +
- 'Without them the binary Phase 10 / REL-04 upgrade-tests against is unverifiable.'
- ).toBeGreaterThanOrEqual(2);
- });
-
- it('every digest in the installer manifest is exactly 64 lowercase hex characters', () => {
- const manifest = read(MANIFEST);
- const malformed = digestCandidates(manifest).filter(
- (candidate) => !/^[0-9a-f]{64}$/.test(candidate)
- );
- expect(
- malformed,
- MANIFEST + ' contains hex runs that are not a well-formed SHA-256: ' +
- malformed.map((m) => m + ' (' + m.length + ' chars)').join(', ') +
- '\nA digest truncated or wrapped across two lines when pasted rejects the CORRECT ' +
- 'binary, which is worse than no pin at all - it looks like tampering.'
- ).toEqual([]);
- });
-
- it('the fetch script enforces exactly the digests the manifest documents', () => {
- const manifestDigests = new Set(manifestSection(1).match(/\b[0-9a-f]{64}\b/g) ?? []);
- const scriptDigests = new Set(read(FETCH_SCRIPT).match(/\b[0-9a-f]{64}\b/g) ?? []);
-
- const undocumented = [...scriptDigests].filter((d) => !manifestDigests.has(d));
- const unenforced = [...manifestDigests].filter((d) => !scriptDigests.has(d));
-
- expect(
- { undocumented, unenforced },
- 'The manifest and the fetch script disagree about which binaries are pinned.\n' +
- ' in ' + FETCH_SCRIPT + ' but not in ' + MANIFEST + ': ' + undocumented.join(', ') +
- '\n in ' + MANIFEST + ' but not in ' + FETCH_SCRIPT + ': ' + unenforced.join(', ') +
- '\nThe manifest is the record and the script is the enforcement; if they drift, the ' +
- 'documented pin is not the one actually checked.'
- ).toEqual({ undocumented: [], unenforced: [] });
- });
-
- /*
- * The same record-vs-enforcement invariant, one section down. tools/baseline/capture.mjs refuses to fulfil a
- * request from a vendored file whose digest is not in the manifest, so a vendor digest that drifts out of
- * section 3 stops the capture rather than silently degrading the baseline. This asserts the two lists are the
- * same list, which is the precondition for that refusal meaning anything.
- */
- it('every vendored capture asset is pinned in the manifest, and vice versa (CUSTODY-10)', () => {
- const indexPath = path.join(repoRoot, 'tools/baseline/vendor/index.json');
- if (!fs.existsSync(indexPath)) return;
- const index = JSON.parse(fs.readFileSync(indexPath, 'utf8')) as {
- assets: { file: string; sha256: string }[];
- };
- const documented = new Set(manifestSection(3).match(/\b[0-9a-f]{64}\b/g) ?? []);
-
- const unpinned = index.assets.filter((a) => !documented.has(a.sha256)).map((a) => a.file);
- const orphaned = [...documented].filter(
- (digest) => !index.assets.some((a) => a.sha256 === digest)
- );
-
- expect(
- { unpinned, orphaned },
- 'tools/baseline/vendor/index.json and section 3 of ' + MANIFEST + ' disagree.\n' +
- ' vendored but not pinned in the manifest: ' + unpinned.join(', ') +
- '\n pinned in the manifest but not vendored: ' + orphaned.join(', ') +
- '\nA re-fetch that updates the files without updating the manifest is exactly the ' +
- 'silent CDN drift the pinning exists to make loud.'
- ).toEqual({ unpinned: [], orphaned: [] });
- });
-
- it('the pinned release URLs use the current repository name, not the redirect (T-01-16)', () => {
- for (const rel of [MANIFEST, FETCH_SCRIPT]) {
- expect(
- /workflow-timer\/releases/.test(read(rel)),
- rel + ' uses the stale fleizean/workflow-timer release path. That URL only works ' +
- 'because GitHub still honours a 301; the repository is now fleizean/workflow. ' +
- '(package.json\'s name field is a separate matter and must stay workflow-timer - ' +
- 'it determines where every user\'s database lives. See tests/app-identity.test.ts.)'
- ).toBe(false);
- }
- });
-});
diff --git a/tests/db-archive-proof.test.ts b/tests/db-archive-proof.test.ts
deleted file mode 100644
index 947663b..0000000
--- a/tests/db-archive-proof.test.ts
+++ /dev/null
@@ -1,278 +0,0 @@
-// D-28: migrate a temp COPY of the owner's archived real database and assert the D-23 invariants. Aggregates only -
-// never a row value, a company name or a note (T-01-37). The proof runs only behind the D-01 door; its guards always run.
-
-import { describe, expect, it } from 'vitest';
-import { createHash } from 'node:crypto';
-import fs from 'node:fs';
-import os from 'node:os';
-import path from 'node:path';
-import type Database from 'better-sqlite3';
-import { classify } from '../src/lib/db/classify';
-import { closeDatabase, openDatabase } from '../src/lib/db/client';
-import { probeDatabase } from '../src/lib/db/probe';
-import { migrateDatabase } from '../src/lib/db/runner';
-import { LATEST } from '../src/lib/db/migrations/registry';
-import {
- captureInvariants,
- diffInvariants,
- originalColumns,
- predictAdoption
-} from './helpers/db-invariants';
-
-export const APPROVAL_ENV = 'WORKFLOW_ARCHIVE_PROOF';
-export const APPROVAL_TOKEN = 'approved-archive';
-
-const DATABASE_FILE = 'krono.db';
-const SIDECARS = ['krono.db-wal', 'krono.db-shm'];
-const ARCHIVE_DIR = 'workflow-timer-archive';
-const DATED = /^\d{4}-\d{2}-\d{2}$/;
-const NOW = new Date(2026, 8, 12, 12, 0, 0);
-
-/** The single gate. A bare `1` is not enough: only the owner's recorded choice reaches this value (D-01). */
-export function archiveProofApproved(env: NodeJS.ProcessEnv): boolean {
- return env[APPROVAL_ENV] === APPROVAL_TOKEN;
-}
-
-interface RealPaths {
- readonly archiveRoot: string;
- readonly liveDir: string;
- readonly liveDb: string;
-}
-
-/** Pure: both locations are computed from the environment, and nothing here opens, stats or lists them. */
-export function realPaths(env: NodeJS.ProcessEnv, home: string): RealPaths {
- const liveDir = env.WFT_REAL_USERDATA ??
- path.join(env.APPDATA ?? path.join(home, 'AppData', 'Roaming'), 'workflow-timer');
- return { archiveRoot: path.join(home, ARCHIVE_DIR), liveDir, liveDb: path.join(liveDir, DATABASE_FILE) };
-}
-
-interface WriteTargets {
- readonly copy: string;
- readonly backupDir: string;
- readonly sidecars: readonly string[];
-}
-
-/** Pure: every path the proof writes to, so a test can prove all of them sit inside the mkdtemp root. */
-export function writeTargets(tempRoot: string): WriteTargets {
- return {
- copy: path.join(tempRoot, DATABASE_FILE),
- backupDir: path.join(tempRoot, 'backups'),
- sidecars: SIDECARS.map((name) => path.join(tempRoot, name))
- };
-}
-
-interface ArchiveDeps {
- readonly hashFile: (file: string) => string | null;
- readonly copyFile: (from: string, to: string) => void;
- readonly listDir: (dir: string) => readonly string[];
- readonly openDatabase: (dbPath: string) => Database.Database;
- readonly log: (line: string) => void;
-}
-
-interface ArchiveOutcome {
- readonly ok: true;
- readonly archivedAt: string;
- readonly counts: Readonly>;
- readonly totalDuration: number | null;
-}
-
-type ArchiveResult = 'refused' | ArchiveOutcome;
-
-/**
- * D-28, in order: re-verify the archive against the live file (a hash of bytes, never a SQLite open),
- * copy it as bytes into a temp directory, run the full chain on the copy, assert the D-23 invariants,
- * delete the copy, and re-hash both real files. Output is counts, sums, digests and pass/fail only.
- */
-export async function runArchiveProof(env: NodeJS.ProcessEnv, deps: ArchiveDeps): Promise {
- if (!archiveProofApproved(env)) return 'refused';
-
- const { log } = deps;
- const { archiveRoot, liveDb } = realPaths(env, os.homedir());
-
- const dated = [...deps.listDir(archiveRoot)].filter((name) => DATED.test(name)).sort().reverse();
- const archivedAt = dated.find((name) => deps.listDir(path.join(archiveRoot, name)).includes(DATABASE_FILE));
- if (archivedAt === undefined) throw new Error('no dated archive directory holds a ' + DATABASE_FILE);
- const archiveDir = path.join(archiveRoot, archivedAt);
- const archiveDb = path.join(archiveDir, DATABASE_FILE);
-
- const archiveBefore = deps.hashFile(archiveDb);
- const liveBefore = deps.hashFile(liveDb);
- log('LIVE_HASH_BEFORE=' + (liveBefore ?? 'absent'));
- log('ARCHIVE_DATE=' + archivedAt);
- if (archiveBefore === null) throw new Error('the archived ' + DATABASE_FILE + ' is missing');
- if (archiveBefore !== liveBefore) {
- throw new Error('the archive no longer matches the live database; refusing to migrate a stale copy (D-02)');
- }
-
- const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'wft-archive-proof-'));
- let outcome: ArchiveOutcome;
- try {
- const targets = writeTargets(tempRoot);
- deps.copyFile(archiveDb, targets.copy);
- const present = deps.listDir(archiveDir);
- for (const [index, name] of SIDECARS.entries()) {
- if (present.includes(name)) deps.copyFile(path.join(archiveDir, name), targets.sidecars[index] as string);
- }
- if (deps.hashFile(targets.copy) !== archiveBefore) throw new Error('the temp copy is not byte-identical');
-
- const before = captureInvariants(targets.copy);
- log('BEFORE counts=' + JSON.stringify(before.counts) + ' sum(duration)=' + String(before.totalDuration));
- log('BEFORE digests=' + JSON.stringify(
- Object.fromEntries(Object.entries(before.tables).map(([t, v]) => [t, v?.digest.slice(0, 16) ?? null]))
- ));
-
- const probe = probeDatabase(targets.copy);
- if (!probe.ok) throw new Error('probe failed: ' + probe.reason);
- const db = deps.openDatabase(targets.copy);
- let report;
- try {
- report = await migrateDatabase(db, {
- dbPath: targets.copy,
- dbClass: classify(probe.observed, LATEST),
- fromVersion: probe.observed.userVersion,
- backupDir: targets.backupDir,
- now: NOW
- });
- } finally {
- closeDatabase(db);
- }
- log('CHAIN class=' + report.dbClass + ' applied=' + JSON.stringify(report.applied) +
- ' toVersion=' + String(report.toVersion) + ' backup=' + (report.backupPath === null ? 'none' : 'taken'));
-
- const after = captureInvariants(targets.copy, { over: originalColumns(before) });
- // The diff lines carry cell values, so only their COUNT is ever reported (T-04-52).
- const differences = diffInvariants(predictAdoption(before), after);
- log('INVARIANT_DIFFERENCES=' + String(differences.length));
- if (differences.length > 0) {
- throw new Error(String(differences.length) + ' invariant differences the D-13 adoption does not predict');
- }
- log('AFTER counts=' + JSON.stringify(after.counts) + ' sum(duration)=' + String(after.totalDuration));
- log('AFTER digests=' + JSON.stringify(
- Object.fromEntries(Object.entries(after.tables).map(([t, v]) => [t, v?.digest.slice(0, 16) ?? null]))
- ));
- if (after.totalDuration !== before.totalDuration) throw new Error('sum(duration) changed');
- if (report.toVersion !== LATEST) throw new Error('the copy did not reach LATEST');
-
- outcome = { ok: true, archivedAt, counts: after.counts, totalDuration: after.totalDuration };
- } finally {
- fs.rmSync(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
- }
- if (fs.existsSync(tempRoot)) throw new Error('the temp copy was not deleted');
-
- const liveAfter = deps.hashFile(liveDb);
- if (deps.hashFile(archiveDb) !== archiveBefore) throw new Error('the archived database changed during the proof');
- log('LIVE_HASH_AFTER=' + (liveAfter ?? 'absent'));
- if (liveAfter !== liveBefore) throw new Error('the live database changed during the proof');
-
- log('REAL_DATA_PROOF_PASS archive');
- return outcome;
-}
-
-/* ---------------------------------------------------------------------------------------- */
-/* The real dependencies - referenced here, invoked only inside the gated test */
-/* ---------------------------------------------------------------------------------------- */
-
-const REAL_DEPS: ArchiveDeps = {
- hashFile: (file) =>
- fs.existsSync(file) ? createHash('sha256').update(fs.readFileSync(file)).digest('hex') : null,
- // Bytes, not fs.copyFile: the source is a database file and CUSTODY-03 keeps that API off it entirely.
- copyFile: (from, to) => fs.writeFileSync(to, fs.readFileSync(from)),
- listDir: (dir) => (fs.existsSync(dir) ? fs.readdirSync(dir) : []),
- openDatabase,
- log: (line) => console.log(line)
-};
-
-/* ---------------------------------------------------------------------------------------- */
-/* Always-run guards */
-/* ---------------------------------------------------------------------------------------- */
-
-interface Recorder {
- readonly calls: string[];
- readonly lines: string[];
-}
-
-const recorder = (): Recorder => ({ calls: [], lines: [] });
-
-const forbiddenDeps = (rec: Recorder): ArchiveDeps => ({
- hashFile: (): never => { rec.calls.push('hashFile'); throw new Error('hashFile must never be called'); },
- copyFile: (): never => { rec.calls.push('copyFile'); throw new Error('copyFile must never be called'); },
- listDir: (): never => { rec.calls.push('listDir'); throw new Error('listDir must never be called'); },
- openDatabase: (): never => { rec.calls.push('openDatabase'); throw new Error('openDatabase must never be called'); },
- log: (line) => rec.lines.push(line)
-});
-
-describe('D-01: the archive proof is gated on one exact token', () => {
- const REJECTED = ['', '1', 'true', 'approved', 'APPROVED-ARCHIVE', ' approved-archive', 'approved-archive '];
-
- it('is false when the variable is absent', () => {
- expect(archiveProofApproved({})).toBe(false);
- });
-
- it.each(REJECTED)('is false for %j', (value) => {
- expect(archiveProofApproved({ [APPROVAL_ENV]: value })).toBe(false);
- });
-
- it('is true only for the exact token', () => {
- expect(archiveProofApproved({ [APPROVAL_ENV]: APPROVAL_TOKEN })).toBe(true);
- });
-});
-
-describe('D-02: the proof computes its real paths without touching them', () => {
- it('derives the live database from WFT_REAL_USERDATA when it is set', () => {
- const paths = realPaths({ WFT_REAL_USERDATA: path.join('X:', 'box') }, path.join('X:', 'home'));
- expect(paths.liveDb).toBe(path.join('X:', 'box', DATABASE_FILE));
- expect(paths.archiveRoot).toBe(path.join('X:', 'home', ARCHIVE_DIR));
- });
-
- it('falls back to APPDATA, then to the home directory', () => {
- expect(realPaths({ APPDATA: path.join('X:', 'roaming') }, path.join('X:', 'home')).liveDb)
- .toBe(path.join('X:', 'roaming', 'workflow-timer', DATABASE_FILE));
- expect(realPaths({}, path.join('X:', 'home')).liveDb)
- .toBe(path.join('X:', 'home', 'AppData', 'Roaming', 'workflow-timer', DATABASE_FILE));
- });
-
- it('writes only inside the mkdtemp root', () => {
- const root = path.join(os.tmpdir(), 'wft-archive-proof-targets');
- const targets = writeTargets(root);
- for (const target of [targets.copy, targets.backupDir, ...targets.sidecars]) {
- expect(path.relative(root, target).startsWith('..'), target).toBe(false);
- expect(path.isAbsolute(path.relative(root, target)), target).toBe(false);
- }
- });
-});
-
-describe('the archive proof refuses without owner approval', () => {
- const REFUSED: (string | undefined)[] = [undefined, '', '1', 'true', 'approved', 'APPROVED-ARCHIVE'];
-
- it.each(REFUSED.map((value) => [JSON.stringify(value ?? null), value] as const))(
- 'refuses without owner approval: %s reads, hashes and copies nothing',
- async (_label, value) => {
- const rec = recorder();
- const env: NodeJS.ProcessEnv = value === undefined ? {} : { [APPROVAL_ENV]: value };
- const result = await runArchiveProof(env, forbiddenDeps(rec));
-
- expect(result).toBe('refused');
- expect(rec.calls).toEqual([]);
- expect(rec.lines).toEqual([]);
- }
- );
-});
-
-/* ---------------------------------------------------------------------------------------- */
-/* The proof itself (D-28) - skipped, never failed, without the owner's token */
-/* ---------------------------------------------------------------------------------------- */
-
-describe.skipIf(!archiveProofApproved(process.env))(
- 'D-28 archive proof (skipped: runs only behind the D-01 checkpoint with ' +
- APPROVAL_ENV + '=' + APPROVAL_TOKEN + ')',
- () => {
- it('migrates a temp copy of the archived real database with its invariants intact', async () => {
- const result = await runArchiveProof(process.env, REAL_DEPS);
- expect(result).not.toBe('refused');
- if (result === 'refused') return;
- expect(result.ok).toBe(true);
- expect(result.counts.work_sessions).toBeGreaterThan(0);
- expect(result.totalDuration).toBeGreaterThan(0);
- }, 300_000);
- }
-);
diff --git a/tests/inventory.test.ts b/tests/inventory.test.ts
deleted file mode 100644
index 24c5243..0000000
--- a/tests/inventory.test.ts
+++ /dev/null
@@ -1,211 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import fs from 'node:fs';
-import path from 'node:path';
-import { fileURLToPath } from 'node:url';
-import { LEGACY_TREE, listV121, readV121 } from './helpers/v121-source';
-import { execFileSync } from 'node:child_process';
-import { read } from './helpers/ts-imports';
-
-/*
- * Why these numbers are load-bearing.
- *
- * SPA-14 deletes legacy/pages/*.html once the behaviour parity checklist in baselines/v1.2.1/PARITY-CHECKLIST.md
- * is fully ticked. That is a one-way door: after the deletion, the record of what those 2,750 lines of inline
- * script did is git archaeology.
- *
- * The checklist is only trustworthy if the artifacts it cites still describe the tree. This file recomputes every
- * count from the LIVE source with the same regular expressions tools/baseline/inventory.sh uses, and compares the
- * COMMITTED artifacts against them element-wise, so a source change that is not regenerated fails CI (T-01-11).
- *
- * The semantics are duplicated here in TypeScript rather than shelled out: the suite must behave identically on
- * Windows and on the Ubuntu CI runner, and a child process running grep is not available on both.
- */
-
-const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
-
-// Re-verified against this repository at commit 22fe9a0, and again after Phase 7 moved the v1.2.1 renderer
-// from src/ to legacy/: the counts are unchanged, because the four page fragments deleted in the same change
-// held no addEventListener and no window.api call at all. Each number is the denominator some
-// later phase divides by, so a silent drift here understates a workload rather than failing.
-const EXPECTED_HANDLER_SITES = 106;
-const EXPECTED_API_NAMES = 21;
-const EXPECTED_API_CALL_SITES = 67;
-
-// 25, NOT 24. .planning/research/PITFALLS.md states 24; the live count is 25 and the correction
-// is recorded in PARITY-CHECKLIST.md. Phase 6 (IPC-01) reproduces this surface and Phase 8
-// (REL-06) ticks against it, so 24 would understate the parity workload by one whole API.
-// If this assertion ever fails after a Phase 8 change, regenerate the artifact AND update the
-// checklist together. Do not relax the number to make the suite green.
-const EXPECTED_PRELOAD_APIS = 25;
-const EXPECTED_IPC_CHANNELS = 25;
-
-// Exposed by preload.js, referenced nowhere in the v1.2.1 renderer. SPA-15 removes these rather than
-// porting them. Asserted as a SET, not a count, so swapping one dead API for another is caught.
-const EXPECTED_DEAD_APIS = [
- 'getSessionsByDateCompany',
- 'getSessionsGrouped',
- 'getTodaySessions',
- 'getTodaysSessionsSummary',
- 'updateCompanyExcelConfig'
-];
-
-// Finding B4. The UI calls window.api.saveSetting; the bridge exposes setSetting. This is a
-// live defect, not dead code: both call sites persist pomodoro_enabled and neither write lands.
-const EXPECTED_UNEXPOSED_CALL = 'saveSetting';
-
-const WINDOW_API_PREFIX = 'window.api.';
-
-// Where Phase 7 put the v1.2.1 renderer. The inventory records what v1.2.1 did, so it follows that code rather
-// than staying pointed at src/, which now holds the v2 tree.
-
-// Mirrors of the generator's greps.
-// grep -o "addEventListener"
-// grep -o "window\.api\.[A-Za-z0-9_]*"
-// grep -o "^ [a-zA-Z0-9_]*:" preload.js
-// grep -o "ipcMain\.\(handle\|on\)('[^']*'" main.js
-const HANDLER_RE = /addEventListener/g;
-const WINDOW_API_RE = /window\.api\.[A-Za-z0-9_]*/g;
-const PRELOAD_PROPERTY_RE = /^ {4}[A-Za-z0-9_]*:/gm;
-const IPC_CHANNEL_RE = /ipcMain\.(?:handle|on)\('([^']*)'/g;
-
-/*
- * grep -r --include=*.html --include=*.js legacy/ - over the v1.2.1 renderer WHEREVER IT LIVES.
- *
- * SPA-14 deleted it in 08-F. Repointing this file at baselines/v1.2.1/*.tsv would have destroyed the whole point:
- * those artifacts are what the counts are compared AGAINST, and a test that reads the artifact and compares it
- * with the artifact proves nothing. listV121/readV121 take the bytes out of the commit that last carried them,
- * so every count below is still RECOMPUTED from the v1.2.1 source and still diffed against the artifact.
- */
-const collectSources = (dir: string): string[] =>
- listV121(dir).filter((file) => file.endsWith('.html') || file.endsWith('.js'));
-
-const readRepoFile = (rel: string): string => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
-
-// The committed artifacts are LF in the index; .gitattributes pins baselines/** to eol=lf, but
-// tolerate CRLF anyway so a checkout with different settings cannot produce a phantom failure.
-const readArtifactLines = (rel: string): string[] =>
- readRepoFile(rel).split(/\r?\n/).filter((line) => line !== '');
-
-const rendererFiles = collectSources(LEGACY_TREE);
-const rendererText = rendererFiles.map((file) => readV121(file)).join(String.fromCharCode(10));
-const preloadSource = readV121('preload.js');
-const mainSource = readV121('main.js');
-
-const countMatches = (text: string, pattern: RegExp): number => (text.match(pattern) ?? []).length;
-
-// LC_ALL=C sort is byte order; for ASCII that is exactly JS's default code-unit sort.
-const sortBytewise = (values: string[]): string[] => [...values].sort();
-
-const calledApiNames: string[] = (rendererText.match(WINDOW_API_RE) ?? [])
- .map((match) => match.slice(WINDOW_API_PREFIX.length));
-
-const exposedApiNames: string[] = sortBytewise(
- (preloadSource.match(PRELOAD_PROPERTY_RE) ?? []).map((match) => match.trim().replace(/:$/, ''))
-);
-
-const ipcChannelNames: string[] = sortBytewise(
- [...mainSource.matchAll(IPC_CHANNEL_RE)].map((match) => match[1] ?? '')
-);
-
-describe('CUSTODY-09: the v1.2.1 behavior inventory is complete and regenerable', () => {
- it(`registers exactly ${EXPECTED_HANDLER_SITES} addEventListener sites under legacy/`, () => {
- expect(countMatches(rendererText, HANDLER_RE)).toBe(EXPECTED_HANDLER_SITES);
- });
-
- it(`calls exactly ${EXPECTED_API_NAMES} distinct window.api names`, () => {
- expect(new Set(calledApiNames).size).toBe(EXPECTED_API_NAMES);
- });
-
- it(`calls window.api from exactly ${EXPECTED_API_CALL_SITES} sites`, () => {
- expect(calledApiNames.length).toBe(EXPECTED_API_CALL_SITES);
- });
-
- it(`exposes exactly ${EXPECTED_PRELOAD_APIS} APIs from preload.js — the corrected figure, not 24`, () => {
- expect(exposedApiNames.length).toBe(EXPECTED_PRELOAD_APIS);
- });
-
- it(`answers on exactly ${EXPECTED_IPC_CHANNELS} ipcMain channels in main.js`, () => {
- expect(ipcChannelNames.length).toBe(EXPECTED_IPC_CHANNELS);
- });
-
- it('preload-surface.txt still matches preload.js exactly, in order', () => {
- // Order matters as well as membership: the artifact is sorted, so an order difference
- // means the extraction changed shape and the Phase 8 diff would be unreadable.
- expect(readArtifactLines('baselines/v1.2.1/preload-surface.txt')).toEqual(exposedApiNames);
- });
-
- it('ipc-channels.txt still matches main.js exactly, in order', () => {
- expect(readArtifactLines('baselines/v1.2.1/ipc-channels.txt')).toEqual(ipcChannelNames);
- });
-
- it('handlers.tsv and api-calls.tsv still have one row per live site', () => {
- // The two TSVs are too large to compare element-wise usefully, but a row count that
- // disagrees with the live tree is the same staleness signal.
- expect(readArtifactLines('baselines/v1.2.1/handlers.tsv').length)
- .toBe(EXPECTED_HANDLER_SITES);
- expect(readArtifactLines('baselines/v1.2.1/api-calls.tsv').length)
- .toBe(EXPECTED_API_CALL_SITES);
- });
-
- it('has exactly five exposed-but-never-called preload APIs, and they are the named five', () => {
- const called = new Set(calledApiNames);
- const dead = exposedApiNames.filter((name) => !called.has(name));
- expect(dead).toEqual(EXPECTED_DEAD_APIS);
- });
-
- it(`has exactly one called-but-never-exposed name, and it is ${EXPECTED_UNEXPOSED_CALL} (B4)`, () => {
- const exposed = new Set(exposedApiNames);
- const unexposed = sortBytewise([...new Set(calledApiNames)].filter((n) => !exposed.has(n)));
- expect(unexposed).toEqual([EXPECTED_UNEXPOSED_CALL]);
- });
-
- it('reads no database and no user content', () => {
- // The inventory is derived from source text only, which is what makes it safe to run
- // before the better-sqlite3 barrier (plan 01-07) and safe to commit to a public repo.
- expect(rendererFiles.every((f) => f.endsWith('.html') || f.endsWith('.js'))).toBe(true);
- expect(rendererFiles.some((f) => f.endsWith('.db'))).toBe(false);
- });
-});
-
-/*
- * 08-REVIEW-TIMER WR-09. The generator behind four artifacts whose whole purpose is to be authoritative about a
- * tree nobody can open any more could produce them empty: `set -e` does not fail a pipeline on a non-zero LEFT
- * side without `set -o pipefail`, so a `git archive` that failed left `tar` succeeding on empty input, $SCAN an
- * empty directory, and all four artifacts regenerated as empty files. The assertions above would catch the
- * committed result, so this is a fragility rather than a hole - but a generator should not be able to do it.
- */
-describe('WR-09: the inventory generator cannot regenerate an empty artifact', () => {
- const script = read('tools/baseline/inventory.sh');
-
- it('fails the pipeline when git archive does, rather than tarring nothing', () => {
- expect(script).toContain('set -euo pipefail');
- expect(script, 'set -e alone lets a failed left-hand side through').not.toMatch(/^set -eu$/m);
- });
-
- it('checks the v1.2.1 source really materialised before it scans', () => {
- expect(script).toContain('did not materialise');
- for (const marker of ['/legacy', 'main.js', 'preload.js']) {
- expect(script).toContain(marker);
- }
- });
-
- it('is a real difference, not a spelling: the control shows what set -eu alone lets through', () => {
- const run = (flags: string): number => {
- try {
- execFileSync('bash', [flags, '-c', 'false | true'], { stdio: 'ignore' });
- return 0;
- } catch (error) {
- return (error as { status?: number }).status ?? -1;
- }
- };
- expect(run('-eu'), 'set -eu already failed a pipeline, so pipefail changes nothing').toBe(0);
- expect(run('-euo')).not.toBe(0);
- });
-
- it('cleans up with a trap that cannot itself change the exit status', () => {
- // `trap '[ -n "$TMP" ] && rm -rf "$TMP"' EXIT` returns non-zero when TMP is empty, which under set -e
- // can alter what the script reports.
- expect(script).not.toContain('[ -n "$TMP" ] && rm -rf');
- expect(script).toContain('trap \'rm -rf "${TMP:-}"\' EXIT');
- });
-});
diff --git a/tests/ipc-contract.test.ts b/tests/ipc-contract.test.ts
index c21e373..7e6b9c7 100644
--- a/tests/ipc-contract.test.ts
+++ b/tests/ipc-contract.test.ts
@@ -37,7 +37,7 @@ const EVENTS = ['app:playSound', 'timer:tick', 'pomodoro:tick', 'data:changed'];
const EXPORTED_SCHEMAS = [
'CompanySchema', 'DayProgressSchema', 'DurationSecondsSchema', 'EpochMsSchema', 'IdSchema', 'LocalDateSchema',
'PomodoroCountsSchema', 'PomodoroIntervalSchema', 'PomodoroSessionSchema', 'PomodoroSnapshotSchema',
- 'PomodoroStatusSchema', 'SettingsSchema', 'SoundIdSchema', 'StreakSchema', 'TimerModeSchema',
+ 'PomodoroStatusSchema', 'SettingsSchema', 'SoundChoiceSchema', 'SoundIdSchema', 'StreakSchema', 'TimerModeSchema',
'TimerSnapshotSchema', 'TimerStatusSchema', 'WeekTotalsSchema', 'WorkSessionSchema'
];
@@ -165,6 +165,9 @@ function v121SettingSeeds(): Map {
return seeds;
}
+// Settings v2 added on its own: v1.2.1 never seeded them, so a database that came from it reads their defaults.
+const V2_ONLY_SETTINGS = ['soundEnabled', 'notificationSound'];
+
// [domain key, v1.2.1 seed key, default].
const SETTING_SEEDS: [keyof Settings, string, Settings[keyof Settings]][] = [
['dailyTargetSeconds', 'daily_target', 28800],
@@ -244,7 +247,7 @@ describe('D-16 / SHARED-05: the channel catalogue', () => {
for (const [name, schema] of Object.entries(ipcEvents)) {
expect(name, 'D-16: event names are domain:action in camelCase').toMatch(/^[a-z]+:[a-z][A-Za-z]*$/);
expect(defOf(schema, name).type, 'an event payload is an object schema').toBe('object');
- expect(schema.safeParse({ sound: 'goalReached', extra: 1 }).success,
+ expect(schema.safeParse({ sound: 'goalReached', choice: 'classic', extra: 1 }).success,
'an event payload must reject an undeclared key').toBe(false);
}
});
@@ -448,18 +451,18 @@ describe('D-19: no date, coercion, catch-all, transform or default anywhere in t
describe('D-20: DEFAULT_SETTINGS mirrors v1.2.1', () => {
const seeds = v121SettingSeeds();
- it('parses under SettingsSchema, holds exactly its 10 keys, and is frozen', () => {
+ it('parses under SettingsSchema, holds exactly its 12 keys, and is frozen', () => {
expect(() => SettingsSchema.parse(DEFAULT_SETTINGS)).not.toThrow();
const schemaKeys = Object.keys(SettingsSchema.shape).sort();
expect(Object.keys(DEFAULT_SETTINGS).sort()).toEqual(schemaKeys);
- expect(schemaKeys).toHaveLength(10);
+ expect(schemaKeys).toHaveLength(12);
expect(Object.isFrozen(DEFAULT_SETTINGS), 'D-20: a caller could rewrite the shared defaults').toBe(true);
});
it('accounts for every v1.2.1 seed: mapped to a domain key, never read, or retired with the export', () => {
const mapped = SETTING_SEEDS.map(([, seed]) => seed);
expect([...seeds.keys()].sort()).toEqual([...mapped, ...UNREAD_SEEDS, ...RETIRED_SEEDS].sort());
- expect(SETTING_SEEDS.map(([key]) => key).sort()).toEqual(Object.keys(SettingsSchema.shape).sort());
+ expect([...SETTING_SEEDS.map(([key]) => key), ...V2_ONLY_SETTINGS].sort()).toEqual(Object.keys(SettingsSchema.shape).sort());
});
it.each(SETTING_SEEDS)('%s defaults to the v1.2.1 seed %s', (key, seed, expected) => {
diff --git a/tests/ipc-parity.test.ts b/tests/ipc-parity.test.ts
deleted file mode 100644
index 8e6030a..0000000
--- a/tests/ipc-parity.test.ts
+++ /dev/null
@@ -1,185 +0,0 @@
-// Criterion 6: every entry on the Phase 1 window.api inventory has a counterpart in the new typed bridge, and a grep
-// proves navigate is not a channel anywhere in the v2 source. The inventory is read from the committed artifact
-// tests/inventory.test.ts holds equal to v1.2.1's preload.js, so this cannot pass against a stale list.
-
-import { describe, expect, it } from 'vitest';
-import { execFileSync } from 'node:child_process';
-import fs from 'node:fs';
-import path from 'node:path';
-import { ipcContract, ipcEvents } from '../src/shared/ipc/contract';
-import { read, repoRoot, stripCommentsAndStrings } from './helpers/ts-imports';
-import type { IpcChannel } from '../src/shared/types';
-
-type Disposition =
- | { readonly channel: IpcChannel }
- /** Removed with the Google Sheets export by the owner on 2026-09-11; the columns stay in krono.db unread. */
- | { readonly removed: 'owner-removed'; readonly why: string }
- /** Deleted on purpose, not overlooked. */
- | { readonly removed: 'deleted'; readonly why: string }
- | { readonly removed: 'not-ported'; readonly why: string };
-
-// One row per name in baselines/v1.2.1/preload-surface.txt. A name with no row fails the totality check below.
-const DISPOSITIONS: Readonly> = {
- // It hid the window rather than closing it, so window:hide is the capability it actually provided. What changed
- // on 2026-09-13 is which button asks for it: the titlebar X asks app:quit now.
- closeWindow: { channel: 'window:hide' },
- createCompany: { channel: 'companies:create' },
- deleteAllSessions: { channel: 'sessions:deleteAll' },
- deleteCompany: { channel: 'companies:delete' },
- deleteSession: { channel: 'sessions:delete' },
- exportDayEnd: { removed: 'owner-removed', why: 'the Google Sheets export the owner removed on 2026-09-11' },
- getCompanies: { channel: 'companies:list' },
- getCompany: { channel: 'companies:get' },
- getCurrentStreak: { channel: 'stats:streak' },
- getSessions: { channel: 'sessions:list' },
- getSessionsByDate: { channel: 'sessions:listByDateRange' },
- getSessionsByDateCompany: { channel: 'sessions:listByDateAndCompany' },
- getSessionsGrouped: {
- removed: 'not-ported',
- why: 'exposed but never called (SPA-15). Work History groups the sessions a date range returns, in the ' +
- 'renderer, rather than asking the database for a grouping it then flattens'
- },
- getSetting: { channel: 'settings:get' },
- getTodaySessions: { channel: 'sessions:listByDateRange' },
- getTodaysSessionsSummary: { channel: 'stats:dayProgress' },
- getWeekTotal: { channel: 'stats:weekTotals' },
- minimizeWindow: { channel: 'window:hide' },
- navigateTo: {
- removed: 'deleted',
- why: 'S1: an unvalidated page argument reached loadFile. HashRouter needs no server to rewrite a path ' +
- 'under file://, so the channel is deleted rather than validated'
- },
- previewDayEnd: { removed: 'owner-removed', why: 'the Google Sheets export the owner removed on 2026-09-11' },
- saveSession: { channel: 'sessions:create' },
- setSetting: { channel: 'settings:update' },
- updateCompany: { channel: 'companies:update' },
- updateCompanyExcelConfig: {
- removed: 'owner-removed',
- why: 'the per-company sheet columns; migration 0002 took excel_column and note_column off the table'
- },
- updateSession: { channel: 'sessions:update' }
-};
-
-// B4: the UI called window.api.saveSetting, which preload.js never exposed, so two pomodoro_enabled writes were lost.
-const UNEXPOSED_CALL: Readonly> = { saveSetting: 'settings:update' };
-
-// Channels with no v1.2.1 counterpart, each with the capability that earned it. Stated so the map is total in both
-// directions: a channel added without a reason shows up here as an unexplained newcomer.
-const NEW_IN_V2: Readonly> = {
- 'timer:getSnapshot': 'the clock moved into main (X1); v1.2.1 kept it in the renderer and persisted a startTime',
- 'timer:start': 'the clock moved into main',
- 'timer:pause': 'the clock moved into main',
- 'timer:reset': 'the clock moved into main',
- 'timer:setMode': 'CORE-14: changing the mode no longer resets what was counted (CB-1)',
- 'timer:stopAndSave':
- 'WR-06: saving a session and clearing the accumulator is one transaction, because two invokes cannot be ' +
- 'atomic - one order duplicates the work on the next launch, the other destroys it',
- 'pomodoro:getSnapshot': 'the cycle is a main-process state machine, not renderer variables (CORE-11)',
- 'pomodoro:start': 'the cycle is a main-process state machine',
- 'pomodoro:pause': 'the cycle is a main-process state machine',
- 'pomodoro:abort': 'POMO-04: an abandoned interval records nothing and advances no counter',
- 'pomodoro:skipBreak': 'POMO-05: a break can be ended from the UI',
- 'pomodoro:counts': 'POMO-09: the daily and weekly pomodoro counts, which v1.2.1 never showed',
- 'window:claimHideNotice':
- 'owner decision 2026-09-13: the hide button explains once where the window went, and the flag is a row in ' +
- 'app_state rather than a localStorage key the renderer may not have (ARCH-03)',
- 'app:quit':
- 'owner decision 2026-09-13: v1.2.1 had no way to end the app from the window at all - closeWindow hid it, ' +
- 'and only the tray menu could quit'
-};
-
-const artifactLines = (rel: string): string[] =>
- fs.readFileSync(path.join(repoRoot, rel), 'utf8').split(/\r?\n/).filter((line) => line !== '');
-
-const INVENTORY = artifactLines('baselines/v1.2.1/preload-surface.txt');
-const CHANNELS = new Set(Object.keys(ipcContract));
-
-describe('criterion 6: the v1.2.1 window.api surface has a counterpart in the typed bridge', () => {
- it('has a row for every name on the inventory, and for no name that is not on it', () => {
- expect(INVENTORY.length, 'the inventory artifact is empty, so this file proves nothing').toBe(25);
- expect(Object.keys(DISPOSITIONS).sort(), 'the inventory and this map disagree').toEqual([...INVENTORY].sort());
- });
-
- it.each(INVENTORY)('%s', (name) => {
- const disposition = DISPOSITIONS[name];
- expect(disposition, name + ' has no disposition').toBeDefined();
- if (disposition !== undefined && 'channel' in disposition) {
- expect(CHANNELS.has(disposition.channel),
- name + ' is mapped to ' + disposition.channel + ', which the contract does not declare').toBe(true);
- } else {
- expect(disposition?.why.length, name + ' is unported with no reason given').toBeGreaterThan(20);
- }
- });
-
- it('ports 20 of the 25 and names the five it does not, one of them the deliberate deletion', () => {
- const unported = Object.entries(DISPOSITIONS).filter(([, d]) => 'removed' in d);
- expect(unported.map(([name]) => name).sort())
- .toEqual(['exportDayEnd', 'getSessionsGrouped', 'navigateTo', 'previewDayEnd', 'updateCompanyExcelConfig']);
- expect(unported.filter(([, d]) => 'removed' in d && d.removed === 'owner-removed')).toHaveLength(3);
- expect(Object.keys(DISPOSITIONS).length - unported.length).toBe(20);
- });
-
- it('closes B4: the write the UI made through a name preload never exposed now has a channel', () => {
- for (const [name, channel] of Object.entries(UNEXPOSED_CALL)) {
- expect(CHANNELS.has(channel), name + ' still has no counterpart').toBe(true);
- }
- });
-
- it('accounts for every channel in the other direction too', () => {
- const ported = new Set(Object.values(DISPOSITIONS).flatMap((d) => 'channel' in d ? [d.channel as string] : []));
- const unexplained = [...CHANNELS].filter((channel) => !ported.has(channel) && !(channel in NEW_IN_V2));
- expect(unexplained,
- 'these channels answer to no v1.2.1 capability and no stated new one:\n ' + unexplained.join('\n '))
- .toEqual([]);
- });
-
- it('answers on at least as many channels as v1.2.1 did', () => {
- expect(CHANNELS.size).toBeGreaterThanOrEqual(artifactLines('baselines/v1.2.1/ipc-channels.txt').length);
- });
-});
-
-/*
- * The grep half of criterion 6, over the v2 tree only: main.js, database/db.js and legacy/pages/*.html are the v1.2.1
- * code this milestone replaces and Phase 7 deletes. What is asserted is stronger than "no channel is called
- * navigate" - no string literal anywhere in the new source says it, apart from Electron's own will-navigate event,
- * which is the guard that refuses navigation rather than a channel that performs it.
- */
-describe('criterion 6: navigate is not a channel anywhere in the v2 source', () => {
- const V2_TREES = ['src/main', 'src/preload', 'src/shared', 'src/renderer/src'];
- // Electron's own event names. will-navigate and will-redirect are the guards that REFUSE a navigation;
- // did-navigate and did-navigate-in-page are how the packaged smoke tells a document load from a hash change.
- const ALLOWED = new Set(['will-navigate', 'did-navigate', 'did-navigate-in-page']);
-
- const v2Files = (): string[] =>
- execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', '--', ...V2_TREES], {
- cwd: repoRoot,
- encoding: 'utf8'
- })
- .split('\n')
- .map((line) => line.trim())
- .filter((file) => /\.(ts|tsx)$/.test(file) && fs.existsSync(path.join(repoRoot, file)));
-
- it('reads the v2 source it claims to', () => {
- const files = v2Files();
- expect(files, 'the scan cannot see the modules it guards').toEqual(expect.arrayContaining([
- 'src/main/window.ts', 'src/main/ipc/register.ts', 'src/preload/bridge.ts', 'src/shared/ipc/contract.ts'
- ]));
- });
-
- it('finds no string literal saying navigate but the will-navigate guard', () => {
- const found = v2Files().flatMap((file) =>
- stripCommentsAndStrings(file, read(file)).strings
- .filter((literal) => literal.value.includes('navigate'))
- .map((literal) => file + ' ' + JSON.stringify(literal.value)));
-
- const offenders = found.filter((entry) => ![...ALLOWED].some((allowed) => entry.endsWith('"' + allowed + '"')));
- expect(offenders, 'S1: navigate is back in the source:\n ' + offenders.join('\n ')).toEqual([]);
- expect(found.length, 'the will-navigate guard is gone, so this scan proves nothing').toBeGreaterThan(0);
- });
-
- it('declares no channel and no event whose name contains it', () => {
- for (const name of [...Object.keys(ipcContract), ...Object.keys(ipcEvents)]) {
- expect(name.toLowerCase()).not.toContain('navigate');
- }
- });
-});
diff --git a/tests/legacy-timer-proof.test.ts b/tests/legacy-timer-proof.test.ts
deleted file mode 100644
index 79abe0b..0000000
--- a/tests/legacy-timer-proof.test.ts
+++ /dev/null
@@ -1,412 +0,0 @@
-// D-01/D-02/D-35.3: the continuity tool's guards, its refusal, and its whole orchestration - over stand-ins only.
-// Nothing here launches Electron, reads the live krono.db or touches ~/workflow-timer-archive.
-
-import { afterAll, describe, expect, it } from 'vitest';
-import { execFileSync } from 'node:child_process';
-import { createHash } from 'node:crypto';
-import fs from 'node:fs';
-import os from 'node:os';
-import path from 'node:path';
-import { fileURLToPath } from 'node:url';
-import {
- assertTempUserData,
- elapsedOf,
- parseManifestPins,
- parseProofArgs,
- runContinuityProof,
- verifyArchivedAppPins
-} from '../tools/db/legacy-timer-proof.mjs';
-import type { ImportObservation, VerifiedPin } from '../tools/db/legacy-timer-proof.mjs';
-
-const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
-const TOOL = path.join('tools', 'db', 'legacy-timer-proof.mjs');
-const ARCHIVE_ROOT = path.join(os.homedir(), 'workflow-timer-archive');
-const SMOKE_SEED_ENV = 'WORKFLOW_SMOKE_SEED_TIMER_STATE';
-
-const tempRoots: string[] = [];
-
-function sandbox(tag: string): string {
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wft-proof-' + tag + '-'));
- tempRoots.push(dir);
- return dir;
-}
-
-afterAll(() => {
- for (const dir of tempRoots) fs.rmSync(dir, { recursive: true, force: true });
-});
-
-/* ---------------------------------------------------------------------------------------- */
-/* The pins */
-/* ---------------------------------------------------------------------------------------- */
-
-const EXE_BYTES = Buffer.from('synthetic Workflow.exe bytes');
-const ASAR_BYTES = Buffer.from('synthetic app.asar bytes');
-
-const digestOf = (bytes: Buffer): string => createHash('sha256').update(bytes).digest('hex');
-
-const manifestText = (exeSha: string, asarSha: string, exeBytes = EXE_BYTES.length): string => [
- '| Extracted file | Bytes | SHA-256 |',
- '|---|---|---|',
- '| `Workflow.exe` | ' + exeBytes.toLocaleString('en-US') + ' | `' + exeSha + '` |',
- '| `resources/app.asar` | ' + String(ASAR_BYTES.length) + ' | `' + asarSha + '` |'
-].join('\n');
-
-const readSynthetic = (file: string): Buffer => (file === 'Workflow.exe' ? EXE_BYTES : ASAR_BYTES);
-
-describe('D-35.3: the archived app is hash-pinned before it is ever launched', () => {
- const exeSha = digestOf(EXE_BYTES);
- const asarSha = digestOf(ASAR_BYTES);
-
- it('parses both pinned rows out of a manifest table', () => {
- const pins = parseManifestPins(manifestText(exeSha, asarSha));
- expect(pins['Workflow.exe']).toEqual({ bytes: EXE_BYTES.length, sha256: exeSha });
- expect(pins['resources/app.asar']).toEqual({ bytes: ASAR_BYTES.length, sha256: asarSha });
- });
-
- it('verifies both files when every byte matches', () => {
- const verified: VerifiedPin[] = verifyArchivedAppPins(manifestText(exeSha, asarSha), readSynthetic);
- expect(verified.map((pin) => pin.file)).toEqual(['Workflow.exe', 'resources/app.asar']);
- });
-
- it('throws naming Workflow.exe when its digest differs', () => {
- expect(() => verifyArchivedAppPins(manifestText('a'.repeat(64), asarSha), readSynthetic))
- .toThrow(/Workflow\.exe does not match its manifest pin/);
- });
-
- it('throws naming resources/app.asar when its digest differs', () => {
- expect(() => verifyArchivedAppPins(manifestText(exeSha, 'b'.repeat(64)), readSynthetic))
- .toThrow(/resources\/app\.asar does not match its manifest pin/);
- });
-
- it('throws when the recorded size differs, before any digest is trusted', () => {
- expect(() => verifyArchivedAppPins(manifestText(exeSha, asarSha, 999), readSynthetic))
- .toThrow(/Workflow\.exe is \d+ bytes; the manifest pins 999/);
- });
-
- it('throws when the manifest has no row for a pinned file', () => {
- expect(() => verifyArchivedAppPins('| `other.exe` | 1 | `' + 'c'.repeat(64) + '` |', readSynthetic))
- .toThrow(/Workflow\.exe has no SHA-256 pin/);
- });
-});
-
-/* ---------------------------------------------------------------------------------------- */
-/* The launch target */
-/* ---------------------------------------------------------------------------------------- */
-
-describe('D-01/D-02: a launch target must be a temp directory outside the production userData', () => {
- it('accepts a freshly made temp directory', () => {
- const production = sandbox('prod-accept');
- const dir = path.join(sandbox('target'), 'ud');
- fs.mkdirSync(dir);
- expect(assertTempUserData(dir, production)).toBe(dir);
- });
-
- it('refuses a path that is not under the temp root', () => {
- const production = sandbox('prod-nontemp');
- expect(() => assertTempUserData(path.join(os.homedir(), 'wft-not-a-temp-dir'), production))
- .toThrow(/is not under /);
- });
-
- it('refuses a path inside the production userData directory', () => {
- const production = sandbox('prod-inside');
- const dir = path.join(production, 'ud');
- fs.mkdirSync(dir);
- expect(() => assertTempUserData(dir, production))
- .toThrow(/is inside the production userData directory/);
- });
-
- it('refuses the production userData directory itself', () => {
- const production = sandbox('prod-self');
- expect(() => assertTempUserData(production, production))
- .toThrow(/is inside the production userData directory/);
- });
-});
-
-/* ---------------------------------------------------------------------------------------- */
-/* The arguments */
-/* ---------------------------------------------------------------------------------------- */
-
-describe('the CLI arguments are parsed, never guessed', () => {
- it('reads a mode, the hard-kill variant and the approval token verbatim', () => {
- expect(parseProofArgs(['--mode=v121', '--hard-kill', '--owner-approved=continuity']))
- .toEqual({ mode: 'v121', hardKill: true, ownerApproved: 'continuity' });
- });
-
- it('keeps a non-matching approval value verbatim rather than normalizing it', () => {
- expect(parseProofArgs(['--mode=v121', '--owner-approved=Continuity']).ownerApproved).toBe('Continuity');
- expect(parseProofArgs(['--mode=v121', '--owner-approved=']).ownerApproved).toBe('');
- });
-
- it('defaults to no mode, no hard kill and no approval', () => {
- expect(parseProofArgs([])).toEqual({ mode: null, hardKill: false, ownerApproved: null });
- expect(parseProofArgs(['--mode=new-build']))
- .toEqual({ mode: 'new-build', hardKill: false, ownerApproved: null });
- });
-
- it('throws on an unknown mode and on an unrecognized argument', () => {
- expect(() => parseProofArgs(['--mode=production'])).toThrow(/unknown --mode=production/);
- expect(() => parseProofArgs(['--owner-aproved=continuity'])).toThrow(/unrecognized argument/);
- });
-
- it('floors the saved elapsed and reports whether v1.2.1 was running', () => {
- expect(elapsedOf('{"elapsed":7.9,"running":true}')).toEqual({ elapsedSeconds: 7, running: true });
- expect(elapsedOf('{"elapsed":0,"running":false}')).toEqual({ elapsedSeconds: 0, running: false });
- expect(() => elapsedOf('{"running":true}')).toThrow(/no numeric elapsed/);
- });
-});
-
-/* ---------------------------------------------------------------------------------------- */
-/* Recording stand-ins */
-/* ---------------------------------------------------------------------------------------- */
-
-interface Recorder {
- readonly paths: string[];
- readonly calls: string[];
- readonly lines: string[];
-}
-
-const recorder = (): Recorder => ({ paths: [], calls: [], lines: [] });
-
-const forbidden = (rec: Recorder, name: string) => (): never => {
- rec.calls.push(name);
- throw new Error(name + ' must never be called here');
-};
-
-const isUnder = (parent: string, child: string): boolean => {
- const rel = path.relative(parent.toLowerCase(), child.toLowerCase());
- return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
-};
-
-describe('mode new-build touches nothing real', () => {
- it('never resolves the archive, never hashes the live database and prints no LIVE_HASH line', async () => {
- const rec = recorder();
- const root = sandbox('dry');
- const production = path.join(root, 'production-userdata');
- fs.mkdirSync(production);
- const binary = path.join(root, 'Workflow.exe');
- let seededRaw: string | null = null;
-
- const result = await runContinuityProof(
- { mode: 'new-build', binary, productionDir: production, timeoutMs: 1000 },
- {
- resolveLaunch: forbidden(rec, 'resolveLaunch'),
- verifyPins: forbidden(rec, 'verifyPins'),
- launchElectron: forbidden(rec, 'launchElectron'),
- hashFile: (file: string) => {
- rec.calls.push('hashFile');
- rec.paths.push(file);
- return 'f'.repeat(64);
- },
- readFile: forbidden(rec, 'readFile'),
- launchSmoke: (options) => {
- rec.calls.push('launchSmoke');
- rec.paths.push(options.binary, options.userDataDir);
- const seed = options.env[SMOKE_SEED_ENV];
- if (seed !== undefined) seededRaw = seed;
- return Promise.resolve({ exit: { code: 0 } });
- },
- observeImport: (dbPath: string): ImportObservation => {
- rec.paths.push(dbPath);
- const raw = seededRaw ?? '';
- return { stored: { raw, elapsedSeconds: elapsedOf(raw).elapsedSeconds }, workSessions: 0 };
- },
- log: (line: string) => rec.lines.push(line)
- }
- );
-
- expect(result.ok, rec.lines.join('\n')).toBe(true);
- expect(result.code).toBe(0);
- expect(rec.calls.filter((name) => name === 'resolveLaunch' || name === 'verifyPins')).toEqual([]);
- expect(rec.calls.filter((name) => name === 'hashFile')).toEqual([]);
- expect(rec.calls.filter((name) => name === 'launchElectron' || name === 'readFile')).toEqual([]);
-
- const liveDb = path.join(production, 'krono.db');
- for (const touched of rec.paths) {
- expect(isUnder(ARCHIVE_ROOT, touched), touched).toBe(false);
- expect(isUnder(production, touched), touched).toBe(false);
- expect(touched).not.toBe(liveDb);
- // Every krono.db the dry run names is the temp copy it just made.
- if (path.basename(touched) === 'krono.db') expect(isUnder(os.tmpdir(), touched), touched).toBe(true);
- }
- expect(rec.lines.filter((line) => line.startsWith('LIVE_HASH_'))).toEqual([]);
- expect(rec.lines.filter((line) => line.includes('REAL_DATA_PROOF_PASS'))).toEqual([]);
- expect(rec.lines).toContain('CONTINUITY_DRY_RUN_PASS');
- expect(rec.lines).toContain('RAW_EQUAL=true');
- expect(rec.lines).toContain('ELAPSED_EQUAL=true');
- expect(rec.lines).toContain('WORK_SESSIONS=0');
- });
-});
-
-/* ---------------------------------------------------------------------------------------- */
-/* The mode v121 output contract */
-/* ---------------------------------------------------------------------------------------- */
-
-const V121_RAW = JSON.stringify({ elapsed: 7, running: true, pomodoroMode: false, lastUpdated: 1_757_000_000_000 });
-
-async function runApprovedV121(rec: Recorder, hashes: readonly string[]): Promise<{ ok: boolean; code: number }> {
- const root = sandbox('v121');
- const production = path.join(root, 'production-userdata');
- fs.mkdirSync(production);
- let hashCall = 0;
-
- return await runContinuityProof(
- {
- mode: 'v121',
- ownerApproved: 'continuity',
- binary: path.join(root, 'Workflow.exe'),
- productionDir: production,
- timeoutMs: 1000
- },
- {
- resolveLaunch: () => ({ executable: path.join(root, 'archived.exe'), args: ['--no-sandbox'] }),
- verifyPins: (): VerifiedPin[] => [{ file: 'Workflow.exe', bytes: 176_903_168, sha256: 'a'.repeat(64) }],
- launchElectron: () => {
- rec.calls.push('launchElectron');
- return Promise.resolve({ raw: V121_RAW });
- },
- launchSmoke: () => Promise.resolve({ exit: { code: 0 } }),
- hashFile: (file: string) => {
- rec.paths.push(file);
- const value = hashes[hashCall] ?? null;
- hashCall += 1;
- return value;
- },
- observeImport: (): ImportObservation => ({
- stored: { raw: V121_RAW, elapsedSeconds: 7 },
- workSessions: 0
- }),
- log: (line: string) => rec.lines.push(line)
- }
- );
-}
-
-describe('mode v121 reports the live database before and after, and passes only when the two agree', () => {
- it('prints both hashes and passes when they are equal', async () => {
- const rec = recorder();
- const hash = 'c'.repeat(64);
- const result = await runApprovedV121(rec, [hash, hash]);
-
- expect(result.ok, rec.lines.join('\n')).toBe(true);
- expect(rec.lines).toContain('LIVE_HASH_BEFORE=' + hash);
- expect(rec.lines).toContain('LIVE_HASH_AFTER=' + hash);
- expect(rec.lines).toContain('REAL_DATA_PROOF_PASS continuity');
- expect(rec.paths.every((file) => path.basename(file) === 'krono.db')).toBe(true);
- });
-
- it('prints both hashes and refuses to pass when they differ', async () => {
- const rec = recorder();
- const result = await runApprovedV121(rec, ['c'.repeat(64), 'd'.repeat(64)]);
-
- expect(result.ok).toBe(false);
- expect(result.code).not.toBe(0);
- expect(rec.lines).toContain('LIVE_HASH_BEFORE=' + 'c'.repeat(64));
- expect(rec.lines).toContain('LIVE_HASH_AFTER=' + 'd'.repeat(64));
- expect(rec.lines.filter((line) => line.includes('REAL_DATA_PROOF_PASS'))).toEqual([]);
- });
-
- it('reports an absent live database as absent, both times', async () => {
- const rec = recorder();
- const result = await runApprovedV121(rec, []);
-
- expect(result.ok, rec.lines.join('\n')).toBe(true);
- expect(rec.lines).toContain('LIVE_HASH_BEFORE=absent');
- expect(rec.lines).toContain('LIVE_HASH_AFTER=absent');
- });
-});
-
-/* ---------------------------------------------------------------------------------------- */
-/* The refusal (D-01) */
-/* ---------------------------------------------------------------------------------------- */
-
-describe('mode v121 refuses without owner approval', () => {
- const REJECTED: (string | undefined)[] = [undefined, '', 'yes', '1', 'true', 'archive', 'Continuity', ' continuity'];
-
- it.each(REJECTED.map((token) => [JSON.stringify(token ?? null), token] as const))(
- 'refuses without owner approval: %s calls no dependency and touches nothing',
- async (_label, token) => {
- const rec = recorder();
- const result = await runContinuityProof(
- { mode: 'v121', ownerApproved: token ?? null },
- {
- resolveLaunch: forbidden(rec, 'resolveLaunch'),
- verifyPins: forbidden(rec, 'verifyPins'),
- launchElectron: forbidden(rec, 'launchElectron'),
- launchSmoke: forbidden(rec, 'launchSmoke'),
- hashFile: forbidden(rec, 'hashFile'),
- readFile: forbidden(rec, 'readFile'),
- observeImport: forbidden(rec, 'observeImport'),
- log: (line: string) => rec.lines.push(line)
- }
- );
-
- expect(result.ok).toBe(false);
- expect(result.code).not.toBe(0);
- expect(result.refused).toBe(true);
- expect(rec.calls).toEqual([]);
- expect(rec.paths).toEqual([]);
- expect(rec.lines).toContain('REAL_DATA_PROOF_REFUSED continuity');
- expect(rec.lines.some((line) => line.includes('--owner-approved=continuity'))).toBe(true);
- expect(rec.lines.filter((line) => line.startsWith('LIVE_HASH_'))).toEqual([]);
- expect(rec.lines.filter((line) => line.includes('REAL_DATA_PROOF_PASS'))).toEqual([]);
- }
- );
-});
-
-interface SpawnResult {
- readonly status: number | null;
- readonly stdout: string;
- readonly stderr: string;
-}
-
-/*
- * The CLI itself, in a sandbox every real-data path is redirected into: realUserDataDir() honours
- * WFT_REAL_USERDATA and resolveLaunch() honours WFT_V121_EXE, so even a broken gate could reach
- * only an empty temp directory. ELECTRON_RUN_AS_NODE and NODE_OPTIONS are removed, per D-02.
- */
-function spawnRefused(args: readonly string[], box: string): SpawnResult {
- const env: Record = { ...process.env } as Record;
- delete env.ELECTRON_RUN_AS_NODE;
- delete env.NODE_OPTIONS;
- env.USERPROFILE = box;
- env.HOME = box;
- env.APPDATA = box;
- env.WFT_REAL_USERDATA = box;
- env.WFT_V121_EXE = path.join(box, 'Workflow.exe');
-
- try {
- const stdout = execFileSync(process.execPath, [TOOL, ...args], {
- cwd: repoRoot, env, encoding: 'utf8', timeout: 20_000, stdio: ['ignore', 'pipe', 'pipe']
- });
- return { status: 0, stdout, stderr: '' };
- } catch (error) {
- const failure = error as { status?: number | null; stdout?: string; stderr?: string };
- return {
- status: failure.status ?? null,
- stdout: failure.stdout ?? '',
- stderr: failure.stderr ?? ''
- };
- }
-}
-
-describe('the spawned CLI refuses without owner approval', () => {
- const CASES: [string, string[]][] = [
- ['with no token at all', ['--mode=v121']],
- ['with the wrong token', ['--mode=v121', '--owner-approved=yes']],
- ['with the wrong token and --hard-kill', ['--mode=v121', '--hard-kill', '--owner-approved=1']]
- ];
-
- it.each(CASES)(
- 'refuses without owner approval: %s',
- (_label, args) => {
- const box = sandbox('cli');
- const result = spawnRefused(args, box);
-
- expect(result.status, result.stdout + result.stderr).not.toBe(0);
- expect(result.stdout).toContain('REAL_DATA_PROOF_REFUSED continuity');
- expect(result.stdout).not.toContain('LIVE_HASH_');
- expect(result.stdout).not.toContain('REAL_DATA_PROOF_PASS');
- expect(fs.readdirSync(box), 'the sandbox must still be empty').toEqual([]);
- },
- 30_000
- );
-});
diff --git a/tests/line-endings.test.ts b/tests/line-endings.test.ts
index 6fb28d9..67023d8 100644
--- a/tests/line-endings.test.ts
+++ b/tests/line-endings.test.ts
@@ -27,7 +27,7 @@ const shebangFiles = git(['ls-files', '-z']).split('\0').filter((f) => f !== ''
* D-13's neighbour: a NUL byte in a text file makes git treat it as BINARY.
*
* It has happened here twice. The first was fixed in 08-REVIEW; the second reached this phase's
- * tools/upgrade-over-v121.mjs as a join separator, and the only reason anyone noticed was a `Bin 0 -> 16993 bytes`
+ * tools/smoke-packaged.mjs as a join separator, and the only reason anyone noticed was a `Bin 0 -> 16993 bytes`
* in a diffstat. A binary-classified source file has no diff, no `git diff --check` whitespace check and no
* end-of-line normalisation - the whole line-ending contract silently stops applying to it. So the file is scanned
* rather than the diffstat being watched. Scoped by extension, because the repository legitimately tracks PNGs,
@@ -41,7 +41,7 @@ describe('no tracked text file is secretly binary', () => {
it('finds the text files to scan', () => {
expect(textFiles.length, 'the scan found no text files, so it proves nothing').toBeGreaterThan(100);
- expect(textFiles).toEqual(expect.arrayContaining(['package.json', 'tools/upgrade-over-v121.mjs']));
+ expect(textFiles).toEqual(expect.arrayContaining(['package.json', 'tools/smoke-packaged.mjs']));
});
it('holds no NUL byte, which would make git classify the file as binary', () => {
diff --git a/tests/lint-coverage.test.ts b/tests/lint-coverage.test.ts
index ff538dc..8da1d07 100644
--- a/tests/lint-coverage.test.ts
+++ b/tests/lint-coverage.test.ts
@@ -74,6 +74,7 @@ const NON_SOURCE: Record = {
'.ico': 'binary asset: icon',
'.woff2': 'binary asset: font',
'.mp3': 'binary asset: sound',
+ '.ogg': 'binary asset: sound',
'': 'no extension: LICENSE and dotfile configuration (.gitignore, .gitattributes, .nvmrc)'
};
@@ -193,10 +194,6 @@ const SQL_EXEMPTIONS: Record = {
'src/main/database-startup.ts': {
expires: 'permanent',
why: 'D-32 folds the -wal back into the database with a pragma before the connection closes'
- },
- 'src/main/smoke.ts': {
- expires: 'permanent',
- why: 'BUILD-06 writes and reads one row in a brand-new injected database to prove the packaged driver works'
}
};
const isSqlExempt = (file: string): boolean => isUnder(file, SQL_HOME) || Object.hasOwn(SQL_EXEMPTIONS, file);
@@ -543,7 +540,7 @@ describe('the rules the rewrite must not lose', () => {
* Per-file coverage alone could pass with the custody rules present but emptied - an option list that bans
* nothing still resolves at error. So the representative files are checked for what the rules actually ban.
*/
- const REPRESENTATIVE = ['src/main/index.ts', 'src/renderer/src/app/App.tsx', 'tools/baseline/archive-real-db.mjs', 'eslint.config.js'];
+ const REPRESENTATIVE = ['src/main/index.ts', 'src/renderer/src/app/App.tsx', 'tools/smoke-packaged.mjs', 'eslint.config.js'];
it.each(REPRESENTATIVE)('CUSTODY-02 and CUSTODY-03 resolve, with their bans intact, for %s', async (file) => {
const properties = await ruleEntry(file, 'no-restricted-properties');
diff --git a/tests/main-adapters.test.ts b/tests/main-adapters.test.ts
index 42ab2ce..cf03430 100644
--- a/tests/main-adapters.test.ts
+++ b/tests/main-adapters.test.ts
@@ -21,7 +21,7 @@ interface Sent {
payload: unknown;
}
-const SOUND_PAYLOAD = { sound: 'goalReached' } as const;
+const SOUND_PAYLOAD = { sound: 'goalReached', choice: 'classic' } as const;
function target(sent: Sent[], options: { destroyed?: boolean; throws?: boolean } = {}): RendererTarget {
return {
@@ -104,7 +104,7 @@ describe('the sound port asks the renderer, because only a document can play aud
it('emits app:playSound with a payload the declared event schema accepts', () => {
const sent: Sent[] = [];
const bus = createElectronRendererBus(ignoreLog, () => [target(sent)]);
- createRendererSound(bus).play('goalReached');
+ createRendererSound(bus).play('goalReached', 'classic');
expect(sent).toHaveLength(1);
expect(sent[0]?.channel).toBe('app:playSound');
expect(ipcEvents['app:playSound'].safeParse(sent[0]?.payload).success).toBe(true);
@@ -202,7 +202,7 @@ describe('createElectronPorts builds the whole port surface once', () => {
it('routes the sound port through a bus rather than through a window of its own', () => {
const sent: Sent[] = [];
const bus = createElectronRendererBus(ignoreLog, () => [target(sent)]);
- createRendererSound(bus).play('goalReached');
+ createRendererSound(bus).play('goalReached', 'classic');
expect(sent.map((s) => s.channel)).toEqual(['app:playSound']);
});
});
diff --git a/tests/main-config.test.ts b/tests/main-config.test.ts
index 94d6e95..fd74b68 100644
--- a/tests/main-config.test.ts
+++ b/tests/main-config.test.ts
@@ -9,11 +9,8 @@ import { describe, expect, it } from 'vitest';
import ts from 'typescript';
import { SHELL_BRIDGE_KEY } from '@shared/constants/bridge';
import {
- DEVELOPMENT_USER_DATA_SUFFIX, MAIN_WINDOW, RENDERER_COMPANIES_ROUTE_HASH, RENDERER_DESTRUCTIVE_TESTID,
- RENDERER_MARKER_TEXT,
- RENDERER_SECOND_ROUTE_HASH, RENDERER_SECOND_ROUTE_TEXT, SMOKE_DB_ENV, SMOKE_FLAG,
- SMOKE_SETTINGS_TARGET_SECONDS, SMOKE_SETTINGS_TARGET_TEXT, SMOKE_XSS_COMPANY_NAME,
- SMOKE_SEED_TIMER_STATE_ENV, USER_DATA_DIR_SWITCH, mainConfig, parseMainConfig
+ DEVELOPMENT_USER_DATA_SUFFIX, MAIN_WINDOW, RENDERER_MARKER_TEXT, RENDERER_SECOND_ROUTE_TEXT, SMOKE_DB_ENV,
+ SMOKE_FLAG, USER_DATA_DIR_SWITCH, mainConfig, parseMainConfig
} from '../src/main/config';
import {
DEVELOPMENT_USER_DATA_SUFFIX as USERDATA_SUFFIX, USER_DATA_DIR_SWITCH as USERDATA_SWITCH
@@ -125,9 +122,7 @@ function processReads(file: string, source: string = read(file)): string[] {
// Plain Node loads the harness, not vite: .gitattributes checks it out CRLF, and vite's SSR transform (hashbang regex
// /^#!.*\n/, whose `.` cannot match \r) then emits code above the #! line - a SyntaxError at import.
const SHARED_CONSTANTS = [
- 'RENDERER_MARKER_TEXT', 'RENDERER_SECOND_ROUTE_HASH', 'RENDERER_SECOND_ROUTE_TEXT', 'SMOKE_DB_ENV',
- 'RENDERER_COMPANIES_ROUTE_HASH', 'EXPECTED_XSS_COMPANY_NAME', 'EXPECTED_DESTRUCTIVE_TESTID',
- 'EXPECTED_SETTINGS_TARGET_TEXT'
+ 'RENDERER_MARKER_TEXT', 'RENDERER_SECOND_ROUTE_TEXT', 'SMOKE_DB_ENV'
] as const;
type SharedConstant = (typeof SHARED_CONSTANTS)[number];
@@ -192,13 +187,6 @@ describe('D-23: parseMainConfig is pure and never throws', () => {
.toBe('http://localhost:5173/');
});
- it('reads the smoke timer seed from its own variable and leaves it unset when absent', () => {
- expect(parseMainConfig({}, []).smokeSeedTimerState,
- 'D-37: an unset seed must stay undefined, or every smoke launch would seed localStorage').toBeUndefined();
- const seed = '{"elapsed":3723,"running":true}';
- expect(parseMainConfig({ [SMOKE_SEED_TIMER_STATE_ENV]: seed }, ['--smoke']).smokeSeedTimerState).toBe(seed);
- });
-
it('recognises --smoke only as the exact argument', () => {
expect(parseMainConfig({}, ['--smoked']).smoke, 'D-23: a look-alike argument started a smoke launch').toBe(false);
expect(parseMainConfig({}, ['app.exe', '--smoke']).smoke).toBe(true);
@@ -219,56 +207,12 @@ describe('D-24: the values the smoke harness depends on equal its own', () => {
.toBe(harness.SMOKE_DB_ENV);
});
- it('shares the second route and its heading with ' + HARNESS + ', so SPA-01 is checked on the same screen', () => {
+ it('shares the second route heading with ' + HARNESS + ', so Settings is checked on the same screen', () => {
const harness = harnessConstants();
- expect(RENDERER_SECOND_ROUTE_HASH, 'D-24: the app would route somewhere the harness does not check')
- .toBe(harness.RENDERER_SECOND_ROUTE_HASH);
expect(RENDERER_SECOND_ROUTE_TEXT, 'D-24: the smoke would wait for text the harness never asserts')
.toBe(harness.RENDERER_SECOND_ROUTE_TEXT);
});
- it('shares the Companies route and the XSS payload with ' + HARNESS + ', so S2 is watched on one string', () => {
- const harness = harnessConstants();
- expect(
- RENDERER_COMPANIES_ROUTE_HASH,
- 'D-24: the escaping would be watched on a screen the harness does not name'
- ).toBe(harness.RENDERER_COMPANIES_ROUTE_HASH);
- expect(
- SMOKE_XSS_COMPANY_NAME,
- 'D-24: the app would name a company one thing and the harness would pass the run for another. A payload ' +
- 'the two ends disagree on proves nothing about S2.'
- ).toBe(harness.EXPECTED_XSS_COMPANY_NAME);
- });
-
- /*
- * Criterion 4: the handle the delete-all-data button carries and the target the probe writes are checked by the
- * harness, so both ends have to spell them the same way. The renderer's own copy of the handle is held equal to
- * this one in tests/settings-screen.test.ts, which closes the chain screen -> app -> harness.
- */
- it('shares the destructive handle and the settings target with ' + HARNESS, () => {
- const harness = harnessConstants();
- expect(
- RENDERER_DESTRUCTIVE_TESTID,
- 'D-24: the smoke would probe for an identifier the harness does not check, so the one irreversible ' +
- 'button in the app would be unguarded in both places at once'
- ).toBe(harness.EXPECTED_DESTRUCTIVE_TESTID);
- expect(
- SMOKE_SETTINGS_TARGET_TEXT,
- 'D-24: the app would wait for one clock reading and the harness would pass the run for another'
- ).toBe(harness.EXPECTED_SETTINGS_TARGET_TEXT);
- });
-
- it('writes a target that reads back as the text the screen is waited for', () => {
- const hours = Math.floor(SMOKE_SETTINGS_TARGET_SECONDS / 3600);
- const minutes = Math.floor((SMOKE_SETTINGS_TARGET_SECONDS % 3600) / 60);
- const clock = String(hours).padStart(2, '0') + ':' + String(minutes).padStart(2, '0');
- expect(
- clock,
- 'the probe would write a target the settings screen never renders as ' + SMOKE_SETTINGS_TARGET_TEXT +
- ', and the launch would wait 20 seconds for text that cannot appear'
- ).toBe(SMOKE_SETTINGS_TARGET_TEXT);
- });
-
it('uses as SMOKE_FLAG the first argument the harness spawns the packaged binary with', () => {
const spawn = only(
findAll(parse(HARNESS), (n): n is ts.CallExpression =>
diff --git a/tests/offline-routes.test.ts b/tests/offline-routes.test.ts
deleted file mode 100644
index 599cd69..0000000
--- a/tests/offline-routes.test.ts
+++ /dev/null
@@ -1,159 +0,0 @@
-/*
- * The judgement behind Phase 10 criterion 7, and a staleness guard over what it recorded.
- *
- * `npm run offline:check` is the gate and it needs a packaged build; what runs in the ordinary suite is the part
- * that decides what counts as "the icons are glyphs" and "the font resolved", exercised with the failures it is
- * supposed to catch. The S4 failure has a shape - the ligature NAME renders as literal text - so the control here
- * is a probe carrying exactly that.
- */
-
-import { describe, expect, it } from 'vitest';
-import fs from 'node:fs';
-import path from 'node:path';
-import { repoRoot } from './helpers/ts-imports';
-import {
- BUNDLED_FONTS, FILL_ON, MAX_GLYPH_EMS, MIN_NAME_EMS, insideThePackage, judgeRouteOffline, judgeSound
-} from '../tools/offline-routes.mjs';
-import type { OfflineCheck } from '../tools/offline-routes.mjs';
-import { SMOKE_BUNDLED_FONTS, SMOKE_ICON_MAX_WIDTH_PX, SMOKE_ICON_FONT_SIZE_PX } from '../src/main/config';
-
-const REPORT = path.join(repoRoot, 'baselines', 'v2', 'OFFLINE-ROUTES.md');
-const report = fs.readFileSync(REPORT, 'utf8');
-
-const icon = (name: string, fontSize: number, ems: number, variation = 'normal'): Record => ({
- name, fontSize, ems, width: fontSize * ems, fontFamily: '"Material Symbols Outlined"', variation
-});
-
-/** A route on which everything worked, so each control below changes exactly one thing. */
-const healthy = (): Record => ({
- fonts: { 'Inter Variable': true, 'Material Symbols Outlined': true },
- icons: [
- icon('home', 24, 1),
- icon('local_fire_department', 18, 1, '"FILL" 1, "wght" 400'),
- icon('play_arrow', 48, 1)
- ],
- longestName: 'local_fire_department',
- nameAsTextEms: 9.8,
- bodyFont: '"Inter Variable", Inter, sans-serif',
- headingFont: '"Inter Variable", Inter, sans-serif',
- shellBackground: 'rgb(16, 28, 34)',
- violations: []
-});
-
-const failedFor = (probe: Record): string[] =>
- judgeRouteOffline('index', probe).filter((check: OfflineCheck) => !check.pass)
- .map((check: OfflineCheck) => check.label.replace('index: ', ''));
-
-describe('the per-route offline verdict', () => {
- it('passes a route that rendered from inside the bundle', () => {
- expect(failedFor(healthy())).toEqual([]);
- });
-
- /*
- * S4, exactly: the font never arrives, so every ligature renders as its own name in the fallback face. The
- * glyphs go from one em to many, and this is the failure the whole check exists for.
- */
- it('fails a route where the icons rendered as literal text', () => {
- const asText = {
- ...healthy(),
- fonts: { 'Inter Variable': true, 'Material Symbols Outlined': false },
- icons: [
- icon('home', 24, 2.4, 'normal'),
- icon('local_fire_department', 18, 9.8, 'normal')
- ]
- };
- const failures = failedFor(asText);
- expect(failures).toContain('Material Symbols Outlined resolved from inside the bundle');
- expect(failures).toContain('every icon measures as a glyph, not as its own name in text');
- expect(failures).toContain('the icons that ask to be filled are filled');
- });
-
- it('fails a route whose text is not drawn in Inter', () => {
- expect(failedFor({ ...healthy(), headingFont: 'system-ui', bodyFont: 'system-ui' }))
- .toEqual(['the text on screen is drawn in Inter']);
- });
-
- it('fails a route with no icons at all, rather than passing vacuously', () => {
- const failures = failedFor({ ...healthy(), icons: [], longestName: '', nameAsTextEms: 0 });
- expect(failures).toContain('the route has icons to judge');
- expect(failures).toContain('the bound separates a glyph from a name, in this document');
- });
-
- it('fails a route where the glyph bound would separate nothing', () => {
- // A document in which the longest icon name measures barely more than a glyph would make every width
- // comparison meaningless, so the run says so instead of reporting a pass.
- expect(failedFor({ ...healthy(), nameAsTextEms: 1.2 }))
- .toEqual(['the bound separates a glyph from a name, in this document']);
- });
-
- it('fails a route that reported a CSP violation, and quotes it', () => {
- const checks = judgeRouteOffline('index', { ...healthy(), violations: ['font-src <- https://fonts.gstatic.com'] });
- const failure = checks.find((check: OfflineCheck) => !check.pass);
- expect(failure?.label).toBe('index: the console reported no CSP violation');
- expect(failure?.detail).toContain('fonts.gstatic.com');
- });
-
- it('fails a route drawn without the palette, which is what a stylesheet that never arrived looks like', () => {
- expect(failedFor({ ...healthy(), shellBackground: 'rgba(0, 0, 0, 0)' }))
- .toEqual(['the route rendered with the palette applied']);
- });
-
- it('fails an icon drawn in some other family, however wide it is', () => {
- const wrongFamily = { ...healthy(), icons: [{ ...icon('home', 24, 1), fontFamily: 'system-ui' }] };
- expect(failedFor(wrongFamily)).toContain('every icon is drawn in the Material Symbols family');
- });
-});
-
-describe('the sound verdict', () => {
- const decoded = { src: 'file:///c/app.asar/out/renderer/assets/notification-x.mp3', duration: 0.65, error: '' };
- const failed = (probe: unknown): string[] =>
- judgeSound(probe).filter((check: OfflineCheck) => !check.pass).map((check: OfflineCheck) => check.label);
-
- it('passes a bundled file that decoded', () => {
- expect(failed(decoded)).toEqual([]);
- });
-
- it('fails a remote source, which is what v1.2.1 would have had offline', () => {
- expect(failed({ ...decoded, src: 'https://example.com/notification.mp3' }))
- .toEqual(['the notification sound resolves to a file inside the app']);
- });
-
- it('fails a file that is there but will not decode', () => {
- expect(failed({ ...decoded, duration: 0, error: 'MediaError 4' }))
- .toEqual(['the notification sound decodes with the network off']);
- });
-});
-
-describe('the bounds and the committed report', () => {
- it('states the same glyph-versus-text distinction src/main/config.ts does, generalised to any size', () => {
- // The smoke draws one icon at a known size and bounds it in pixels; this bounds every icon in ems. The two
- // must agree at that size, or the app and this tool would disagree about what a glyph is.
- expect(SMOKE_ICON_MAX_WIDTH_PX / SMOKE_ICON_FONT_SIZE_PX).toBeLessThanOrEqual(MAX_GLYPH_EMS);
- expect(MIN_NAME_EMS).toBeGreaterThan(MAX_GLYPH_EMS);
- expect([...BUNDLED_FONTS].sort()).toEqual([...SMOKE_BUNDLED_FONTS].sort());
- expect(FILL_ON).toBe('"FILL" 1');
- });
-
- it('carries no absolute path from the machine that produced it', () => {
- expect(insideThePackage('file:///C:/Users/someone/dist/win-unpacked/resources/app.asar/out/renderer/a.mp3'))
- .toBe('app.asar/out/renderer/a.mp3');
- expect(report).not.toMatch(/file:\/\/\//);
- expect(report).not.toMatch(/Users/);
- });
-
- it('records four routes, no failure, no remote request and no CSP violation', () => {
- expect(report).toContain('| Routes checked | 4 |');
- expect(report).toContain('| Checks failed | **0** |');
- expect(report).toContain('| Remote requests attempted | 0 |');
- expect(report).toContain('| CSP violations reported | 0 |');
- expect(report).toContain('| Console errors | 0 |');
- for (const route of ['index', 'companies', 'work-history', 'settings']) {
- expect(report, route + ' is missing from the report').toContain('| `' + route + '` |');
- }
- });
-
- it('records a sound that decoded, not merely a path that exists', () => {
- expect(report).toMatch(/\| Decoded duration \| 0\.\d+s \|/);
- expect(report).toContain('| Error | none |');
- });
-});
diff --git a/tests/parity-diff.test.ts b/tests/parity-diff.test.ts
deleted file mode 100644
index ce0f445..0000000
--- a/tests/parity-diff.test.ts
+++ /dev/null
@@ -1,191 +0,0 @@
-/*
- * The colour maths and the register behind criterion 2's second gate.
- *
- * 08-REVIEW-TIMER WR-07 / 08-REVIEW-SCREENS WR-05: neither capture-v2.mjs nor diff-computed.mjs was referenced by
- * any package.json script, any test or any workflow. diff-computed's own header argues that its SETTLED register
- * is "a gate rather than a reading: a future change either matches the baselines or arrives in the unexplained
- * list", and PARITY-CHECKLIST.md describes the tool as one that "exits non-zero on any difference that is not in
- * its register with a reason" - but nothing ran it, so it exited non-zero at nobody, and the committed
- * 29-difference register could not go stale in a way any check would notice. Not one of the ~200 lines of colour
- * maths that decide what COUNTS as a difference was exercised either.
- *
- * `npm run parity:check` is the gate itself, and it needs a packaged build. This is what runs in the ordinary
- * suite: the normalisation, and a staleness guard over the committed report.
- */
-
-import { describe, expect, it } from 'vitest';
-import fs from 'node:fs';
-import path from 'node:path';
-import { repoRoot } from './helpers/ts-imports';
-
-import {
- PAGES, SETTLED, colourToHex, normalise, oklabToSrgb, settledReason, splitTopLevel
-} from '../tools/baseline/diff-computed.mjs';
-import { FORCED_CHILD_ENV, childEnvironment } from '../tools/baseline/probe-userdata.mjs';
-import { UPDATE_CHECK_DISABLED_ENV } from '../src/main/config';
-
-const REPORT = path.join(repoRoot, 'baselines', 'v1.2.1', 'VISUAL-PARITY-DIFF.md');
-const report = fs.readFileSync(REPORT, 'utf8');
-
-describe('the colour resolution that decides what counts as a difference', () => {
- it('reads black, white and mid grey out of oklab', () => {
- expect(oklabToSrgb(0, 0, 0)).toEqual([0, 0, 0]);
- expect(oklabToSrgb(1, 0, 0)).toEqual([255, 255, 255]);
- // An achromatic oklch is the same grey however it is spelled, which is the whole premise.
- expect(colourToHex('oklch', '1 0 0')).toBe('#ffffffff');
- expect(colourToHex('oklch', '0 0 0')).toBe('#000000ff');
- expect(colourToHex('oklab', '1 0 0')).toBe('#ffffffff');
- });
-
- it('spells rgb and rgba the same way, in both syntaxes', () => {
- expect(colourToHex('rgb', '16, 28, 34')).toBe('#101c22ff');
- expect(colourToHex('rgb', '16 28 34')).toBe('#101c22ff');
- expect(colourToHex('rgba', '16, 28, 34, 0.5')).toBe('#101c2280');
- expect(colourToHex('rgb', '16 28 34 / 50%')).toBe('#101c2280');
- });
-
- it('refuses a notation it does not understand rather than inventing a colour', () => {
- expect(colourToHex('lab', '50 20 30')).toBeNull();
- expect(normalise('color', 'lab(50 20 30)')).toBe('lab(50 20 30)');
- });
-
- it('resolves every colour in a declaration, not just the first', () => {
- expect(normalise('border', '1px solid rgb(255, 0, 0)')).toBe('1px solid #ff0000ff');
- });
-});
-
-describe('the normalisations the header says are not differences', () => {
- it('drops the always-transparent shadow slots v4 adds', () => {
- expect(normalise('box-shadow', 'rgba(0, 0, 0, 0) 0px 0px 0px 0px')).toBe('none');
- expect(normalise('box-shadow', 'rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgb(255, 0, 0) 0px 1px 2px 0px'))
- .toBe('#ff0000ff 0px 1px 2px 0px');
- });
-
- it('zeroes an outline width that draws nothing because the style is none', () => {
- expect(normalise('outline', 'rgb(100, 116, 139) none 0px'))
- .toBe(normalise('outline', 'rgb(100, 116, 139) none 3px'));
- });
-
- it('reads v3 9999px pill and v4 calc(infinity * 1px) as the same pill', () => {
- expect(normalise('border-radius', '9999px')).toBe(normalise('border-radius', '33554400px'));
- // The control: an ordinary radius is still itself.
- expect(normalise('border-radius', '8px')).not.toBe(normalise('border-radius', '9999px'));
- });
-
- it('splits a comma list without cutting inside a function call', () => {
- expect(splitTopLevel('rgb(0, 0, 0) 0px 1px, rgb(1, 1, 1) 0px 2px'))
- .toEqual(['rgb(0, 0, 0) 0px 1px', ' rgb(1, 1, 1) 0px 2px']);
- });
-});
-
-describe('WR-07: the committed report cannot go stale unnoticed', () => {
- it('carries every register entry, with its reason', () => {
- expect(SETTLED.length).toBeGreaterThan(0);
- for (const entry of SETTLED) {
- const row = '| `' + entry.page + '` | `' + entry.prop + '` | ' +
- (entry.side === 'v1' ? 'v1.2.1 only' : 'v2 only') + ' | `' + entry.value + '` |';
- expect(report, 'the register holds a difference the report does not name').toContain(row);
- expect(report).toContain(entry.why);
- }
- });
-
- it('states the number the register actually holds', () => {
- const stated = /Design-property differences, settled with a reason \| (\d+) \|/.exec(report);
- expect(stated?.[1], 'the report does not state a settled count').toBeDefined();
- expect(Number(stated?.[1]), 'the stated count and the register disagree').toBe(SETTLED.length);
- });
-
- it('claims nothing unexplained, which is what makes it a gate', () => {
- expect(report).toContain('| Design-property differences, **unexplained** | 0 |');
- });
-
- it('answers for every entry it registers, and for nothing else', () => {
- for (const entry of SETTLED) {
- expect(settledReason(entry.page, entry.prop, entry.side, entry.value)?.why).toBe(entry.why);
- }
- expect(settledReason('index', 'color', 'v2', '#000000ff')).toBeUndefined();
- });
-
- it('registers nothing for a page the diff does not visit', () => {
- for (const entry of SETTLED) {
- expect(PAGES).toContain(entry.page);
- }
- });
-
- it('has a header nobody pasted into (WR-10)', () => {
- const source = fs.readFileSync(path.join(repoRoot, 'tools', 'baseline', 'diff-computed.mjs'), 'utf8');
- const header = source.slice(0, source.indexOf('*/'));
- expect(header, 'a statement was pasted into the block comment again').not.toContain('lines.push(');
- expect(header).toContain('evidence of nothing either way');
- });
-});
-
-describe('WR-07: the gate is reachable from the command line', () => {
- const scripts = JSON.parse(
- fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')
- ) as { scripts: Record };
-
- it('names the capture, the diff and the check', () => {
- expect(scripts.scripts['baseline:capture-v2']).toContain('capture-v2.mjs');
- expect(scripts.scripts['baseline:diff']).toContain('diff-computed.mjs');
- expect(scripts.scripts['parity:check']).toContain('parity-check.mjs');
- });
-
- it('is invoked by the workflow that has a packaged build to photograph', () => {
- const workflow = fs.readFileSync(path.join(repoRoot, '.github', 'workflows', 'package.yml'), 'utf8');
- expect(workflow, 'nothing runs the parity gate').toContain('parity:check');
- });
-});
-
-/*
- * Phase 11: the gate failed three times running, on a different page and a different property each time, because
- * the answer was where the operator's mouse was. At 1920x1080 the window fills the screen, the real pointer lands
- * on whatever is under it, and Chromium applies :hover - which the vocabulary diff reads as a colour v1.2.1 never
- * had. It was caught as `oklab(0.684327 -0.0772989 -0.129777 / 0.9)` on the Settings Save button, Tailwind's
- * hover:bg-primary/90, normalising to #13a4ece6.
- */
-describe('the capture does not depend on where the mouse is', () => {
- const capture = fs.readFileSync(path.join(repoRoot, 'tools', 'baseline', 'capture-v2.mjs'), 'utf8');
-
- it('stops the OS pointer reaching the content before any route is captured', () => {
- expect(capture, 'the capture reads hover states off whatever the pointer happens to be over')
- .toContain('setIgnoreMouseEvents(true)');
- expect(
- capture.indexOf('setIgnoreMouseEvents(true)'),
- 'mouse events are disabled after the first route has already been captured'
- ).toBeLessThan(capture.indexOf('await captureRoute('));
- });
-
- it('would report a hover colour as a difference, which is why the line above matters', () => {
- // Not a hypothetical: this is the exact value the failing runs produced, through the real normaliser.
- expect(normalise('background-color', 'oklab(0.684327 -0.0772989 -0.129777 / 0.9)')).toBe('#13a4ece6');
- expect(settledReason('settings', 'background-color', 'v2', '#13a4ece6'),
- 'a hover colour was added to the settled register instead of being kept out of the capture')
- .toBeUndefined();
- });
-});
-
-/*
- * REPO-06: a harness launches the real application, which asks GitHub for a version manifest thirty seconds
- * after its window opens - and a parity capture runs for about a minute. Every launcher takes its environment
- * from childEnvironment(), so the opt-out is set there once.
- */
-describe('no harness reaches the network', () => {
- it('forces the update check off for every child process', () => {
- expect(FORCED_CHILD_ENV).toEqual({ WORKFLOW_NO_UPDATE_CHECK: '1' });
- expect(Object.keys(FORCED_CHILD_ENV)[0], 'the harness sets a variable src/main/config.ts does not read')
- .toBe(UPDATE_CHECK_DISABLED_ENV);
- expect(childEnvironment({}).env['WORKFLOW_NO_UPDATE_CHECK']).toBe('1');
- // And it wins over an ambient value, so a shell that exported an empty string cannot re-enable it.
- expect(childEnvironment({ WORKFLOW_NO_UPDATE_CHECK: '' }).env['WORKFLOW_NO_UPDATE_CHECK']).toBe('1');
- });
-
- it('is what a launcher actually passes to the app', () => {
- for (const tool of ['baseline/capture-v2.mjs', 'baseline/responsive-matrix.mjs', 'offline-routes.mjs',
- 'upgrade-over-v121.mjs', 'second-instance.mjs', 'smoke-packaged.mjs']) {
- expect(fs.readFileSync(path.join(repoRoot, 'tools', tool), 'utf8'),
- 'tools/' + tool + ' builds its own environment instead of taking childEnvironment()')
- .toContain('childEnvironment()');
- }
- });
-});
diff --git a/tests/release-proofs.test.ts b/tests/release-proofs.test.ts
deleted file mode 100644
index 2e36688..0000000
--- a/tests/release-proofs.test.ts
+++ /dev/null
@@ -1,185 +0,0 @@
-/*
- * The judgement behind two proofs that need two OS processes and a packaged build to produce their inputs:
- * criterion 6 (installing over v1.2.1 loses nothing) and IPC-07 (a second launch is sent back to the first).
- *
- * `npm run upgrade:check` and `npm run instance:check` are the gates. What runs in the ordinary suite is what
- * decides whether the numbers those launches produced mean the claim holds - exercised with the failures each is
- * supposed to catch, because a verdict function that has never been shown to fail carries nothing.
- */
-
-import { describe, expect, it } from 'vitest';
-import { judgeUpgrade, STREAK_DAYS } from '../tools/upgrade-over-v121.mjs';
-import { judgeSecondInstance } from '../tools/second-instance.mjs';
-import type { UpgradeCheck } from '../tools/upgrade-over-v121.mjs';
-
-const seededSessions = [
- { name: 'Fixture task: one', duration: 9900, date: '2026-09-18', company_id: 2, note: 'a note' },
- { name: 'Fixture task: two', duration: 9900, date: '2026-09-18', company_id: 3, note: null }
-];
-
-const seeded = {
- sessions: seededSessions,
- companies: [
- { id: 1, name: 'Unassigned', note_required: 0 },
- { id: 3, name: 'Contoso Fixture', note_required: 1 }
- ],
- settings: {
- daily_target: '28800',
- goal_notification: 'true',
- exclude_weekends_from_streak: 'false',
- pomodoro_enabled: 'false',
- pomodoro_work_duration: '1500',
- pomodoro_short_break: '300',
- pomodoro_long_break: '900',
- pomodoro_sessions_until_long_break: '4'
- },
- streak: STREAK_DAYS,
- today: '2026-09-18'
-};
-
-const read = {
- errors: [] as string[],
- sessions: [
- // Renumbered ids on purpose: a migration may renumber, and that is not a loss.
- { id: 91, name: 'Fixture task: one', durationSeconds: 9900, date: '2026-09-18', companyId: 2, note: 'a note' },
- { id: 92, name: 'Fixture task: two', durationSeconds: 9900, date: '2026-09-18', companyId: 3, note: null }
- ],
- companies: [
- { id: 1, name: 'Unassigned', noteRequired: false },
- { id: 3, name: 'Contoso Fixture', noteRequired: true }
- ],
- settings: {
- dailyTargetSeconds: 28800,
- goalNotification: true,
- excludeWeekendsFromStreak: false,
- pomodoroEnabled: false,
- pomodoroWorkSeconds: 1500,
- pomodoroShortBreakSeconds: 300,
- pomodoroLongBreakSeconds: 900,
- pomodoroSessionsUntilLongBreak: 4
- },
- streak: STREAK_DAYS
-};
-
-const backups = ['krono.db.2026-09-18T08-19-43-732Z.bak'];
-
-const upgradeFailures = (over: Record): string[] =>
- judgeUpgrade({ seeded, read: { ...read, ...over }, backups })
- .filter((check: UpgradeCheck) => !check.pass).map((check: UpgradeCheck) => check.label);
-
-describe('criterion 6: what the app gives back after installing over v1.2.1', () => {
- it('passes a migration that kept everything, even with the ids renumbered', () => {
- expect(upgradeFailures({})).toEqual([]);
- });
-
- it('fails a session that was dropped, and one whose note was rewritten', () => {
- expect(upgradeFailures({ sessions: read.sessions.slice(1) })).toEqual([
- 'every work session came back, field for field',
- 'no second of tracked time was lost or invented'
- ]);
- // The note is the user's text, and NULL versus empty is the distinction the app hangs the attribution
- // prompt on - so a note that changed is a session that did not come back.
- const renoted = read.sessions.map((row, index) => (index === 0 ? { ...row, note: 'something else' } : row));
- expect(upgradeFailures({ sessions: renoted })).toEqual(['every work session came back, field for field']);
- });
-
- it('fails a duration that changed while the count stayed right - the rounding-to-six-minutes shape', () => {
- const rounded = read.sessions.map((row) => ({ ...row, durationSeconds: 9600 }));
- expect(upgradeFailures({ sessions: rounded })).toEqual([
- 'every work session came back, field for field',
- 'no second of tracked time was lost or invented'
- ]);
- });
-
- it('fails a company that vanished, and a note_required flag that was lost with the retired columns', () => {
- expect(upgradeFailures({ companies: read.companies.slice(0, 1) })).toEqual([
- 'every company came back, by name',
- 'note_required survived the sheets retirement, which dropped the columns beside it'
- ]);
- const unflagged = read.companies.map((row) => ({ ...row, noteRequired: false }));
- expect(upgradeFailures({ companies: unflagged }))
- .toEqual(['note_required survived the sheets retirement, which dropped the columns beside it']);
- });
-
- it('fails a streak the app got wrong in either direction', () => {
- expect(upgradeFailures({ streak: STREAK_DAYS - 1 }))
- .toEqual(['the streak is the ' + String(STREAK_DAYS) + ' consecutive days the fixture holds']);
- expect(upgradeFailures({ streak: STREAK_DAYS + 1 }))
- .toContain('the streak is the ' + String(STREAK_DAYS) + ' consecutive days the fixture holds');
- });
-
- it('fails settings that came back as defaults instead of as what the user set', () => {
- expect(upgradeFailures({ settings: { ...read.settings, dailyTargetSeconds: 28801 } }))
- .toEqual(['the daily target survived']);
- expect(upgradeFailures({ settings: { ...read.settings, pomodoroWorkSeconds: 1499 } }))
- .toEqual(['the four pomodoro durations survived']);
- expect(upgradeFailures({ settings: { ...read.settings, goalNotification: false } }))
- .toEqual(['the boolean preferences survived, with their values and not their defaults']);
- });
-
- it('fails a failed IPC call rather than treating an empty answer as an empty database', () => {
- const failures = upgradeFailures({ errors: ['settings:get -> {"code":"INTERNAL"}'], settings: {} });
- expect(failures[0]).toBe('the app answered every question it was asked');
- });
-
- it('fails a migration that took no backup, or more than one', () => {
- const noBackup = judgeUpgrade({ seeded, read, backups: [] })
- .filter((check: UpgradeCheck) => !check.pass).map((check: UpgradeCheck) => check.label);
- expect(noBackup).toEqual(['the migration took exactly one backup before touching anything']);
- });
-});
-
-const surfaced = {
- first: {
- alive: true, windowsBefore: 1, visibleBefore: false, windowsAfter: 1, visibleAfter: true, companiesAfter: 4
- },
- second: { exited: true, timedOut: false, code: 0, signal: null, ms: 136, stdout: '', stderr: '' },
- walFiles: ['krono.db-wal']
-};
-
-const instanceFailures = (over: Record): string[] =>
- judgeSecondInstance({ ...surfaced, ...over })
- .filter((check: UpgradeCheck) => !check.pass).map((check: UpgradeCheck) => check.label);
-
-describe('IPC-07: a second launch is sent back to the first', () => {
- it('passes the launch that behaved', () => {
- expect(instanceFailures({})).toEqual([]);
- });
-
- it('fails a second process that stayed up - two processes on one SQLite file', () => {
- expect(instanceFailures({ second: { ...surfaced.second, exited: false, timedOut: true, code: null } }))
- .toEqual(['the second launch exited by itself', 'the second launch exited cleanly, not by crashing']);
- });
-
- it('fails a second process that exited by crashing rather than by quitting at the lock', () => {
- expect(instanceFailures({ second: { ...surfaced.second, code: 1, stderr: 'TypeError: boom' } }))
- .toEqual(['the second launch exited cleanly, not by crashing', 'the second launch said nothing on stderr']);
- });
-
- it('fails a second window opened on the same database', () => {
- expect(instanceFailures({ first: { ...surfaced.first, windowsAfter: 2 } }))
- .toEqual(['there is still exactly one main window, not a second one on the same database']);
- });
-
- it('fails a first window that was never surfaced, which is the whole point of the handler', () => {
- expect(instanceFailures({ first: { ...surfaced.first, visibleAfter: false } }))
- .toEqual(['the existing window was surfaced rather than ignored']);
- });
-
- it('fails a window that was already visible, where surfacing could not be observed', () => {
- expect(instanceFailures({ first: { ...surfaced.first, visibleBefore: true } }))
- .toEqual(['the first launch opened exactly one window, and it was hidden before the second']);
- });
-
- it('fails a first process that died, or stopped answering from its database', () => {
- expect(instanceFailures({ first: { ...surfaced.first, alive: false } }))
- .toEqual(['the first process is still alive']);
- expect(instanceFailures({ first: { ...surfaced.first, companiesAfter: -1 } }))
- .toEqual(['the first process still answers from its own database, so nothing took the file from it']);
- });
-
- it('fails a second write-ahead log, which is what a second writer leaves behind', () => {
- expect(instanceFailures({ walFiles: ['krono.db-wal', 'krono.db-wal2'] }))
- .toEqual(['the second launch left no second write-ahead log beside the database']);
- });
-});
diff --git a/tests/renderer-build-output.test.ts b/tests/renderer-build-output.test.ts
index d059c43..57b13f7 100644
--- a/tests/renderer-build-output.test.ts
+++ b/tests/renderer-build-output.test.ts
@@ -3,6 +3,7 @@ import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
+import { SOUND_CHOICES } from '../src/shared/constants/settings';
/*
* Why this file reads the BUILD OUTPUT and not the source (BUILD-07, T-02-12, T-02-13).
@@ -279,9 +280,10 @@ describe('BUILD-07: the BUILT renderer carries its Content-Security-Policy and l
});
it('SPA-10: the notification sound is emitted into the bundle and referenced from it', () => {
- const sounds = emittedWith('.mp3');
- expect(sounds.length, 'no .mp3 under ' + BUILT_DIR + ' - the notification sound is not in the bundle')
- .toBeGreaterThan(0);
+ const sounds = emittedWith('.ogg');
+ // Every sound the Settings screen offers has to be a file in the bundle, or picking it plays silence.
+ expect(sounds.length, 'a sound the Settings screen offers is not in the bundle under ' + BUILT_DIR)
+ .toBeGreaterThanOrEqual(SOUND_CHOICES.length);
const names = sounds.map((file) => path.posix.basename(file));
const referenced = emittedWith('.js').some((file) => {
const code = read(file);
diff --git a/tests/renderer-structure.test.ts b/tests/renderer-structure.test.ts
index bfe52dd..6846ae3 100644
--- a/tests/renderer-structure.test.ts
+++ b/tests/renderer-structure.test.ts
@@ -322,19 +322,6 @@ describe('criterion 1 / SPA-15: the v1.2.1 renderer moved, and the dead fragment
).toEqual([]);
});
- /*
- * The deletion had to leave the CUSTODY-09 inventory untouched, and it did: the fragments held no
- * addEventListener and no window.api call, so 106/21/67 are unchanged. tests/inventory.test.ts recomputes all
- * three from legacy/ and compares them element-wise, which is the real guard; this is the pointer to it.
- */
- it('kept the behaviour inventory measurable, by moving what the inventory counts', () => {
- for (const artifact of ['baselines/v1.2.1/handlers.tsv', 'baselines/v1.2.1/api-calls.tsv']) {
- const rows = read(artifact).split('\n').filter((row) => row.trim() !== '');
- expect(rows.length, artifact + ' is empty, so the inventory cannot be recomputed').toBeGreaterThan(0);
- const stale = rows.filter((row) => !row.startsWith(LEGACY_PAGES + '/') && !row.startsWith('legacy/renderer/'));
- expect(stale.slice(0, 3), artifact + ' still cites files at their pre-cutover paths').toEqual([]);
- }
- });
});
/*
diff --git a/tests/repository-assets.test.ts b/tests/repository-assets.test.ts
index 9a71c1c..f73de70 100644
--- a/tests/repository-assets.test.ts
+++ b/tests/repository-assets.test.ts
@@ -18,7 +18,7 @@ import path from 'node:path';
import { repoRoot } from './helpers/ts-imports';
/** Extensions whose bytes are the asset. Text duplicates are a different question and not this one. */
-const BINARY_EXTENSIONS = ['.png', '.ico', '.svg', '.woff2', '.mp3', '.jpg', '.jpeg', '.gif', '.webp'];
+const BINARY_EXTENSIONS = ['.png', '.ico', '.svg', '.woff2', '.mp3', '.ogg', '.jpg', '.jpeg', '.gif', '.webp'];
/**
* The duplicate content that is allowed to stand, keyed by the sorted paths that hold it.
diff --git a/tests/responsive-matrix.test.ts b/tests/responsive-matrix.test.ts
deleted file mode 100644
index c2ec8fb..0000000
--- a/tests/responsive-matrix.test.ts
+++ /dev/null
@@ -1,223 +0,0 @@
-/*
- * The judgement behind Phase 10's 15-cell matrix, and a staleness guard over what it recorded.
- *
- * `npm run matrix:check` is the gate, and it needs a packaged build to drive - so what runs in the ordinary suite
- * is the part that decides what COUNTS as a failure, exercised with negative controls. A verdict function that
- * cannot be shown to fail is a verdict function that proves nothing, and this one has to be trusted 588 times.
- *
- * The committed report is held to the shape the criterion asks for: 15 cells, none sampled, none failing.
- */
-
-import { describe, expect, it } from 'vitest';
-import fs from 'node:fs';
-import path from 'node:path';
-import { repoRoot } from './helpers/ts-imports';
-
-import {
- CELL_COUNT, MODALS_NOT_REACHED, SCALES, SIZES, judgeModal, judgeRoute, linesAgree
-} from '../tools/baseline/responsive-matrix.mjs';
-
-const REPORT = path.join(repoRoot, 'baselines', 'v2', 'RESPONSIVE-MATRIX.md');
-const report = fs.readFileSync(REPORT, 'utf8');
-
-const box = (left: number, right: number): Record => ({
- left, right, top: 0, bottom: 100, width: right - left, height: 100
-});
-
-/** A cell in which everything is correct, so each control below changes exactly one thing. */
-function healthy(): Record {
- return {
- modalOpen: false,
- innerWidth: 380,
- innerHeight: 600,
- devicePixelRatio: 1.5,
- documentScrollWidth: 380,
- bodyScrollWidth: 380,
- shell: box(0, 380),
- nav: box(0, 380),
- dial: { ...box(90, 290), width: 200, height: 200 },
- canvas: { cssWidth: 100, cssHeight: 50, bitmapWidth: 150, bitmapHeight: 75 },
- overflowing: [], overflowingCount: 0,
- clipped: [], clippedCount: 0,
- behindNav: [], behindNavCount: 0,
- scrolled: { scrollTop: 0, scrollHeight: 600, clientHeight: 600 }
- };
-}
-
-const failedLabels = (measured: Record, size: readonly number[]): string[] =>
- judgeRoute(measured, size).filter((check: { pass: boolean }) => !check.pass)
- .map((check: { label: string }) => check.label);
-
-describe('the per-route verdict', () => {
- it('passes a cell in which nothing is wrong', () => {
- expect(failedLabels(healthy(), [380, 600])).toEqual([]);
- });
-
- it('fails a document wider than its viewport', () => {
- expect(failedLabels({ ...healthy(), documentScrollWidth: 381 }, [380, 600]))
- .toEqual(['the document is no wider than the viewport']);
- expect(failedLabels({ ...healthy(), bodyScrollWidth: 400 }, [380, 600]))
- .toEqual(['the document is no wider than the viewport']);
- });
-
- it('fails an element hanging past an edge, and names it', () => {
- const overflowing = [{ path: 'x', name: 'canvas.absolute', detail: '0.0..554.3 of 380' }];
- const checks = judgeRoute({ ...healthy(), overflowing, overflowingCount: 1 }, [380, 600]);
- const failed = checks.find((check: { pass: boolean }) => !check.pass);
- expect(failed?.label).toBe('no drawn element crosses either edge');
- // The detail is what a reader has to act on; a bare count would send them back to the app to look.
- expect(failed?.detail).toContain('canvas.absolute');
- expect(failed?.detail).toContain('554.3');
- });
-
- it('fails a clipped box and content left behind the bottom bar', () => {
- expect(failedLabels({ ...healthy(), clippedCount: 1, clipped: [{ path: 'x', name: 'div', detail: 'd' }] }, [380, 600]))
- .toEqual(['no overflow:hidden box cuts off what it holds']);
- expect(failedLabels({ ...healthy(), behindNavCount: 2, behindNav: [{ path: 'x', name: 'p', detail: 'd' }] }, [380, 600]))
- .toEqual(['nothing sits behind the bottom bar at the end of the scroll']);
- });
-
- it('fails a bottom bar that is not the width of the content column - the v1.2.1 430-vs-448 defect', () => {
- expect(failedLabels({ ...healthy(), nav: box(9, 371) }, [380, 600]))
- .toEqual(['the bottom bar is aligned with the content column']);
- // Half a pixel is device-pixel rounding at a fractional scale, not a misalignment.
- expect(failedLabels({ ...healthy(), nav: box(0.4, 380) }, [380, 600])).toEqual([]);
- });
-
- it('fails a ring that is not square, and one that does not fit its column', () => {
- expect(failedLabels({ ...healthy(), dial: { ...box(90, 290), width: 200, height: 260 } }, [380, 600]))
- .toEqual(['the timer ring keeps its aspect ratio']);
- expect(failedLabels({ ...healthy(), dial: { ...box(0, 500), width: 500, height: 500 } }, [380, 600]))
- .toEqual(['the timer ring fits the column']);
- });
-
- it('fails a canvas whose bitmap ignores the device pixel ratio - the soft-flame defect', () => {
- // The shape the component had before Phase 10: a 300x150 bitmap whatever the box or the scale.
- expect(failedLabels({
- ...healthy(),
- canvas: { cssWidth: 100, cssHeight: 50, bitmapWidth: 300, bitmapHeight: 150 }
- }, [380, 600])).toEqual(['the fire canvas bitmap is sized in device pixels']);
- });
-
- it('fails a viewport smaller than the cell asks for, and allows the two pixels Windows adds at 125%', () => {
- // A 379px viewport also makes the 380px document too wide for it, which is the point: a cell that came out
- // narrower than it claims is measuring a layout nobody asked it to measure.
- expect(failedLabels({ ...healthy(), innerWidth: 379 }, [380, 600])).toEqual([
- 'the window reached at least 380x600 CSS px, and no more than 4 over',
- 'the document is no wider than the viewport'
- ]);
- expect(failedLabels({ ...healthy(), innerWidth: 382, innerHeight: 602 }, [380, 600])).toEqual([]);
- expect(failedLabels({ ...healthy(), innerWidth: 420 }, [380, 600]))
- .toEqual(['the window reached at least 380x600 CSS px, and no more than 4 over']);
- });
-
- it('fails a cell measured with a dialog over it, so a contaminated profile cannot read as a layout bug', () => {
- expect(failedLabels({ ...healthy(), modalOpen: true }, [380, 600]))
- .toEqual(['the route was measured with nothing over it']);
- });
-
- it('skips the ring and the canvas on a route that has neither, rather than inventing a verdict', () => {
- const checks = judgeRoute({ ...healthy(), dial: null, canvas: null }, [380, 600]);
- expect(checks.map((check: { label: string }) => check.label).join(' ')).not.toContain('ring');
- expect(checks.every((check: { pass: boolean }) => check.pass)).toBe(true);
- });
-});
-
-describe('the per-modal verdict', () => {
- const fits = {
- found: true,
- panel: { left: 16, right: 364, top: 24, bottom: 576, width: 348, height: 552 },
- overlay: { left: 0, right: 380, top: 0, bottom: 600 },
- body: { overflowY: 'auto', scrollHeight: 900, clientHeight: 420, scrollbarWidth: 'none' },
- fits: true,
- innerWidth: 380,
- innerHeight: 600
- };
- const failed = (measured: unknown): string[] =>
- judgeModal('Dialog', measured).filter((c: { pass: boolean }) => !c.pass).map((c: { label: string }) => c.label);
-
- it('passes a dialog that fits and scrolls inside itself', () => {
- expect(failed(fits)).toEqual([]);
- });
-
- it('reports a dialog that never opened, and says nothing else about it', () => {
- expect(judgeModal('Dialog', { found: false })).toHaveLength(1);
- expect(failed({ found: false })).toEqual(['Dialog: the dialog opened']);
- });
-
- it('fails a panel taller than the window - criterion 3 at a 600px height', () => {
- expect(failed({ ...fits, fits: false, panel: { ...fits.panel, bottom: 720, height: 700 } }))
- .toEqual(['Dialog: the panel is inside the viewport']);
- });
-
- it('fails a panel with no internal scroll box, and one that draws a raw scrollbar', () => {
- expect(failed({ ...fits, body: null })).toEqual([
- 'Dialog: it scrolls inside itself rather than overflowing',
- 'Dialog: the scrollbar stays hidden, as every v1.2.1 overlay did'
- ]);
- expect(failed({ ...fits, body: { ...fits.body, scrollbarWidth: 'auto' } }))
- .toEqual(['Dialog: the scrollbar stays hidden, as every v1.2.1 overlay did']);
- });
-});
-
-describe('the committed matrix report', () => {
- it('is fifteen cells, because the criterion says checked and recorded, not sampled', () => {
- expect(CELL_COUNT).toBe(15);
- expect(SCALES).toEqual([1, 1.25, 1.5]);
- expect(report).toContain('| Cells measured | 15 of 15 |');
- for (const [width, height] of SIZES) {
- for (const scale of SCALES) {
- const row = '| ' + String(width) + 'x' + String(height) + ' | ' + String(Math.round(scale * 100)) + '% |';
- expect(report, row + ' is missing from the report').toContain(row);
- }
- }
- });
-
- it('records no failure, no remote request and no CSP violation', () => {
- expect(report).toContain('| Checks failed | **0** |');
- expect(report).toContain('| Remote requests attempted | 0 |');
- expect(report).toContain('| CSP violations reported | 0 |');
- });
-
- it('names the dialogs the harness cannot reach, rather than leaving them out', () => {
- expect(MODALS_NOT_REACHED.length).toBeGreaterThan(0);
- for (const [name] of MODALS_NOT_REACHED) {
- expect(report, name + ' is not declared unreached').toContain('| ' + name + ' |');
- }
- });
-
- it('proves the fire canvas loop stopped at every scale, which is the leak criterion 4 names', () => {
- const stopped = report.match(/cancels its frame loop on unmount \| `0 frame\(s\) after leaving Home/g) ?? [];
- expect(stopped).toHaveLength(SCALES.length);
- // The control beside it: a loop that was never running could not be proved to have stopped.
- const running = report.match(/really was animating before the unmount/g) ?? [];
- expect(running).toHaveLength(SCALES.length);
- });
-});
-
-
-/*
- * The tolerance exists because the same build measured on two machines lands a device pixel apart
- * at 125%, and it is worth exactly as much as the things it still refuses.
- */
-describe('the report comparison tolerates a machine, not a layout', () => {
- it.each([
- ['the 125% column extent CI reported', '| 380x600 | 125% | index | 0.0-382.4 | 0.0-382.4 | 193x193 | 0 | 0 | 0 |', '| 380x600 | 125% | index | 0.0-381.6 | 0.0-381.6 | 193x193 | 0 | 0 | 0 |'],
- ['the 125% panel offset CI reported', '| 125% | About: the panel is inside the viewport | `panel 130.8-470.8 of 602, 31.2-351.2 of 382` |', '| 125% | About: the panel is inside the viewport | `panel 130.8-470.8 of 602, 30.8-350.8 of 382` |'],
- ['the 125% CSS width CI reported', '| 430x932 | 125% | 538x1165 | 431x932 | 1.25 | PASS | PASS | PASS | PASS |', '| 430x932 | 125% | 538x1165 | 430x932 | 1.25 | PASS | PASS | PASS | PASS |'],
- ])('accepts %s', (_label, was, now) => {
- expect(linesAgree(was, now)).toBe(true);
- });
-
- it.each([
- ['a shift a person could see', '| 380x600 | 125% | index | 0.0-382.4 | 0.0-382.4 | 193x193 | 0 | 0 | 0 |', '| 380x600 | 125% | index | 0.0-362.4 | 0.0-362.4 | 193x193 | 0 | 0 | 0 |'],
- ['two pixels where no scale rounds', '| 380x600 | 100% | index | 0.0-380.0 | 0.0-380.0 | 192x192 | 0 | 0 | 0 |', '| 380x600 | 100% | index | 0.0-378.0 | 0.0-378.0 | 192x192 | 0 | 0 | 0 |'],
- ['a PASS that became a FAIL', '| 430x932 | 125% | 538x1165 | 431x932 | 1.25 | PASS | PASS | PASS | PASS |', '| 430x932 | 125% | 538x1165 | 431x932 | 1.25 | PASS | FAIL | PASS | PASS |'],
- ['a ring that stopped scaling', '| 1920x1080 | 100% | index | 0.0-1920.0 | 0.0-1920.0 | 346x346 | 0 | 0 | 0 |', '| 1920x1080 | 100% | index | 0.0-1920.0 | 0.0-1920.0 | 300x300 | 0 | 0 | 0 |'],
- ['a failed check appearing', '| Checks failed | **0** |', '| Checks failed | **1** |'],
- ['a cell going unmeasured', '| Cells measured | 15 of 15 |', '| Cells measured | 14 of 15 |'],
- ['a CSP violation appearing', '| CSP violations reported | 0 |', '| CSP violations reported | 1 |'],
- ])('still refuses %s', (_label, was, now) => {
- expect(linesAgree(was, now)).toBe(false);
- });
-});
\ No newline at end of file
diff --git a/tests/settings-screen.test.ts b/tests/settings-screen.test.ts
index 2457240..d45f917 100644
--- a/tests/settings-screen.test.ts
+++ b/tests/settings-screen.test.ts
@@ -8,6 +8,8 @@
* setting that bounces at the boundary or refuse one the app accepts.
*/
+import fs from 'node:fs';
+import path from 'node:path';
import { describe, expect, it, vi } from 'vitest';
import {
ABOUT, DAILY_TARGET_LABEL, DESTRUCTIVE_ACTION_ID, NUMBER_FIELDS, QUICK_TARGET_HOURS, RESET_ALL_CONFIRM,
@@ -15,9 +17,8 @@ import {
reviewDraft, targetSecondsOf
} from '@renderer/features/settings/settings-view';
import type { NumberField, SettingsDraft } from '@renderer/features/settings/settings-view';
-import { DEFAULT_SETTINGS, SETTINGS_BOUNDS } from '@shared/constants/settings';
+import { DEFAULT_SETTINGS, SETTINGS_BOUNDS, SOUND_CHOICES } from '@shared/constants/settings';
import { SettingsValidationError, validateSettingsPatch } from '@main/services/settings.service';
-import { RENDERER_DESTRUCTIVE_TESTID } from '@main/config';
import ts from 'typescript';
import { read, scriptKindFor, stripCommentsAndStrings } from './helpers/ts-imports';
@@ -80,6 +81,36 @@ describe('the fields are typed in units the bounds divide into', () => {
});
});
+describe('the bundled sounds', () => {
+ // A choice with no file plays silence; a big file is a long sound, and these are meant to be blips.
+ const MAX_SOUND_BYTES = 40_000;
+
+ it.each([...SOUND_CHOICES])('$id has a short file under src/assets/sounds', (choice) => {
+ const file = path.join(__dirname, '..', 'src', 'assets', 'sounds', choice.id + '.ogg');
+ expect(fs.existsSync(file), file + ' is missing').toBe(true);
+ expect(fs.statSync(file).size).toBeLessThan(MAX_SOUND_BYTES);
+ });
+
+ it('has no file the Settings screen does not offer', () => {
+ const dir = path.join(__dirname, '..', 'src', 'assets', 'sounds');
+ const files = fs.readdirSync(dir).filter((name) => name.endsWith('.ogg')).map((name) => name.slice(0, -4));
+ expect(files.sort()).toEqual(SOUND_CHOICES.map((choice) => choice.id).sort());
+ });
+});
+
+describe('the sound settings', () => {
+ it('sends the sound switch and the chosen sound when they change, and nothing else', () => {
+ const base = stored();
+ const draft = { ...draftFrom(base), soundEnabled: false, notificationSound: 'rise' as const };
+ expect(reviewDraft(draft, base).patch).toEqual({ soundEnabled: false, notificationSound: 'rise' });
+ });
+
+ it('sends neither when they are as stored', () => {
+ const base = stored();
+ expect(reviewDraft(draftFrom(base), base).patch).toEqual({});
+ });
+});
+
describe('SET-01/SET-02: a draft that changed nothing writes nothing', () => {
it('sends no key at all when nothing was touched', () => {
const base = stored();
@@ -229,14 +260,12 @@ describe('SET-02: the daily target', () => {
});
/*
- * Criterion 4's third clause, and the only part of it a unit test can hold: the identifier the danger-zone button
- * is reached by is the one the packaged smoke looks for. That the button actually carries it, and that
- * v1.2.1's '.mt-8.mb-8 button' finds nothing, is settled in tools/smoke-packaged.mjs.
+ * The danger-zone button is reached by an identifier rather than by v1.2.1's '.mt-8.mb-8 button', which a spacing
+ * tweak could detach from the handler.
*/
describe('the destructive action is reached by a stable identifier', () => {
- it('names the same identifier the packaged smoke looks for', () => {
- expect(DESTRUCTIVE_ACTION_ID, 'the smoke would probe for a handle the screen does not carry')
- .toBe(RENDERER_DESTRUCTIVE_TESTID);
+ it('is reached by a fixed identifier, never by where the button sits', () => {
+ expect(DESTRUCTIVE_ACTION_ID).toBe('reset-all-data');
});
it('asks before deleting, and says what will go', () => {
diff --git a/tests/settings-service.test.ts b/tests/settings-service.test.ts
index bac2515..2078950 100644
--- a/tests/settings-service.test.ts
+++ b/tests/settings-service.test.ts
@@ -54,6 +54,32 @@ function attempt(h: Harness, patch: Partial): SettingsValidationError
}
}
+describe('the notification sound is one of the bundled ones, and the switch is a boolean', () => {
+ it('accepts every bundled sound and the on/off switch', () => {
+ const { service, patches } = harness();
+ service.update({ notificationSound: 'pep', soundEnabled: false });
+ expect(patches).toEqual([{ notificationSound: 'pep', soundEnabled: false }]);
+ });
+
+ it('refuses a sound the app does not ship, and writes nothing', () => {
+ const { service, patches } = harness();
+ expect(() => service.update({ notificationSound: '/etc/passwd' as never })).toThrow(SettingsValidationError);
+ expect(() => service.update({ notificationSound: 'https://example.com/a.mp3' as never }))
+ .toThrow(SettingsValidationError);
+ expect(patches).toEqual([]);
+ });
+
+ it('refuses a switch that is not a boolean', () => {
+ const { service } = harness();
+ expect(() => service.update({ soundEnabled: 'no' as never })).toThrow(SettingsValidationError);
+ });
+
+ it('defaults to the classic sound, switched on', () => {
+ expect(DEFAULT_SETTINGS.notificationSound).toBe('classic');
+ expect(DEFAULT_SETTINGS.soundEnabled).toBe(true);
+ });
+});
+
describe('CORE-03: the service covers the ten-key Settings shape and nothing else', () => {
it('knows exactly the keys the wire schema declares', () => {
expect([...SETTING_KEYS].sort()).toEqual(Object.keys(SettingsSchema.shape).sort());
diff --git a/tests/smoke-harness.test.ts b/tests/smoke-harness.test.ts
index 2a4de87..a3e6062 100644
--- a/tests/smoke-harness.test.ts
+++ b/tests/smoke-harness.test.ts
@@ -1,25 +1,16 @@
-// D-37: the packaged smoke's verdicts are pure functions of what was observed, so every case is judged here in
+// The packaged smoke's verdicts are pure functions of what was observed, so every case is judged here in
// milliseconds rather than only inside a 90-second Electron launch. The launches themselves supply the inputs.
import { describe, expect, it } from 'vitest';
+import { EXIT_CODES, SMOKE_WATCHDOG_MS } from '../src/main/config';
import {
- EXIT_CODES, SMOKE_BUNDLED_FONTS, SMOKE_ICON_MAX_WIDTH_PX, SMOKE_ICON_TEXT_MIN_WIDTH_PX, SMOKE_WATCHDOG_MS
-} from '../src/main/config';
-import { IPC_CHANNELS } from '../src/shared/ipc/channels';
-import {
- BACKUP_DIR as HARNESS_BACKUP_DIR, DATABASE_FILE as HARNESS_DATABASE_FILE,
- DEFAULT_TIMEOUT_MS, EXPECTED_APP_VERSION, EXPECTED_BUNDLED_FONTS, EXPECTED_EXIT_CODES, EXPECTED_ICON_MAX_WIDTH_PX,
- EXPECTED_ICON_TEXT_MIN_WIDTH_PX, EXPECTED_IPC_CHANNELS, EXPECTED_LATEST, EXPECTED_SERVICES,
- EXPECTED_TRAY_RESET_LABEL, EXPECTED_WATCHDOG_MS, evaluateRefusalCase, evaluateTimerCase, evaluateWalFlushed,
- evaluateWindowRecovery
+ BACKUP_DIR as HARNESS_BACKUP_DIR, DATABASE_FILE as HARNESS_DATABASE_FILE, DEFAULT_TIMEOUT_MS, EXPECTED_LATEST,
+ REFUSED_NEWER_EXIT_CODE, evaluateFreshCase, evaluateLaunch, evaluateLegacyCase, evaluateRefusalCase,
+ evaluateWalFlushed, parseSmokeReport, unpackedBinaryPath
} from '../tools/smoke-packaged.mjs';
import { LATEST } from '../src/lib/db/migrations/registry';
import { BACKUP_DIR, DATABASE_FILE } from '../src/main/database-startup';
-import { RESET_POSITION_LABEL } from '../src/main/tray';
-import { read } from './helpers/ts-imports';
-import type { SmokeCheck, SmokeReport } from '../tools/smoke-packaged.mjs';
-
-const NEWER_HASH = 'f'.repeat(64);
+import type { DatabaseObservation, SmokeCheck, SmokeReport } from '../tools/smoke-packaged.mjs';
const reportOf = (fields: Record): SmokeReport => ({ ok: false, fields });
@@ -29,236 +20,152 @@ const exitWith = (code: number): { code: number | null; signal: string | null; t
const failed = (checks: readonly SmokeCheck[]): string[] =>
checks.filter((check) => !check.pass).map((check) => check.label);
-const refusal = {
- exit: exitWith(EXIT_CODES.refusedNewer),
- report: reportOf({ SMOKE_REPORT_KIND: 'refused', SMOKE_REPORT_TITLE: 'Workflow will not open this database' }),
- hashBefore: NEWER_HASH,
- hashAfter: NEWER_HASH,
- smokeDbExists: false
+/** A window that came up, drew both screens, and played a decoded sound from inside the bundle. */
+const HEALTHY_WINDOW = {
+ SMOKE_WINDOW_CREATED: 'true',
+ SMOKE_HOME_RENDERED: 'true',
+ SMOKE_SETTINGS_RENDERED: 'true',
+ SMOKE_CONTAINER_TARGET: '28800',
+ SMOKE_SOUND_PLAYS: '1',
+ SMOKE_SOUND_SRC: 'file:///app.asar/out/renderer/assets/classic-abc.ogg',
+ SMOKE_SOUND_ERROR: '0',
+ SMOKE_SOUND_DURATION: '0.32'
};
-describe('D-30/D-37: a newer database is refused in the packaged app without a window or a write', () => {
- it('passes on the refusal the bootstrap is supposed to produce', () => {
+describe('a newer database is refused in the packaged app without a window or a write', () => {
+ const hash = 'f'.repeat(64);
+ const refusal = {
+ exit: exitWith(EXIT_CODES.refusedNewer),
+ report: reportOf({ SMOKE_REPORT_KIND: 'refused' }),
+ hashBefore: hash,
+ hashAfter: hash,
+ smokeDbExists: false
+ };
+
+ it('passes the refusal that leaves everything as it found it', () => {
expect(failed(evaluateRefusalCase(refusal))).toEqual([]);
});
- const DEVIATIONS: [string, Partial][] = [
- ['the app exited 0 instead of the refusal code', { exit: exitWith(0) }],
- ['the app exited with the wrong refusal code', { exit: exitWith(EXIT_CODES.refusedUnrecognized) }],
- ['no refusal was reported', { report: reportOf({}) }],
- ['a door refusal was reported instead', { report: reportOf({ SMOKE_REPORT_KIND: 'door' }) }],
- ['a window was created', {
- report: reportOf({ SMOKE_REPORT_KIND: 'refused', SMOKE_WINDOW_CREATED: 'true' })
- }],
- ['the file was changed', { hashAfter: '0'.repeat(64) }],
- ['a smoke database was created', { smokeDbExists: true }]
- ];
-
- it.each(DEVIATIONS)('fails when %s', (_name, deviation) => {
- expect(failed(evaluateRefusalCase({ ...refusal, ...deviation })).length).toBeGreaterThan(0);
+ it('fails a window, a changed file, a smoke database, or the wrong exit code', () => {
+ expect(failed(evaluateRefusalCase({ ...refusal, report: reportOf({ SMOKE_REPORT_KIND: 'refused', SMOKE_WINDOW_CREATED: 'true' }) })))
+ .toEqual(['no window was ever created']);
+ expect(failed(evaluateRefusalCase({ ...refusal, hashAfter: '0'.repeat(64) })))
+ .toEqual(['the database file is unchanged, byte for byte']);
+ expect(failed(evaluateRefusalCase({ ...refusal, smokeDbExists: true }))).toEqual(['no smoke database was created']);
+ expect(failed(evaluateRefusalCase({ ...refusal, exit: exitWith(0) })))
+ .toEqual(['the packaged app exited with the refused-newer code']);
});
});
-describe('D-35 item 2 / SC5: a timer seeded by one launch is imported by the next, without inflation', () => {
- const raw = JSON.stringify({
- elapsed: 3723, running: true, pomodoroMode: false, pomodoroState: 'work',
- pomodoroSessionCount: 0, lastUpdated: 1_757_000_000_000
- });
- const timer = {
- report: reportOf({ SMOKE_TIMER_IMPORT: 'timerState=imported goalDate=absent' }),
- seeded: { raw, elapsedSeconds: 3723 },
- stored: { raw, elapsedSeconds: 3723, wasRunning: true, lastUpdated: 1_757_000_000_000 },
- workSessions: 0
+describe('a healthy launch', () => {
+ const fixtureDir = '/tmp/wft-smoke-x/ud';
+ const launch = {
+ exit: exitWith(0),
+ report: { ok: true, fields: { SMOKE_APP_NAME: 'workflow-timer', SMOKE_USER_DATA: fixtureDir } } as SmokeReport,
+ fixtureDir
};
- it('passes when the raw string and the elapsed seconds both come back unchanged', () => {
- expect(failed(evaluateTimerCase(timer))).toEqual([]);
+ it('passes when it exited 0, said SMOKE_OK and stayed in the temporary directory', () => {
+ expect(failed(evaluateLaunch(launch))).toEqual([]);
});
- it('fails when elapsedSeconds grew while the app was closed (the B12 signature)', () => {
- const inflated = { ...timer.stored, elapsedSeconds: 3723 + 86_400 };
- expect(failed(evaluateTimerCase({ ...timer, stored: inflated })).length).toBeGreaterThan(0);
+ it('fails when userData is anywhere else', () => {
+ const elsewhere = { ...launch, report: { ok: true, fields: { ...launch.report.fields, SMOKE_USER_DATA: '/home/someone/.config/workflow-timer' } } };
+ expect(failed(evaluateLaunch(elsewhere))).toEqual(['userData was the temporary directory, not a real profile']);
});
- it('fails when the raw string was rewritten rather than kept verbatim', () => {
- const rewritten = { ...timer.stored, raw: '{"elapsed":3723}' };
- expect(failed(evaluateTimerCase({ ...timer, stored: rewritten })).length).toBeGreaterThan(0);
- });
-
- it('fails when app_state holds no legacy timer at all', () => {
- expect(failed(evaluateTimerCase({ ...timer, stored: null })).length).toBeGreaterThan(0);
- });
-
- it('fails when the import invented a work session', () => {
- expect(failed(evaluateTimerCase({ ...timer, workSessions: 1 })).length).toBeGreaterThan(0);
+ it('fails a nonzero exit', () => {
+ expect(failed(evaluateLaunch({ ...launch, exit: exitWith(1) }))).toContain('the packaged app exited 0');
});
});
-describe('D-32: every normal smoke exit leaves no write-ahead log behind', () => {
- it('passes when the -wal is absent', () => {
- expect(evaluateWalFlushed({ caseName: 'fresh', exists: false, size: 0 }).pass).toBe(true);
- });
+describe('a fresh install', () => {
+ const fresh = reportOf({ SMOKE_DB_CLASS: 'fresh', SMOKE_DB_VERSION: String(EXPECTED_LATEST), ...HEALTHY_WINDOW });
- it('passes when the -wal survives at 0 bytes', () => {
- expect(evaluateWalFlushed({ caseName: 'legacy', exists: true, size: 0 }).pass).toBe(true);
+ it('passes when the database is new, current, and the window works', () => {
+ expect(failed(evaluateFreshCase({ report: fresh }))).toEqual([]);
});
- it('fails when the -wal still carries frames', () => {
- expect(evaluateWalFlushed({ caseName: 'timer', exists: true, size: 32_768 }).pass).toBe(false);
+ it.each([
+ ['Home did not render', { SMOKE_HOME_RENDERED: 'false' }, 'Home rendered "Daily Target"'],
+ ['Settings did not render', { SMOKE_SETTINGS_RENDERED: 'false' }, 'Settings rendered "Settings"'],
+ ['no sound played', { SMOKE_SOUND_PLAYS: '0' }, 'the sound was played from a file inside the app'],
+ ['the sound would not decode', { SMOKE_SOUND_ERROR: '4' }, 'the sound decoded, so the bytes are really there'],
+ ['the sound came from off the machine', { SMOKE_SOUND_SRC: 'https://example.com/a.ogg' }, 'the sound was played from a file inside the app']
+ ])('fails when %s', (_what, change, label) => {
+ expect(failed(evaluateFreshCase({ report: reportOf({ ...fresh.fields, ...change }) }))).toEqual([label]);
});
});
-describe('T-04-50: the harness and the app cannot drift apart on exit codes', () => {
- it('EXPECTED_EXIT_CODES restates EXIT_CODES from src/main/config.ts exactly', () => {
- expect(EXPECTED_EXIT_CODES).toEqual({ ...EXIT_CODES });
- });
-
- it('EXPECTED_IPC_CHANNELS restates the contract channel count, so a new channel is smoked too', () => {
- expect(EXPECTED_IPC_CHANNELS).toBe(IPC_CHANNELS.length);
- });
-
- /*
- * A smoke launch shows no window and creates no tray, so a run that hangs is a process only Task Manager can
- * end. The app gives itself a deadline; the harness's kill is the backstop behind it, and the order matters -
- * the other way round, every hang is reported as an anonymous kill with nothing said about how far it got.
- */
- it('lets the app time itself out before the harness kills it', () => {
- expect(EXPECTED_WATCHDOG_MS, 'the harness and the app disagree on the deadline').toBe(SMOKE_WATCHDOG_MS);
- expect(SMOKE_WATCHDOG_MS, 'the harness would kill the launch before it could say why it stopped')
- .toBeLessThan(DEFAULT_TIMEOUT_MS);
- });
-
- it('arms that deadline on every smoke launch and fails on it, rather than passing', () => {
- const source = read('src/main/smoke.ts');
- expect(source, 'nothing arms the watchdog').toContain('armWatchdog(lines)');
- expect(source, 'a watchdog that exits ok would report a hang as a pass').toContain('EXIT_CODES.smokeStuck');
- expect(source, 'the watchdog reports a pass').toMatch(/ok: false,\s+code: EXIT_CODES\.smokeStuck/);
- expect(source, 'a finished run would still be killed by its own watchdog').toContain('clearTimeout(watchdog)');
- });
-
- // The same seven names tests/container.test.ts reads off the real container, so neither can move alone.
- it('EXPECTED_SERVICES restates what the container composes', () => {
- expect(EXPECTED_SERVICES).toBe('companies,goal,pomodoro,sessions,settings,stats,timer');
- });
-
- // SPA-08/SPA-09: the app measures, the harness judges. A bound loosened on one side alone would pass both.
- it('the icon-width bounds and the font list restate src/main/config.ts', () => {
- expect(EXPECTED_ICON_MAX_WIDTH_PX).toBe(SMOKE_ICON_MAX_WIDTH_PX);
- expect(EXPECTED_ICON_TEXT_MIN_WIDTH_PX).toBe(SMOKE_ICON_TEXT_MIN_WIDTH_PX);
- expect([...EXPECTED_BUNDLED_FONTS]).toEqual([...SMOKE_BUNDLED_FONTS]);
- });
-
- /*
- * V2-SCHEMA-02: the harness seeds and reads a file by name. Flipping DATABASE_RENAME_RELEASED changes the name
- * the app uses, and a harness still naming krono.db would seed a file the app never opens and then report a
- * green smoke against a fresh empty database. Held here so the two names cannot move apart.
- */
- it('the database filename, the backup directory and LATEST restate their sources', () => {
- expect(HARNESS_DATABASE_FILE).toBe(DATABASE_FILE);
- expect(HARNESS_BACKUP_DIR).toBe(BACKUP_DIR);
- expect(EXPECTED_LATEST).toBe(LATEST);
- });
-
- /*
- * Phase 10 criterion 5: the harness finds the tray item by its label. Renaming the item without renaming this
- * would leave the check looking for a menu entry that no longer exists - and it reports on the WHOLE menu
- * string, so an absent item reads as a pass to nobody but a careless eye.
- */
- it('the tray reset label restates src/main/tray.ts', () => {
- expect(EXPECTED_TRAY_RESET_LABEL).toBe(RESET_POSITION_LABEL);
- });
-});
-
-/*
- * Phase 10 criterion 5 again, as pure judgement. The launch supplies the numbers; what they MEAN is decided here,
- * where the two controls can be shown to fail: a saved rectangle that was never off-screen, and a window that was
- * already on a display before the reset was clicked, would both make the claim vacuous.
- */
-describe('criterion 5: an off-screen window comes back', () => {
- const recovered: Record = {
- SMOKE_DISPLAYS: '2',
- SMOKE_OFFSCREEN_SAVED: '-30000,-30000 500x700',
- SMOKE_OFFSCREEN_SAVED_ON_DISPLAY: 'false',
- SMOKE_OFFSCREEN_OPENED: '745,50 430x932',
- SMOKE_OFFSCREEN_ON_DISPLAY: 'true',
- SMOKE_TRAY_MENU: 'Workflow ' + EXPECTED_APP_VERSION +
- '|separator|Show Workflow|Reset window position|separator|Quit Workflow',
- SMOKE_TRAY_RESET_BEFORE: '-30000,-30000 500x700',
- SMOKE_TRAY_RESET_BEFORE_ON_DISPLAY: 'false',
- SMOKE_TRAY_RESET_AFTER: '745,50 430x932',
- SMOKE_TRAY_RESET_AFTER_ON_DISPLAY: 'true',
- SMOKE_TRAY_RESET_CALLS: '1',
- SMOKE_TRAY_RESET_PERSISTED: 'true',
- SMOKE_APP_VERSION: EXPECTED_APP_VERSION,
- SMOKE_TRAY_VERSION_ITEM: 'Workflow ' + EXPECTED_APP_VERSION,
- SMOKE_TRAY_UPDATE_ITEM: '',
- SMOKE_TRAY_RELEASE_OPENS: '0'
+describe('an upgrade from v1.2.1', () => {
+ const before: DatabaseObservation = {
+ userVersion: 0, companies: 2, workSessions: 3, pomodoroSessions: 1, totalDuration: 6345, nullCompanySessions: 1
+ };
+ const after: DatabaseObservation = {
+ userVersion: EXPECTED_LATEST, companies: 3, workSessions: 3, pomodoroSessions: 1, totalDuration: 6345, nullCompanySessions: 0
+ };
+ const legacy = {
+ report: reportOf({ SMOKE_DB_CLASS: 'legacy', ...HEALTHY_WINDOW }),
+ before,
+ after,
+ backups: ['krono.db.2026-01-05T12-00-00-000Z.bak']
};
- const failuresFor = (fields: Record): string[] =>
- evaluateWindowRecovery({ report: { ok: true, fields } })
- .filter((check: SmokeCheck) => !check.pass).map((check: SmokeCheck) => check.label);
-
- it('passes the launch that recovered', () => {
- expect(failuresFor(recovered)).toEqual([]);
- });
- it('fails a saved rectangle that was never off-screen, so the fallback proved nothing', () => {
- expect(failuresFor({ ...recovered, SMOKE_OFFSCREEN_SAVED_ON_DISPLAY: 'true' }))
- .toEqual(['the saved off-screen rectangle really is one this machine would refuse']);
+ it('passes when nothing was lost and one backup was taken', () => {
+ expect(failed(evaluateLegacyCase(legacy))).toEqual([]);
});
- it('fails a window that did not come back, and one the menu offers no way back for', () => {
- expect(failuresFor({ ...recovered, SMOKE_OFFSCREEN_ON_DISPLAY: 'false' }))
- .toEqual(['a window whose saved bounds are entirely off-screen opens on a display that exists']);
- expect(failuresFor({ ...recovered, SMOKE_TRAY_MENU: 'Show Workflow|separator|Quit Workflow' }))
- .toEqual(['the tray menu offers a way back for a window nobody can reach']);
+ it('fails a lost session, a changed total, and a missing or misnamed backup', () => {
+ expect(failed(evaluateLegacyCase({ ...legacy, after: { ...after, workSessions: 2 } })))
+ .toEqual(['every work session survived']);
+ expect(failed(evaluateLegacyCase({ ...legacy, after: { ...after, totalDuration: 6000 } })))
+ .toEqual(['sum(duration) is unchanged']);
+ expect(failed(evaluateLegacyCase({ ...legacy, backups: [] })))
+ .toEqual(['exactly one verified-name backup was taken']);
+ expect(failed(evaluateLegacyCase({ ...legacy, backups: ['krono.db.bak'] })))
+ .toEqual(['exactly one verified-name backup was taken']);
});
- it('fails a reset clicked on a window that was already on screen', () => {
- expect(failuresFor({ ...recovered, SMOKE_TRAY_RESET_BEFORE_ON_DISPLAY: 'true' }))
- .toEqual(['the window really was off-screen before the reset item was clicked']);
+ it('fails a session left without a company', () => {
+ expect(failed(evaluateLegacyCase({ ...legacy, after: { ...after, nullCompanySessions: 1 } })))
+ .toEqual(['no work session is left without a company']);
});
+});
- it('fails a reset that moved nothing, fired twice, or was not written back', () => {
- expect(failuresFor({ ...recovered, SMOKE_TRAY_RESET_AFTER_ON_DISPLAY: 'false' }))
- .toEqual(['clicking it put the window back on a display, and told the action exactly once']);
- expect(failuresFor({ ...recovered, SMOKE_TRAY_RESET_CALLS: '2' }))
- .toEqual(['clicking it put the window back on a display, and told the action exactly once']);
- expect(failuresFor({ ...recovered, SMOKE_TRAY_RESET_PERSISTED: 'false' }))
- .toEqual(['the reset was written back, so the next launch opens where it was moved to']);
+describe('a clean exit folds the write-ahead log back in', () => {
+ it('passes an absent or empty -wal and fails one still holding bytes', () => {
+ expect(evaluateWalFlushed({ caseName: 'fresh', exists: false, size: 0 }).pass).toBe(true);
+ expect(evaluateWalFlushed({ caseName: 'fresh', exists: true, size: 0 }).pass).toBe(true);
+ expect(evaluateWalFlushed({ caseName: 'fresh', exists: true, size: 4096 }).pass).toBe(false);
});
+});
- it('fails a launch that saw no display at all, where every answer above would be meaningless', () => {
- expect(failuresFor({ ...recovered, SMOKE_DISPLAYS: '0' }))
- .toContain('the harness saw at least one display, so the recovery claim is about something');
+describe('the harness reads the report the app writes', () => {
+ it('turns KEY=value lines into fields and SMOKE_OK into a flag', () => {
+ const report = parseSmokeReport('noise\nSMOKE_DB_CLASS=fresh\nSMOKE_OK\nSMOKE_FAIL=x=y\n');
+ expect(report.ok).toBe(true);
+ expect(report.fields).toEqual({ SMOKE_DB_CLASS: 'fresh', SMOKE_FAIL: 'x=y' });
});
- /*
- * REPO-06. The last of these is the one worth having: a smoke launch has run no check, so an update item on its
- * menu would mean the app is offering an update it has no evidence for - which is worse than offering none.
- */
- it('fails a packaged build whose version is not this repository\'s', () => {
- expect(failuresFor({ ...recovered, SMOKE_APP_VERSION: '1.2.1' }))
- .toContain('the packaged app reports the version this repository declares');
+ it('knows where each platform puts the unpacked executable', () => {
+ expect(unpackedBinaryPath('dist', 'linux', 'x64')).toMatch(/linux-unpacked[\\/]workflow-timer$/);
+ expect(unpackedBinaryPath('dist', 'win32', 'x64')).toMatch(/win-unpacked[\\/]Workflow\.exe$/);
+ expect(unpackedBinaryPath('dist', 'darwin', 'arm64')).toMatch(/mac-arm64[\\/]Workflow\.app/);
});
+});
- it('fails a tray that does not say which version is installed, or says the wrong one', () => {
- expect(failuresFor({ ...recovered, SMOKE_TRAY_VERSION_ITEM: '' }))
- .toEqual(['the tray states the installed version']);
- expect(failuresFor({ ...recovered, SMOKE_TRAY_VERSION_ITEM: 'Workflow 1.2.1' }))
- .toEqual(['the tray states the installed version']);
+describe('the harness and the app agree on the constants they both spell', () => {
+ it('the database file, backup directory and latest schema version', () => {
+ expect(HARNESS_DATABASE_FILE).toBe(DATABASE_FILE);
+ expect(HARNESS_BACKUP_DIR).toBe(BACKUP_DIR);
+ expect(EXPECTED_LATEST, 'the migration registry moved on and the smoke did not').toBe(LATEST);
});
- it('fails a tray that offers an update no check found, or a launch that opened a browser', () => {
- expect(failuresFor({ ...recovered, SMOKE_TRAY_UPDATE_ITEM: 'Update available: 9.9.9' }))
- .toEqual(['the tray offers no update, and opened nothing, when no check has run']);
- expect(failuresFor({ ...recovered, SMOKE_TRAY_RELEASE_OPENS: '1' }))
- .toEqual(['the tray offers no update, and opened nothing, when no check has run']);
+ it('the refused-newer exit code', () => {
+ expect(REFUSED_NEWER_EXIT_CODE).toBe(EXIT_CODES.refusedNewer);
});
- it('holds the version the harness expects to the one package.json declares', () => {
- const pkg = JSON.parse(read('package.json')) as Record;
- expect(EXPECTED_APP_VERSION).toBe(String(pkg['version']));
- expect(EXPECTED_APP_VERSION, 'the harness is still expecting a 1.x build').toMatch(/^2\./);
+ it('the app gives up before the harness does, so a stuck run names its own reason', () => {
+ expect(SMOKE_WATCHDOG_MS).toBeLessThan(DEFAULT_TIMEOUT_MS);
});
});
diff --git a/tests/window-hardening.test.ts b/tests/window-hardening.test.ts
index a20e4d9..44deba6 100644
--- a/tests/window-hardening.test.ts
+++ b/tests/window-hardening.test.ts
@@ -10,8 +10,7 @@ import { findAll, read, scriptKindFor } from './helpers/ts-imports';
// executeJavaScript against it, so a regression there hands Node and fs to whatever that origin contains.
const WINDOW_SOURCES: readonly string[] = [
'src/main/window.ts',
- 'src/main/legacy-storage.ts',
- 'src/main/smoke.ts'
+ 'src/main/legacy-storage.ts'
];
const REQUIRED: Readonly> = {
@@ -64,7 +63,7 @@ const literals = WINDOW_SOURCES.flatMap(windowLiterals);
describe('WR-06/T-02-04: every window this app opens is sandboxed, isolated and Node-free', () => {
it('finds a BrowserWindow in each source that opens one, so the checks below are not vacuous', () => {
- expect(literals.length, 'no BrowserWindow literal was found at all').toBeGreaterThanOrEqual(3);
+ expect(literals.length, 'no BrowserWindow literal was found at all').toBeGreaterThanOrEqual(2);
for (const file of WINDOW_SOURCES) {
expect(literals.filter((literal) => literal.file === file).length, file + ' constructs no BrowserWindow')
.toBeGreaterThan(0);
diff --git a/tools/baseline/archive-real-db.mjs b/tools/baseline/archive-real-db.mjs
deleted file mode 100644
index bccb65e..0000000
--- a/tools/baseline/archive-real-db.mjs
+++ /dev/null
@@ -1,412 +0,0 @@
-#!/usr/bin/env node
-/*
- * CUSTODY-07 - archive the owner's real, populated krono.db somewhere safe, and commit its schema and nothing else.
- *
- * D-03 is a hard constraint, not a preference: this repository is public and the database holds real client names
- * and real work notes. A committed database cannot be un-published - a history rewrite does not reach the clones
- * already taken. So the real file goes to a dated folder outside %APPDATA%\workflow-timer\ and outside this
- * working tree, and the only thing that lands in git is the DDL from sqlite_master.
- *
- * The "outside this working tree" part is enforced by resolved-absolute-path containment rather than by care, and
- * the refusal is exercised by --self-test rather than trusted.
- *
- * Why node:sqlite and not better-sqlite3: at this point in phase 01, better-sqlite3 is still 9.6.0 and its
- * better_sqlite3.node is unbuildable here (D-12 gates the upgrade behind plan 01-07). node:sqlite ships inside the
- * pinned Node 24 runtime, needs no native build, and adds no third-party supply-chain surface. Its
- * ExperimentalWarning on stderr is expected.
- *
- * Usage:
- * node tools/baseline/archive-real-db.mjs archive the real triple + extract schema
- * node tools/baseline/archive-real-db.mjs --self-test exercise both guards on synthetic data
- *
- * Environment:
- * WFT_ARCHIVE_SOURCE userData directory to read (default: %APPDATA%\workflow-timer)
- * WFT_ARCHIVE_DIR archive root to write under (default: ~/workflow-timer-archive)
- */
-
-import fs from 'node:fs';
-import os from 'node:os';
-import path from 'node:path';
-import { fileURLToPath } from 'node:url';
-import { DatabaseSync } from 'node:sqlite';
-
-const SCRIPT_NAME = 'tools/baseline/archive-real-db.mjs';
-
-/*
- * IN-06: both names, newest first. This hardcoded krono.db, so after V2-SCHEMA's rename the CUSTODY-07 archive -
- * the thing that protects the owner's real data before a risky run - would have found nothing to archive and said
- * so as "No database at ...". Whichever name is on disk is the one that gets archived, with its own sidecars.
- */
-const DB_NAMES = ['workflow.db', 'krono.db'];
-const sidecarsOf = (name) => [name + '-wal', name + '-shm'];
-
-const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
-
-/* ---------------------------------------------------------------------------------------- */
-/* Path containment */
-/* ---------------------------------------------------------------------------------------- */
-
-/*
- * Resolve a path to its real location even when it does not exist yet, by realpath-ing the
- * deepest ancestor that does. A raw string prefix test is not good enough here: on Windows
- * `~/workflow-timer-archive` and the repository can both sit under C:\Users\, symlinks and
- * junctions are common, and `..` segments in an env var would defeat a prefix comparison
- * entirely. The whole point of this guard is that it cannot be talked around.
- */
-function resolveDeep(target) {
- let current = path.resolve(target);
- const trailing = [];
- for (;;) {
- if (fs.existsSync(current)) {
- return path.join(fs.realpathSync(current), ...trailing);
- }
- const parent = path.dirname(current);
- if (parent === current) {
- return path.resolve(target);
- }
- trailing.unshift(path.basename(current));
- current = parent;
- }
-}
-
-function isInside(child, parent) {
- const rel = path.relative(resolveDeep(parent), resolveDeep(child));
- return rel === '' || (!rel.startsWith('..' + path.sep) && rel !== '..' && !path.isAbsolute(rel));
-}
-
-/*
- * The D-03 guard. Called before anything is created, read or written.
- */
-function assertDestinationIsSafe(destDir, sourceDir, guardRepoRoot) {
- if (isInside(destDir, guardRepoRoot)) {
- throw new Error(
- 'D-03 REFUSAL: the archive destination ' + resolveDeep(destDir) +
- ' is inside the repository working tree ' + resolveDeep(guardRepoRoot) + '.\n' +
- 'The real krono.db holds real client names and work notes and this repository is ' +
- 'public. A committed database cannot be un-published. Set WFT_ARCHIVE_DIR to a path ' +
- 'outside the repository.'
- );
- }
- if (isInside(destDir, sourceDir)) {
- throw new Error(
- 'D-03 REFUSAL: the archive destination ' + resolveDeep(destDir) +
- ' is inside the source userData directory ' + resolveDeep(sourceDir) + '.\n' +
- 'An archive kept inside the directory it protects against is not an archive. ' +
- 'Set WFT_ARCHIVE_DIR to a path outside userData.'
- );
- }
-}
-
-/* ---------------------------------------------------------------------------------------- */
-/* Schema extraction */
-/* ---------------------------------------------------------------------------------------- */
-
-/*
- * Reads ONLY type, name and sql from sqlite_master. No user table is ever queried. This is the
- * single place in the whole restructure where a statement runs against real user data, and it is
- * deliberately incapable of returning a row of it.
- */
-function readSchema(dbPath) {
- const db = new DatabaseSync(dbPath, { readOnly: true });
- try {
- return db
- .prepare(
- 'SELECT type, name, sql FROM sqlite_master ' +
- 'WHERE sql IS NOT NULL ORDER BY type, name'
- )
- .all();
- } finally {
- db.close();
- }
-}
-
-function renderSchema(rows, provenance) {
- const header = [
- '-- v1.2.1 schema extract - DDL ONLY, ZERO ROWS.',
- '--',
- '-- Generated by ' + SCRIPT_NAME + ' on ' + provenance.date + '.',
- '-- Source: the sqlite_master table of a copy of the real, populated krono.db.',
- '--',
- '-- This file exists because D-03 forbids the database itself from entering this public',
- '-- repository: it contains real client names and real work notes. Only the schema may be',
- '-- committed. The generator selects nothing but type, name and sql from sqlite_master and',
- '-- refuses to write this file if the result carries row data or a stray string literal.',
- '--',
- '-- D-05: this is the second of two schema sources. database/db.js holds the canonical',
- '-- v1.2.1 DDL; this is what one real installation actually has. A difference between them',
- '-- is not a bug to fix here - it is a second legacy variant that plan 01-07 and Phase 4',
- '-- must migrate.',
- '--',
- '-- Do not hand-edit. Regenerate with: node ' + SCRIPT_NAME,
- '-- Objects: ' + rows.length + ' (' + rows.map((r) => r.type + ' ' + r.name).join(', ') + ')',
- ''
- ].join('\n');
-
- const body = rows
- .map((row) => String(row.sql).replace(/\s+$/, '') + ';')
- .join('\n\n');
-
- return { header: header + '\n', body: body + '\n' };
-}
-
-/*
- * Self-assertion, run against the DDL body only - not against the generated provenance header, whose prose is
- * fixed text this script controls. Checking the header would mean the check fires on the words used to describe
- * it, which is how the first run of this script failed.
- *
- * The generator is not trusted to have selected only DDL. DEFAULT '...' is the one legitimate string literal in a
- * CREATE TABLE, so it is scrubbed first and anything still quoted afterwards fails.
- */
-function assertDdlOnly(body) {
- if (/\bINSERT\b/i.test(body)) {
- throw new Error('SCHEMA EXTRACT CARRIES ROW DATA: an insert statement is present.');
- }
- if (/\bVALUES\s*\(/i.test(body)) {
- throw new Error('SCHEMA EXTRACT CARRIES ROW DATA: a VALUES clause is present.');
- }
- const withoutDefaults = body.replace(/DEFAULT\s+'(?:[^']|'')*'/gi, 'DEFAULT ');
- const stray = withoutDefaults.match(/'(?:[^']|'')*'/g);
- if (stray && stray.length > 0) {
- throw new Error(
- 'SCHEMA EXTRACT CARRIES A STRING LITERAL THAT IS NOT A COLUMN DEFAULT: ' +
- stray.slice(0, 3).join(', ') + '. Refusing to write it.'
- );
- }
-}
-
-function writeSchemaExtract(dbPath, outPath, provenance) {
- const rows = readSchema(dbPath);
- const { header, body } = renderSchema(rows, provenance);
-
- // Check before writing, so a bad extract never reaches the filesystem at all...
- assertDdlOnly(body);
-
- const text = header + body;
- fs.mkdirSync(path.dirname(outPath), { recursive: true });
- fs.writeFileSync(outPath, text, 'utf8');
-
- // ...and again after, so what landed on disk is provably what was checked.
- try {
- const written = fs.readFileSync(outPath, 'utf8');
- if (written !== text) {
- throw new Error('SCHEMA EXTRACT DIFFERS FROM WHAT WAS VERIFIED. Refusing to keep it.');
- }
- assertDdlOnly(written.slice(header.length));
- } catch (err) {
- fs.rmSync(outPath, { force: true });
- throw err;
- }
- return { rows, text };
-}
-
-/* ---------------------------------------------------------------------------------------- */
-/* Archive */
-/* ---------------------------------------------------------------------------------------- */
-
-function archive({ sourceDir, destRoot, schemaOut, guardRepoRoot, stamp }) {
- const dateStamp = stamp || new Date().toLocaleDateString('en-CA');
- const destDir = path.join(destRoot, dateStamp);
-
- // Guard FIRST. Nothing is created, read or copied before the destination is proven safe.
- assertDestinationIsSafe(destDir, sourceDir, guardRepoRoot);
-
- const DB_NAME = DB_NAMES.find((name) => fs.existsSync(path.join(sourceDir, name)));
- if (DB_NAME === undefined) {
- throw new Error(
- 'No database at ' + DB_NAMES.map((name) => path.join(sourceDir, name)).join(' or ') +
- '. Set WFT_ARCHIVE_SOURCE.'
- );
- }
- const SIDECARS = sidecarsOf(DB_NAME);
-
- fs.mkdirSync(destDir, { recursive: true });
-
- const copied = [];
- for (const name of [DB_NAME, ...SIDECARS]) {
- const from = path.join(sourceDir, name);
- if (!fs.existsSync(from)) {
- copied.push({ name, bytes: null, note: 'absent at source' });
- continue;
- }
- /*
- * CUSTODY-03 bans fs-copying a SQLite database, and that ban is correct everywhere except here. It exists
- * because copying a LIVE database yields a file that opens fine and passes integrity_check while missing
- * every committed-but-uncheckpointed page in the -wal. None of that applies at this call site: the
- * application is not running, the measured -wal is 0 bytes, and the whole triple is copied together.
- *
- * db.backup() is deliberately NOT used, because it would require opening the owner's real database for
- * WRITE, which is exactly what T-01-17 forbids. Do not cite this site as precedent for a backup path.
- */
- // eslint-disable-next-line no-restricted-syntax -- cold archive, app not running, T-01-17 forbids opening the real DB for write
- fs.copyFileSync(from, path.join(destDir, name));
- copied.push({ name, bytes: fs.statSync(from).size, note: null });
- }
-
- // The schema is read from the COPY, never from the source. The source is touched exactly
- // once, by the read half of copyFileSync above (T-01-17).
- const { rows } = writeSchemaExtract(path.join(destDir, DB_NAME), schemaOut, { date: dateStamp });
-
- return { destDir, copied, schemaOut, objects: rows.map((r) => r.type + ' ' + r.name) };
-}
-
-/* ---------------------------------------------------------------------------------------- */
-/* Self-test */
-/* ---------------------------------------------------------------------------------------- */
-
-const SECRET_ROWS = {
- company: 'ACME-SELFTEST-CLIENT-NAME',
- note: 'SELFTEST-CONFIDENTIAL-WORK-NOTE'
-};
-
-function buildSyntheticSource(dir) {
- fs.mkdirSync(dir, { recursive: true });
- const dbPath = path.join(dir, DB_NAMES[DB_NAMES.length - 1]);
- const db = new DatabaseSync(dbPath);
- try {
- db.exec(
- 'CREATE TABLE companies (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE,' +
- ' created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, note_required INTEGER DEFAULT 0);' +
- 'CREATE TABLE work_sessions (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL,' +
- ' duration INTEGER NOT NULL, date TEXT NOT NULL, company_id INTEGER, note TEXT);' +
- 'CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);' +
- 'CREATE TABLE pomodoro_sessions (id INTEGER PRIMARY KEY AUTOINCREMENT, date TEXT NOT NULL,' +
- ' company_id INTEGER, pomodoros_completed INTEGER DEFAULT 1);'
- );
- db.prepare('INSERT INTO companies (name) VALUES (?)').run(SECRET_ROWS.company);
- db.prepare(
- 'INSERT INTO work_sessions (name, duration, date, company_id, note) VALUES (?, ?, ?, ?, ?)'
- ).run('selftest', 60, '2026-01-01', 1, SECRET_ROWS.note);
- } finally {
- db.close();
- }
- return dbPath;
-}
-
-function selfTest() {
- const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wft-selftest-'));
- const failures = [];
- try {
- const sourceDir = path.join(root, 'userData');
- buildSyntheticSource(sourceDir);
-
- /* Assertion 1 - the extract is DDL only and carries no row value. */
- const schemaOut = path.join(root, 'out', 'schema.sql');
- const result = archive({
- sourceDir,
- destRoot: path.join(root, 'archive'),
- schemaOut,
- guardRepoRoot: repoRoot,
- stamp: '1970-01-01'
- });
- const text = fs.readFileSync(schemaOut, 'utf8');
- const tables = ['companies', 'work_sessions', 'settings', 'pomodoro_sessions'];
- const missing = tables.filter((t) => !new RegExp('CREATE TABLE ' + t + '\\b').test(text));
- const leaked = Object.values(SECRET_ROWS).filter((v) => text.includes(v));
-
- if (missing.length > 0) {
- failures.push('DDL-only assertion: missing CREATE TABLE for ' + missing.join(', '));
- } else if (leaked.length > 0) {
- failures.push('DDL-only assertion: row values leaked into the extract: ' + leaked.join(', '));
- } else if (/\bINSERT\b/i.test(text)) {
- failures.push('DDL-only assertion: an INSERT reached the extract');
- } else {
- console.log(
- 'PASS DDL-only assertion: 4 CREATE TABLE statements, 0 row values, 0 INSERT.' +
- ' Archived ' + result.copied.filter((c) => c.bytes !== null).length + ' file(s).'
- );
- }
-
- /* Assertion 2 - a destination inside the repository is refused. */
- let refused = false;
- let refusalMessage = '';
- try {
- archive({
- sourceDir,
- destRoot: path.join(repoRoot, 'tests', 'fixtures', 'should-never-exist'),
- schemaOut: path.join(root, 'out', 'never-written.sql'),
- guardRepoRoot: repoRoot,
- stamp: '1970-01-01'
- });
- } catch (err) {
- refused = /D-03 REFUSAL/.test(String(err && err.message));
- refusalMessage = String(err && err.message).split('\n')[0];
- }
- const strayDir = path.join(repoRoot, 'tests', 'fixtures', 'should-never-exist');
- if (!refused) {
- failures.push('refused-destination assertion: an in-repo destination was NOT refused');
- } else if (fs.existsSync(strayDir)) {
- failures.push('refused-destination assertion: refused, but ' + strayDir + ' was created anyway');
- } else {
- console.log('PASS refused-destination assertion: ' + refusalMessage);
- }
-
- /* Assertion 3 - a destination inside the source userData directory is refused too. */
- let refusedNested = false;
- try {
- archive({
- sourceDir,
- destRoot: path.join(sourceDir, 'archive'),
- schemaOut: path.join(root, 'out', 'never-written.sql'),
- guardRepoRoot: repoRoot,
- stamp: '1970-01-01'
- });
- } catch (err) {
- refusedNested = /D-03 REFUSAL/.test(String(err && err.message));
- }
- if (!refusedNested) {
- failures.push('refused-destination assertion: an in-userData destination was NOT refused');
- } else {
- console.log('PASS refused-destination assertion: an in-userData destination is refused.');
- }
- } finally {
- fs.rmSync(root, { recursive: true, force: true });
- }
-
- if (failures.length > 0) {
- for (const f of failures) {
- console.error('FAIL ' + f);
- }
- process.exitCode = 1;
- return;
- }
- console.log('SELF_TEST_OK');
-}
-
-/* ---------------------------------------------------------------------------------------- */
-/* Entry point */
-/* ---------------------------------------------------------------------------------------- */
-
-function defaultSourceDir() {
- const appData = process.env.APPDATA;
- if (appData) {
- return path.join(appData, 'workflow-timer');
- }
- if (process.platform === 'darwin') {
- return path.join(os.homedir(), 'Library', 'Application Support', 'workflow-timer');
- }
- return path.join(os.homedir(), '.config', 'workflow-timer');
-}
-
-function main() {
- if (process.argv.includes('--self-test')) {
- selfTest();
- return;
- }
-
- const sourceDir = process.env.WFT_ARCHIVE_SOURCE || defaultSourceDir();
- const destRoot = process.env.WFT_ARCHIVE_DIR || path.join(os.homedir(), 'workflow-timer-archive');
- const schemaOut = path.join(repoRoot, 'tests', 'fixtures', 'v121-real-schema.sql');
-
- const result = archive({ sourceDir, destRoot, schemaOut, guardRepoRoot: repoRoot });
-
- console.log('Archived from: ' + resolveDeep(sourceDir));
- console.log('Archived to: ' + result.destDir);
- for (const item of result.copied) {
- console.log(' ' + item.name + ': ' + (item.note ? item.note : item.bytes + ' bytes'));
- }
- console.log('Schema extract: ' + result.schemaOut);
- console.log('Objects: ' + result.objects.join(', '));
- console.log('The archive is NOT in this repository and must never be moved into it (D-03).');
-}
-
-main();
diff --git a/tools/baseline/capture-v2.mjs b/tools/baseline/capture-v2.mjs
deleted file mode 100644
index c863f66..0000000
--- a/tools/baseline/capture-v2.mjs
+++ /dev/null
@@ -1,274 +0,0 @@
-#!/usr/bin/env node
-/*
- * Photograph the v2 SPA the way tools/baseline/capture.mjs photographed v1.2.1, so criterion 2's "diffing computed
- * styles against the Phase 1 baselines" is a computation rather than a look.
- *
- * Same property set, same reader function, same window sizes, timezone, locale, colour scheme, device scale factor
- * and LCD-text setting - imported from capture.mjs rather than copied, because a second copy of the measurement is
- * a second thing that can drift. What differs is unavoidable and is the point: v1.2.1 was four HTML documents
- * under Electron 28 with the Tailwind Play CDN, and this is one document under Electron 44 with Tailwind compiled
- * at build time.
- *
- * node tools/baseline/capture-v2.mjs --out=DIR 4 routes x 5 sizes -> DIR/computed, DIR/pixels
- * node tools/baseline/capture-v2.mjs --smoke 1 route x 1 size into a temporary directory
- *
- * The fixture is seed-baseline-db.mjs's, unchanged: the v1.2.1-shaped krono.db the baselines were taken over,
- * which this build migrates on open. A different fixture would diff the content as well as the styling.
- */
-
-/* global window, document, getComputedStyle */
-
-import fs from 'node:fs';
-import os from 'node:os';
-import path from 'node:path';
-import { execFileSync } from 'node:child_process';
-import { fileURLToPath } from 'node:url';
-import { _electron as electron } from 'playwright-core';
-
-import { COMPUTED_PROPS, SIZES, readComputedStyles } from './capture.mjs';
-import { childEnvironment } from './probe-userdata.mjs';
-import { seedDatabase } from './seed-baseline-db.mjs';
-import { unpackedBinaryPath } from '../smoke-packaged.mjs';
-
-const SCRIPT_NAME = 'tools/baseline/capture-v2.mjs';
-const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
-
-/*
- * The palette's background-dark, #101c22 - the same settled value capture.mjs waits on, read off a
- * different element. v1.2.1 carried the palette classes on ; AppShell carries them, and
- * src/renderer/index.html's is deliberately bare, so body here computes to transparent. That
- * difference is a finding in its own right and is recorded in VISUAL-PARITY-DIFF.md; waiting on body
- * would simply hang.
- */
-const SETTLED_BACKGROUND = 'rgb(16, 28, 34)';
-const SETTLED_ELEMENT = '#app-shell';
-
-/*
- * Which v2 route answers for which v1.2.1 page. `page` is the baseline artifact's stem, so a record
- * written here lands beside the one it is compared with and the filenames carry the pairing.
- */
-export const ROUTES = [
- { page: 'index', hash: '#/', heading: 'Work Timer' },
- { page: 'companies', hash: '#/companies', heading: 'Companies' },
- { page: 'work-history', hash: '#/history', heading: 'Work History' },
- { page: 'settings', hash: '#/settings', heading: 'Settings' }
-];
-
-export { COMPUTED_PROPS, SIZES };
-
-/*
- * firstWindow() is the WRONG window here. D-33 opens a hidden renderer at out/renderer/legacy-storage.html
- * to read v1.2.1's localStorage before the app window exists, and it closes itself the moment it has
- * answered - so a capture that grabbed the first window got a page that was already gone by the first
- * evaluate, reported as "Target page, context or browser has been closed".
- */
-const APP_DOCUMENT = 'out/renderer/index.html';
-
-export async function appWindow(app, timeoutMs = 30000) {
- const deadline = Date.now() + timeoutMs;
- const isApp = (candidate) => candidate.url().endsWith(APP_DOCUMENT);
- while (Date.now() < deadline) {
- const found = app.windows().find(isApp);
- if (found) return found;
- await app.waitForEvent('window', { timeout: Math.max(1, deadline - Date.now()) }).catch(() => null);
- }
- throw new Error('no window at ' + APP_DOCUMENT + ' within ' + timeoutMs + 'ms');
-}
-
-async function captureRoute(page, win, route, sizes, out, log) {
- await page.evaluate((hash) => { window.location.hash = hash; }, route.hash);
- await page.waitForFunction(
- (expected) => document.querySelector('#root h1')?.textContent?.trim() === expected,
- route.heading,
- { timeout: 30000 }
- );
- await page.waitForFunction(
- ([selector, expected]) => {
- const shell = document.querySelector(selector);
- return shell !== null && getComputedStyle(shell).backgroundColor === expected;
- },
- [SETTLED_ELEMENT, SETTLED_BACKGROUND],
- { timeout: 30000 }
- );
- await page.evaluate(() => document.fonts.ready);
-
- const written = [];
- for (const [width, height] of sizes) {
- await win.evaluate(
- (browserWindow, size) => browserWindow.setContentSize(size[0], size[1]),
- [width, height]
- );
- await page.waitForFunction((w) => window.innerWidth === w, width, { timeout: 15000 });
- /* Let the resize-driven layout and the fire canvas's ResizeObserver settle. */
- await page.waitForTimeout(150);
-
- const stem = route.page + '@' + width + 'x' + height;
- const pngPath = path.join(out, 'pixels', stem + '.png');
- const jsonPath = path.join(out, 'computed', stem + '.json');
- fs.mkdirSync(path.dirname(pngPath), { recursive: true });
- fs.mkdirSync(path.dirname(jsonPath), { recursive: true });
-
- await page.screenshot({
- path: pngPath,
- animations: 'disabled',
- caret: 'hide',
- /* The streak card's fire canvas is the one non-deterministic region, as in v1.2.1. */
- mask: [page.locator('canvas')],
- fullPage: false
- });
-
- const styles = await readComputedStyles(page);
- fs.writeFileSync(jsonPath, JSON.stringify(styles, null, 2) + '\n');
-
- written.push({ png: pngPath, json: jsonPath, elements: Object.keys(styles).length });
- log(SCRIPT_NAME + ': ' + stem + ' -> ' + written.at(-1).elements + ' elements');
- }
- return written;
-}
-
-export async function runCaptureV2(options = {}) {
- const smoke = options.smoke ?? false;
- const log = options.quiet ? () => {} : (...parts) => console.log(...parts);
- const routes = smoke ? ROUTES.slice(0, 1) : ROUTES;
- const sizes = smoke ? [[1280, 800]] : SIZES;
-
- const distDir = path.resolve(options.distDir ?? path.join(repoRoot, 'dist'));
- const binary = unpackedBinaryPath(distDir, process.platform, process.arch);
- if (!fs.existsSync(binary)) {
- throw new Error('no unpacked build at ' + binary + ' - run `npm run build:unpack` first');
- }
-
- const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'wft-capture-v2-'));
- const fixtureDir = path.join(fixtureRoot, 'ud');
- const seeded = seedDatabase(fixtureDir);
- log(SCRIPT_NAME + ': fixture seeded at ' + seeded.file + ' (today=' + seeded.dates.today + ')');
-
- const out = options.out ?? path.join(fixtureRoot, 'out');
- const remote = [];
- let app = null;
- const written = [];
-
- /*
- * ELECTRON_RUN_AS_NODE in the ambient environment boots the executable as plain Node, whose option
- * parser then rejects --no-sandbox and exits - which reads as "the app failed to launch" and not as
- * "the harness poisoned it". probe-userdata.mjs found this in Phase 1; the scrubbing is shared.
- */
- const { env, removed } = childEnvironment();
- if (removed.length > 0) log(SCRIPT_NAME + ': scrubbed from the child environment -> ' + removed.join(', '));
-
- try {
- app = await electron.launch({
- executablePath: binary,
- env,
- args: [
- '--no-sandbox',
- '--user-data-dir=' + fixtureDir,
- '--force-device-scale-factor=1',
- '--disable-lcd-text'
- ],
- timezoneId: 'Europe/Istanbul',
- locale: 'tr-TR',
- colorScheme: 'dark'
- });
-
- const page = await appWindow(app);
- const win = await app.browserWindow(page);
-
- /*
- * S3/S4: nothing remote may be reached. The CSP in index.html is what forbids it and Phase 10
- * criterion 7 is what proves it offline; this is a passive record of any attempt made while the
- * capture is driving, so a capture cannot quietly depend on a live third party the way v1.2.1's
- * would have without tools/baseline/vendor.
- */
- page.on('request', (request) => {
- if (/^https?:/.test(request.url())) remote.push(request.url());
- });
-
- await page.waitForLoadState('load');
-
- /*
- * Phase 11: the verdict must not depend on where the operator's mouse is.
- *
- * At 1920x1080 the window fills the screen, so the real pointer is inside it, Chromium applies :hover to
- * whatever it lands on, and the vocabulary diff reads that as a colour v1.2.1 never rendered. Reproduced
- * as `oklab(0.684327 -0.0772989 -0.129777 / 0.9)` on the Settings Save button - Tailwind's
- * hover:bg-primary/90, which normalises to #13a4ece6. Three consecutive runs each failed on a DIFFERENT
- * page and property, because the answer was the pointer.
- *
- * setIgnoreMouseEvents is the only version a resize cannot undo: moving or resizing a window under the
- * cursor makes Windows deliver a fresh WM_MOUSEMOVE, so a synthetic move is re-overwritten.
- */
- await win.evaluate((browserWindow) => { browserWindow.setIgnoreMouseEvents(true); });
- /* Clears any hover the window had already picked up between opening and the line above. */
- await page.mouse.move(-1, -1);
-
- for (const route of routes) {
- log(SCRIPT_NAME + ': ' + route.page + ' (' + route.hash + ')');
- written.push(...await captureRoute(page, win, route, sizes, out, log));
- }
- } finally {
- if (app) {
- const pid = app.process().pid;
- try {
- await Promise.race([
- app.evaluate(({ app: electronApp }) => electronApp.exit(0)),
- new Promise((resolve) => setTimeout(resolve, 10000))
- ]);
- } catch {
- /* already gone; the backstop below settles it either way */
- }
- try {
- if (process.platform === 'win32') {
- execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' });
- } else {
- process.kill(pid, 'SIGKILL');
- }
- } catch {
- /* already exited - the expected path */
- }
- }
- }
-
- log(SCRIPT_NAME + ': wrote ' + written.length + ' PNG and ' + written.length + ' JSON under ' + out);
- return { out, written, remote, fixtureRoot };
-}
-
-const invokedDirectly = process.argv[1] &&
- path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
-
-if (invokedDirectly) {
- const argv = process.argv.slice(2);
- const smoke = argv.includes('--smoke');
- const outArg = argv.find((a) => a.startsWith('--out='));
-
- let result;
- try {
- result = await runCaptureV2({
- smoke,
- out: outArg ? path.resolve(outArg.slice('--out='.length)) : undefined
- });
- } catch (error) {
- console.error(SCRIPT_NAME + ': ' + (error.stack ?? error.message));
- process.exit(1);
- }
-
- if (result.written.length === 0) {
- console.error(SCRIPT_NAME + ': no artifact was written');
- process.exit(1);
- }
- if (result.remote.length > 0) {
- console.error(SCRIPT_NAME + ': the app attempted ' + result.remote.length + ' remote request(s):');
- for (const url of result.remote.slice(0, 5)) console.error(SCRIPT_NAME + ': ' + url);
- process.exit(1);
- }
-
- if (!outArg) {
- try {
- fs.rmSync(result.fixtureRoot, { recursive: true, force: true, maxRetries: 20, retryDelay: 250 });
- } catch (error) {
- console.warn(SCRIPT_NAME + ': could not remove ' + result.fixtureRoot + ' (' + error.code + '); harmless');
- }
- }
- console.log(SCRIPT_NAME + ': OK - ' + result.written.length + ' PNG + ' + result.written.length + ' JSON');
- process.exit(0);
-}
diff --git a/tools/baseline/capture.mjs b/tools/baseline/capture.mjs
deleted file mode 100644
index 6162082..0000000
--- a/tools/baseline/capture.mjs
+++ /dev/null
@@ -1,392 +0,0 @@
-#!/usr/bin/env node
-/*
- * CUSTODY-10 / D-11 - photograph v1.2.1 deterministically, so Phase 8 can prove the rewrite changed nothing it
- * was not supposed to change.
- *
- * WHY THIS EXISTS. Once src/pages/*.html are deleted there is no way to re-render v1.2.1. The owner chose Tailwind
- * v4 with a compatibility layer (G1) rather than pinning v3, so the regressions this baseline has to catch are the
- * silent ones: `rounded` becoming `rounded-sm`, `shadow` becoming `shadow-sm`, `ring` becoming `ring-3`,
- * `outline-none` becoming `outline-hidden`, and changed default border, ring and placeholder colours. None is
- * visible to an eyeball comparison; all are obvious in a computed-style diff, which is why this emits JSON next to
- * every PNG.
- *
- * THE ENTRY GATE. Before this script can take a screenshot it runs tools/baseline/probe-userdata.mjs and refuses
- * to continue unless the fixture redirection has been proven IN THIS RUN. A silent redirection failure would
- * produce beautiful screenshots of the owner's real client names, committed to a public repository (D-03).
- * `--smoke --skip-probe` is asserted to exit non-zero: a gate that cannot be observed to fail is decoration.
- *
- * Modes:
- * node tools/baseline/capture.mjs 4 pages x 5 sizes -> baselines/v1.2.1/
- * node tools/baseline/capture.mjs --smoke 1 page x 1 size -> a temporary directory
- * node tools/baseline/capture.mjs --skip-probe MUST exit non-zero
- * node tools/baseline/capture.mjs --out=DIR override the output root
- *
- * Scope (D-11): playwright-core is pinned to exactly 1.63.0 because its Electron support is experimental and sits
- * outside its stability guarantees, so Phase 8 must compare against a capture taken by the same driver.
- */
-
-/*
- * The callbacks passed to page.evaluate / page.waitForFunction below are serialised and run inside
- * the RENDERER, not in this Node process, so they legitimately reference browser globals. Declared
- * inline rather than by adding globals.browser to the `**\/*.mjs` block in eslint.config.js: that
- * block covers every tooling script in the repository, and only this one file crosses into the
- * page. A file-scoped declaration keeps `no-undef` doing its job everywhere else.
- */
-/* global window, document, getComputedStyle */
-
-import crypto from 'node:crypto';
-import fs from 'node:fs';
-import os from 'node:os';
-import path from 'node:path';
-import { execFileSync } from 'node:child_process';
-import { fileURLToPath } from 'node:url';
-import { _electron as electron } from 'playwright-core';
-
-import {
- childEnvironment,
- failureReport,
- probeUserDataRedirection,
- resolveLaunch
-} from './probe-userdata.mjs';
-import { readVendorIndex, resolveVendored, seedDatabase, verifyVendor } from './seed-baseline-db.mjs';
-
-const SCRIPT_NAME = 'tools/baseline/capture.mjs';
-
-const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
-const vendorDir = path.join(repoRoot, 'tools', 'baseline', 'vendor');
-
-/* The five window sizes RESP-02 cares about, and the four pages v1.2.1 has. */
-export const SIZES = [[380, 600], [430, 932], [768, 1024], [1280, 800], [1920, 1080]];
-export const PAGES = ['index', 'companies', 'work-history', 'settings'];
-
-/* The palette's background-dark, #101c22, as Chromium reports it. Waiting on this value rather than
- * on a load state is the whole trick: the Tailwind Play CDN is a JIT engine that generates CSS from
- * the live DOM through a MutationObserver, so `load` fires long before the page is styled. */
-const SETTLED_BACKGROUND = 'rgb(16, 28, 34)';
-
-export const COMPUTED_PROPS = [
- 'display', 'position', 'width', 'height', 'margin', 'padding', 'color',
- 'background-color', 'border', 'border-radius', 'box-shadow', 'font-family', 'font-size',
- 'font-weight', 'line-height', 'letter-spacing', 'flex', 'grid-template-columns', 'gap',
- 'opacity', 'overflow', 'outline', 'z-index', 'transform'
-];
-
-const VENDORED_HOSTS = ['cdn.tailwindcss.com', 'fonts.googleapis.com', 'fonts.gstatic.com'];
-
-/* ---------------------------------------------------------------------------------------- */
-/* Vendored assets */
-/* ---------------------------------------------------------------------------------------- */
-
-/*
- * Verify once, at startup, then serve from memory. verifyVendor() checks every file on disk against
- * tools/baseline/vendor/index.json AND against the digest table in baselines/v1.2.1/MANIFEST.md, so
- * "verified against the manifest before use" is satisfied for the whole run by a single pass; the
- * alternative, re-hashing 1.8 MB on every request, would verify the same bytes dozens of times and
- * prove nothing extra.
- */
-function loadVendorCache() {
- const verification = verifyVendor();
- if (!verification.ok) {
- throw new Error(
- `vendored assets failed verification:\n - ${verification.failures.join('\n - ')}`
- );
- }
- const index = readVendorIndex();
- const cache = new Map();
- for (const asset of index.assets) {
- const body = fs.readFileSync(path.join(vendorDir, asset.file));
- const digest = crypto.createHash('sha256').update(body).digest('hex');
- if (digest !== asset.sha256) throw new Error(`${asset.file} changed between verify and read`);
- cache.set(asset.file, { body, contentType: asset.contentType });
- }
- return { index, cache, count: verification.count };
-}
-
-/* ---------------------------------------------------------------------------------------- */
-/* Capture */
-/* ---------------------------------------------------------------------------------------- */
-
-async function capturePage(page, win, name, sizes, out, log) {
- const pagePath = `src/pages/${name}.html`;
- await win.evaluate((browserWindow, target) => browserWindow.loadFile(target), pagePath);
- await page.waitForFunction(
- (file) => window.location.pathname.endsWith(file),
- `${name}.html`,
- { timeout: 30000 }
- );
- await page.waitForFunction(
- (expected) => getComputedStyle(document.body).backgroundColor === expected,
- SETTLED_BACKGROUND,
- { timeout: 30000 }
- );
- await page.evaluate(() => document.fonts.ready);
-
- const written = [];
- for (const [width, height] of sizes) {
- await win.evaluate(
- (browserWindow, size) => browserWindow.setContentSize(size[0], size[1]),
- [width, height]
- );
- /* main.js:31 makes the window frameless, so setSize and setContentSize differ. Assert the
- * renderer actually reached the requested width before capturing. */
- await page.waitForFunction((w) => window.innerWidth === w, width, { timeout: 15000 });
-
- const stem = `${name}@${width}x${height}`;
- const pngPath = path.join(out, 'pixels', `${stem}.png`);
- const jsonPath = path.join(out, 'computed', `${stem}.json`);
- fs.mkdirSync(path.dirname(pngPath), { recursive: true });
- fs.mkdirSync(path.dirname(jsonPath), { recursive: true });
-
- await page.screenshot({
- path: pngPath,
- animations: 'disabled',
- caret: 'hide',
- mask: [page.locator('#streakFireCanvas')],
- fullPage: false
- });
-
- const styles = await readComputedStyles(page);
- fs.writeFileSync(jsonPath, `${JSON.stringify(styles, null, 2)}\n`);
-
- written.push({ png: pngPath, json: jsonPath, elements: Object.keys(styles).length });
- log(`${SCRIPT_NAME}: ${stem} -> ${written.at(-1).elements} elements`);
- }
- return written;
-}
-
-/*
- * The one measurement. Phase 8 captures the v2 SPA with this same function rather than a copy of it
- * (tools/baseline/capture-v2.mjs), so "the two records were taken the same way" is structural.
- */
-export async function readComputedStyles(page, props = COMPUTED_PROPS) {
- return page.evaluate((wanted) => {
- const pathOf = (el) => {
- const parts = [];
- for (let n = el; n && n.nodeType === 1 && n !== document.documentElement; n = n.parentElement) {
- const i = Array.prototype.indexOf.call(n.parentElement?.children ?? [], n);
- parts.unshift(`${n.tagName.toLowerCase()}${n.id ? `#${n.id}` : ''}:nth-child(${i + 1})`);
- }
- return parts.join(' > ');
- };
- const result = {};
- for (const el of document.querySelectorAll('*')) {
- const cs = getComputedStyle(el);
- const record = {};
- for (const prop of wanted) record[prop] = cs.getPropertyValue(prop);
- result[pathOf(el)] = record;
- }
- return result;
- }, props);
-}
-
-export async function runCapture(options = {}) {
- const smoke = options.smoke ?? false;
- const log = options.quiet ? () => {} : (...parts) => console.log(...parts);
- const pages = smoke ? ['index'] : PAGES;
- const sizes = smoke ? [[1280, 800]] : SIZES;
-
- const vendor = loadVendorCache();
- log(`${SCRIPT_NAME}: ${vendor.count} vendored assets verified against MANIFEST.md`);
-
- const launch = resolveLaunch();
- const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'wft-capture-'));
- const fixtureDir = path.join(fixtureRoot, 'ud');
- const seeded = seedDatabase(fixtureDir);
- log(`${SCRIPT_NAME}: fixture seeded at ${seeded.file} (today=${seeded.dates.today})`);
-
- const out = options.out ?? (smoke
- ? path.join(fixtureRoot, 'out')
- : path.join(repoRoot, 'baselines', 'v1.2.1'));
-
- const { env } = childEnvironment();
- const blocked = [];
- const fulfilled = new Set();
- let app = null;
- const written = [];
-
- try {
- app = await electron.launch({
- executablePath: launch.executable,
- args: [
- ...launch.args,
- `--user-data-dir=${fixtureDir}`,
- /* Every flag below is a measured source of drift, not a precaution. */
- '--force-device-scale-factor=1',
- '--disable-lcd-text'
- ],
- env,
- timezoneId: 'Europe/Istanbul',
- locale: 'tr-TR',
- colorScheme: 'dark'
- });
-
- const context = app.context();
-
- /*
- * Two URL-PREDICATE routes rather than one catch-all glob, and the difference is not stylistic. A
- * catch-all also intercepts the file:// navigation that loads the application's own pages out of app.asar,
- * and route.continue() on a file:// request aborts it - the whole capture dies with "(-3) loading
- * file:///.../app.asar/src/pages/index.html", which reads like a missing page rather than a routing bug.
- * The glob form would not have worked anyway: the Tailwind URL has a query and no path.
- */
- const isVendoredHost = (url) => VENDORED_HOSTS.includes(url.hostname);
- const isOtherRemote = (url) => /^https?:$/.test(url.protocol) && !isVendoredHost(url);
-
- await context.route(isVendoredHost, async (route) => {
- const url = route.request().url();
- const asset = resolveVendored(url, vendor.index);
- if (!asset) {
- blocked.push(url);
- await route.abort('blockedbyclient');
- return;
- }
- const cached = vendor.cache.get(asset.file);
- fulfilled.add(asset.file);
- await route.fulfill({ body: cached.body, contentType: cached.contentType });
- });
-
- /* Fail loud, not silently online: an un-vendored remote request would put the baseline back
- * at the mercy of a live third party, which is the drift this whole directory exists to
- * remove. Blocked URLs are reported, and the run reports which hosts were actually served. */
- await context.route(isOtherRemote, async (route) => {
- blocked.push(route.request().url());
- await route.abort('blockedbyclient');
- });
-
- const page = await app.firstWindow();
- const win = await app.browserWindow(page);
-
- /*
- * Let the startup navigation finish before issuing one of our own. createWindow() calls
- * loadFile('src/pages/index.html') at app.whenReady(), and firstWindow() resolves as soon as
- * the BrowserWindow exists - not when it has loaded. Navigating on top of an in-flight load
- * cancels it, and Electron's loadFile promise rejects on the FIRST did-fail-load it sees,
- * which is the cancelled one: "(-3) loading file:///.../app.asar/src/pages/index.html". That
- * error names the file we asked for, so it reads like a missing page rather than a race.
- */
- await page.waitForLoadState('load');
-
- /* Any subresource fetched before the routes above were installed is now in the HTTP cache.
- * Clear it, or a re-navigation could be served the live CDN rather than the vendored copy
- * and the whole pinning exercise would be theatre. */
- await app.evaluate(({ session }) => session.defaultSession.clearCache());
-
- for (const name of pages) {
- log(`${SCRIPT_NAME}: ${name}`);
- written.push(...await capturePage(page, win, name, sizes, out, log));
- }
- } finally {
- if (app) {
- /* NOT app.close(): main.js:70-72 preventDefault()s the close event and hides to the
- * tray unless the tray Quit item set isQuiting, so a close request hangs with no
- * diagnostic (research pitfall 4). */
- const pid = app.process().pid;
- try {
- await Promise.race([
- app.evaluate(({ app: electronApp }) => electronApp.exit(0)),
- new Promise((resolve) => setTimeout(resolve, 10000))
- ]);
- } catch {
- /* the process may already be gone; the backstop below settles it either way */
- }
- try {
- if (process.platform === 'win32') {
- execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' });
- } else {
- process.kill(pid, 'SIGKILL');
- }
- } catch {
- /* already exited - this is the expected path */
- }
- }
- }
-
- const missedHosts = VENDORED_HOSTS.filter(
- (host) => ![...fulfilled].some((file) => hostServes(host, file))
- );
-
- const result = { out, written, blocked, fulfilled: [...fulfilled], missedHosts, fixtureRoot };
- if (blocked.length > 0) {
- log(`${SCRIPT_NAME}: WARNING - ${blocked.length} un-vendored remote request(s) were blocked:`);
- for (const url of blocked.slice(0, 5)) log(`${SCRIPT_NAME}: ${url}`);
- }
- log(`${SCRIPT_NAME}: wrote ${written.length} PNG and ${written.length} JSON under ${out}`);
- return result;
-}
-
-function hostServes(host, file) {
- if (host === 'cdn.tailwindcss.com') return file === 'tailwind.js';
- if (host === 'fonts.googleapis.com') return file.startsWith('fonts-css/');
- return file.startsWith('fonts/');
-}
-
-/* ---------------------------------------------------------------------------------------- */
-/* CLI - the entry gate lives here and nowhere else */
-/* ---------------------------------------------------------------------------------------- */
-
-const invokedDirectly = process.argv[1] &&
- path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
-
-if (invokedDirectly) {
- const argv = process.argv.slice(2);
- const smoke = argv.includes('--smoke');
- const outArg = argv.find((a) => a.startsWith('--out='));
-
- if (argv.includes('--skip-probe')) {
- console.error(
- `${SCRIPT_NAME}: REFUSING TO RUN - --skip-probe was passed.\n` +
- 'The fixture-redirection probe is not optional. Without it, a silent --user-data-dir\n' +
- 'failure produces screenshots of the owner\'s real client names and work notes, and\n' +
- 'those screenshots are committed to a public repository (D-03). This flag exists only\n' +
- 'so that the gate can be OBSERVED to fail; a gate that cannot fail is decoration.'
- );
- process.exit(3);
- }
-
- console.log(`${SCRIPT_NAME}: running the fixture-redirection probe before anything else`);
- let probe;
- try {
- probe = await probeUserDataRedirection();
- } catch (error) {
- console.error(`${SCRIPT_NAME}: ${error.message}`);
- process.exit(1);
- }
- if (!probe.ok) {
- console.error(failureReport(probe));
- process.exit(1);
- }
-
- let result;
- try {
- result = await runCapture({ smoke, out: outArg ? path.resolve(outArg.slice('--out='.length)) : undefined });
- } catch (error) {
- console.error(`${SCRIPT_NAME}: ${error.stack ?? error.message}`);
- process.exit(1);
- }
-
- if (result.written.length === 0) {
- console.error(`${SCRIPT_NAME}: no artifact was written`);
- process.exit(1);
- }
- if (result.missedHosts.length > 0) {
- console.error(
- `${SCRIPT_NAME}: no request was fulfilled from ${result.missedHosts.join(', ')} - ` +
- 'the interception is not reaching the page'
- );
- process.exit(1);
- }
- /* The fixture database always goes; in --smoke without --out the artifacts live inside it and
- * go with it, because plan 01-06 - not this one - owns the real capture run.
- *
- * Retries and a swallowed error are not laziness. The Electron process was killed a moment ago
- * and Windows does not release its handles on the fixture database synchronously, so a plain
- * rmSync loses a race and throws EPERM AFTER a completely successful capture - turning a green
- * run red for a leftover temporary directory the OS will clean up anyway. */
- try {
- fs.rmSync(result.fixtureRoot, { recursive: true, force: true, maxRetries: 20, retryDelay: 250 });
- } catch (error) {
- console.warn(`${SCRIPT_NAME}: could not remove ${result.fixtureRoot} (${error.code}); harmless`);
- }
- if (smoke) console.log(`${SCRIPT_NAME}: SMOKE_OK - ${result.written.length} PNG + ${result.written.length} JSON written, temporary output removed`);
- process.exit(0);
-}
diff --git a/tools/baseline/diff-computed.d.mts b/tools/baseline/diff-computed.d.mts
deleted file mode 100644
index ca93a8d..0000000
--- a/tools/baseline/diff-computed.d.mts
+++ /dev/null
@@ -1,53 +0,0 @@
-// Type contract for tools/baseline/diff-computed.mjs, so strict TypeScript tests can import it (TS7016).
-// Keep it in step with the script's exports.
-
-export interface SettledEntry {
- page: string;
- prop: string;
- /** `v1` for a value only v1.2.1 rendered, `v2` for one only the SPA renders. */
- side: string;
- value: string;
- why: string;
-}
-
-export interface PageDifference {
- page: string;
- prop: string;
- side: string;
- value: string;
- why?: string;
-}
-
-export interface PageClassification {
- settled: PageDifference[];
- unexplained: PageDifference[];
-}
-
-export interface DiffResult {
- design: Record;
- geometry: Record;
-}
-
-export declare const PAGES: string[];
-export declare const SIZES: string[];
-export declare const DESIGN_PROPS: string[];
-export declare const GEOMETRY_PROPS: string[];
-export declare const SETTLED: SettledEntry[];
-
-export declare function oklabToSrgb(L: number, a: number, b: number): number[];
-export declare function colourToHex(fn: string, body: string): string | null;
-export declare function resolveColours(value: string): string;
-export declare function normalise(prop: string, rawValue: string): string;
-export declare function splitTopLevel(value: string): string[];
-export declare function visibleKeys(record: unknown): string[];
-export declare function diffPage(
- baselineDir: string, v2Dir: string, page: string, props: string[]
-): { prop: string; lost: string[]; gained: string[] }[];
-export declare function settledReason(
- page: string, prop: string, side: string, value: string
-): SettledEntry | undefined;
-export declare function classifyPage(
- baselineDir: string, v2Dir: string, page: string, props: string[], explain: boolean
-): PageClassification;
-export declare function run(options?: { baselineDir?: string; v2Dir?: string }): DiffResult;
-export declare function renderReport(result: DiffResult): string;
diff --git a/tools/baseline/diff-computed.mjs b/tools/baseline/diff-computed.mjs
deleted file mode 100644
index c869c06..0000000
--- a/tools/baseline/diff-computed.mjs
+++ /dev/null
@@ -1,432 +0,0 @@
-#!/usr/bin/env node
-/*
- * Criterion 2's second gate, as a computation: diff the v2 SPA's computed styles against the Phase 1 baselines
- * under baselines/v1.2.1/computed/.
- *
- * node tools/baseline/capture-v2.mjs --out=DIR
- * node tools/baseline/diff-computed.mjs --v2=DIR [--report=FILE]
- *
- * WHY THIS IS NOT AN ELEMENT-BY-ELEMENT DIFF. The baseline keys every element by its position in the document
- * (`tag#id:nth-child(n) > ...`). v1.2.1 was four hand-written HTML documents and v2 is one React tree, so almost
- * no key exists on both sides - a keyed diff would report "100% different" and mean nothing. What the baseline is
- * FOR is named in capture.mjs's own header: catching the silent Tailwind v4 regressions, none of which is visible
- * to an eyeball. Those show up as a VALUE that v1.2.1 rendered and v2 renders nowhere, or the reverse. So this
- * diffs the value vocabulary of each property, per page, unioned over the five sizes.
- *
- * WHAT IS NORMALISED FIRST, AND WHY EACH IS NOT A DIFFERENCE:
- *
- * - COLOUR NOTATION. Tailwind v3 shipped its palette as rgb(); v4 ships the same palette as oklch(), and
- * getComputedStyle returns the notation that was specified. slate-400 is `rgb(148, 163, 184)` under v3 and
- * `oklch(0.704 0.04 256.788)` under v4 - one colour, two spellings. Every colour is resolved to 8-bit sRGB.
- * - EMPTY SHADOW SLOTS. v4's shadow chain carries more always-transparent slots than v3's, so the same shadow
- * reads as five comma-separated parts instead of three. Fully transparent zero-size parts are dropped.
- * - OUTLINE WIDTH UNDER `outline-style: none`. CSS 2.1 computed outline-width to 0 when the style is none;
- * current Chromium keeps the specified width. Both records say `none`, so neither draws an outline. This is
- * Chromium 120 vs 152, not the rewrite.
- * - `rounded-full`. v3 emitted 9999px, v4 emits calc(infinity * 1px) -> 3.35544e+07px. Both exceed half of any
- * element on any of these screens, so both draw the same pill.
- *
- * WHAT IS DELIBERATELY NOT A GATE. The twelve geometry properties (width, height, margin, padding, gap, flex,
- * grid-template-columns, transform, display, position, overflow, z-index) are reported as counts only. The DOM
- * differs by construction - v1.2.1 kept every modal in the markup and v2 mounts each one only while it opens - and
- * the content column is 40rem where the v1.2.1 column was 448px (owner, 2026-09-14), so above 448px these
- * baselines have nothing left to match. A geometry vocabulary difference is
- * therefore evidence of nothing either way.
- */
-
-import fs from 'node:fs';
-import path from 'node:path';
-import { fileURLToPath } from 'node:url';
-
-const SCRIPT_NAME = 'tools/baseline/diff-computed.mjs';
-const NEWLINE = String.fromCharCode(10);
-const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
-
-export const PAGES = ['index', 'companies', 'work-history', 'settings'];
-export const SIZES = ['380x600', '430x932', '768x1024', '1280x800', '1920x1080'];
-
-/** The properties a Tailwind rename or a changed default would move. These are the gate. */
-export const DESIGN_PROPS = [
- 'color', 'background-color', 'border', 'border-radius', 'box-shadow', 'font-family',
- 'font-size', 'font-weight', 'line-height', 'letter-spacing', 'opacity', 'outline'
-];
-
-/** Reported, never a gate - see the header. */
-export const GEOMETRY_PROPS = [
- 'display', 'position', 'width', 'height', 'margin', 'padding', 'flex',
- 'grid-template-columns', 'gap', 'overflow', 'z-index', 'transform'
-];
-
-/* ---------------------------------------------------------------------------------------- */
-/* Colour */
-/* ---------------------------------------------------------------------------------------- */
-
-const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);
-
-/** Linear-light sRGB channel to the 0..1 gamma-encoded value. */
-function gammaEncode(c) {
- return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
-}
-
-/** Oklab to 8-bit sRGB. The matrices are the ones in the CSS Color 4 specification. */
-export function oklabToSrgb(L, a, b) {
- const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
- const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
- const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
- const l = l_ * l_ * l_;
- const m = m_ * m_ * m_;
- const s = s_ * s_ * s_;
- const lr = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
- const lg = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
- const lb = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s;
- return [lr, lg, lb].map((c) => Math.round(clamp01(gammaEncode(c)) * 255));
-}
-
-const num = (token) => {
- const t = token.trim();
- if (t.endsWith('%')) return Number.parseFloat(t) / 100;
- if (t.endsWith('deg')) return Number.parseFloat(t);
- return Number.parseFloat(t);
-};
-
-/** `1`, `0.5`, `50%`, `none` -> 0..1. */
-const alphaOf = (token) => (token === undefined || token === null ? 1 : clamp01(num(token)));
-
-const hex = (rgb, alpha) =>
- '#' + rgb.map((c) => c.toString(16).padStart(2, '0')).join('') +
- Math.round(clamp01(alpha) * 255).toString(16).padStart(2, '0');
-
-/*
- * One colour function -> #rrggbbaa. Handles the four notations these records actually contain:
- * rgb(), rgba(), oklab() and oklch(), in both comma and space syntax, with an optional `/ alpha`.
- */
-export function colourToHex(fn, body) {
- const parts = body.replace(/\//g, ' / ').split(/[\s,]+/).filter((t) => t !== '');
- const slash = parts.indexOf('/');
- const channels = slash === -1 ? parts : parts.slice(0, slash);
- const alpha = slash === -1
- ? (channels.length > 3 ? alphaOf(channels[3]) : 1)
- : alphaOf(parts[slash + 1]);
- const c = channels.slice(0, 3).map(num);
- if (fn === 'rgb' || fn === 'rgba') return hex(c.map((v) => Math.round(v)), alpha);
- if (fn === 'oklab') return hex(oklabToSrgb(c[0], c[1], c[2]), alpha);
- if (fn === 'oklch') {
- const h = (c[2] * Math.PI) / 180;
- return hex(oklabToSrgb(c[0], c[1] * Math.cos(h), c[1] * Math.sin(h)), alpha);
- }
- return null;
-}
-
-/** Every colour function in a declaration, resolved to #rrggbbaa in place. */
-export function resolveColours(value) {
- return value.replace(
- /\b(rgba?|oklab|oklch)\(([^()]*)\)/g,
- (whole, fn, body) => colourToHex(fn, body) ?? whole
- );
-}
-
-/* ---------------------------------------------------------------------------------------- */
-/* Per-property normalisation */
-/* ---------------------------------------------------------------------------------------- */
-
-const TRANSPARENT_SLOT = /^#00000000 0px 0px 0px 0px$/;
-/* v3's rounded-full is 9999px; v4's is calc(infinity * 1px), which Chromium reports as 3.35544e+07px. */
-const FULL_RADIUS_PX = 9999;
-
-export function normalise(prop, rawValue) {
- let value = resolveColours(String(rawValue).trim());
-
- if (prop === 'box-shadow') {
- const kept = splitTopLevel(value).map((part) => part.trim()).filter((part) => !TRANSPARENT_SLOT.test(part));
- value = kept.length === 0 ? 'none' : kept.join(', ');
- }
-
- if (prop === 'outline') {
- /* `