Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
137 commits
Select commit Hold shift + click to select a range
1884ab5
refactor: the two components the conformance walker could not see
Harted Sep 17, 2026
127846e
test: the slot walk reads a third spelling, and CONTRIBUTING says wha…
Harted Sep 17, 2026
9b0b0ec
docs: a mutation that never applied reads as a mutant that survived
Harted Sep 17, 2026
76659cb
fix: a throw out of the vector reaches the caller that catches it
Harted Sep 17, 2026
d0082c4
fix: the port walk answers a port it bound, or nothing
Harted Sep 17, 2026
7433762
test: the collaborator that really throws, and three stale comments
Harted Sep 17, 2026
072e76e
fix: a walk that finds nothing puts the listener back
Harted Sep 17, 2026
48a306e
fix: three channels that said one thing and did another
Harted Sep 17, 2026
f5d1892
fix: the update notice belongs to the window that can open a link
Harted Sep 17, 2026
c1268ce
docs: three comments the commit before them made false
Harted Sep 17, 2026
33ee46b
fix: a second press inside the round trip the store now waits for
Harted Sep 17, 2026
7d2c9e0
refactor: the branches, exports and declarations nothing reaches
Harted Sep 18, 2026
1e5fd1b
refactor: one shape, written out again in four places
Harted Sep 18, 2026
ecd8b17
docs: two comments the serial key made harder to read
Harted Sep 18, 2026
713dc0b
fix: the Linear Interpolation fields kept two decimals of what you typed
Harted Sep 18, 2026
fe667cb
refactor: one shape, written out again in six places in the renderer
Harted Sep 18, 2026
eaab8ab
docs: the changelog line for a defect, under Fixed
Harted Sep 18, 2026
ece3b82
refactor: one shape, written twice in shared
Harted Sep 18, 2026
14ce095
fix: a v1 unit that is not a unit, and the copy the test did not ask …
Harted Sep 18, 2026
955ab51
fix: the key, the count and the message
Harted Sep 18, 2026
d751220
fix: a version the rehydrate did not re-read, and a row key two addre…
Harted Sep 18, 2026
3e94acd
refactor: what six comments and three names point at
Harted Sep 19, 2026
80986b4
docs: the lookback, one address narrower
Harted Sep 19, 2026
d2fcda3
refactor: the version is the layout store's to fetch
Harted Sep 19, 2026
9c66e28
docs: what the guard covers, and what it does not
Harted Sep 19, 2026
8bb279a
refactor: two ways past the boundary's generics, both closed
Harted Sep 19, 2026
52a71cd
test: the one method name that is not a channel's
Harted Sep 19, 2026
f7d79eb
refactor: one event list per direction
Harted Sep 19, 2026
7ee27df
refactor: the event list nothing reads, and the send that is not one
Harted Sep 19, 2026
b4388ff
docs: the two rules the event split added
Harted Sep 19, 2026
31c3731
fix: the range a scan asks for, and the field holding none
Harted Sep 19, 2026
70160f2
fix: the floor a field never gets to apply, and the pair the mask keeps
Harted Sep 19, 2026
9663207
fix: one read, two ceilings, each in the wrong place
Harted Sep 19, 2026
6d5a8d9
fix: what the raised ceiling let through, and the group that must sta…
Harted Sep 19, 2026
c6eee68
fix: the chunk is the stride, and the coils a write leaves alone
Harted Sep 19, 2026
3bd2c85
fix: the message FC5 does not owe, and the type a scan reads twice
Harted Sep 19, 2026
aa0103e
refactor: the callback a child gets is about a child, not about a row
Harted Sep 19, 2026
9ff3ef2
fix: the coils a picker draws, and four hand-written bounds
Harted Sep 19, 2026
a8434d0
refactor: the scan dialogs' leaves, one file each
Harted Sep 19, 2026
ab66659
refactor: a cell asks the column it is built on, rather than copying it
Harted Sep 19, 2026
39dcd27
fix: a delete releases the registers it was holding
Harted Sep 19, 2026
0cc666b
refactor: one store version above what the last release wrote
Harted Sep 19, 2026
5ffadaf
docs: the prose skill says a comment is not a diary
Harted Sep 19, 2026
45fe6a3
fix: a port another server holds says so
Harted Sep 19, 2026
e93508a
fix: a server the key holds no port for is opened anyway
Harted Sep 19, 2026
3af7bbe
test: what the client grid computes, and what it keeps
Harted Sep 19, 2026
7ae2d6e
fix: the inline add bar reads the unit it is looking at, and says why…
Harted Sep 19, 2026
a0ea1d5
test: the string encoder, the scan batcher, and the two mode switches
Harted Sep 19, 2026
aff42c8
fix: the value column reads the word before it stringifies it
Harted Sep 19, 2026
c31f1da
refactor: the interpolation modal is mounted when it is open
Harted Sep 19, 2026
b63a9f0
fix: a client config that cannot be read leaves the Load button working
Harted Sep 19, 2026
8d3a806
fix: Clear Config asks before it drops the mapping
Harted Sep 19, 2026
c787c2d
fix: a write owns the client until its read back is in
Harted Sep 19, 2026
3f6a0ff
refactor: checkHasConfig stops at the first type that holds something
Harted Sep 19, 2026
c72c484
fix: a server register names one address, in its key and in its param…
Harted Sep 19, 2026
ab3412f
fix: a write Modbux refused reads nothing back
Harted Sep 19, 2026
1b075ee
fix: a new unit id drops the rows the old one answered
Harted Sep 19, 2026
663771b
fix: a blank COM port is kept in the field rather than sent to main
Harted Sep 19, 2026
b5ef1d9
test: what a key and a parameter that disagree cost on the other two …
Harted Sep 19, 2026
3579cd4
fix: a unit id the store already holds leaves the grid alone
Harted Sep 19, 2026
b8aea9c
docs: a comment says what the code does, not what it replaced
Harted Sep 19, 2026
e486ece
fix: a register is dropped by the schema that will judge it
Harted Sep 19, 2026
ea250b3
fix: a write gives the device the timeout the user set
Harted Sep 19, 2026
d1ccac7
fix: read configuration answers a new unit id and a new register type
Harted Sep 19, 2026
69b7f71
fix: a unit missing a register type does not cost the whole field
Harted Sep 19, 2026
1b51c7a
fix: the redraw is the unit id's and the type's, not the address's
Harted Sep 19, 2026
2e3e5b2
fix: the read is asked for where main reads the mapping
Harted Sep 19, 2026
912bd0b
fix: a store from a newer Modbux runs the steps that save a field
Harted Sep 19, 2026
9ba819a
fix: the three read loops ask who owns the client
Harted Sep 22, 2026
a0ea3b4
fix: one predicate names who owns the client, and every control asks it
Harted Sep 22, 2026
1cf6abb
fix: Connect refuses a host or a COM port main was never given
Harted Sep 22, 2026
333459b
fix: four states the new refusal reached that it had no business refu…
Harted Sep 22, 2026
c0dc0de
fix: a connection address off disk is read for what it is
Harted Sep 22, 2026
85ba87e
fix: the read length off disk is read the same way, and the predicate…
Harted Sep 22, 2026
d04c67e
fix: a read's answer is drawn only for what it was sent under
Harted Sep 22, 2026
edc4b92
test: Escape closes the add register dialog from inside it
Harted Sep 22, 2026
e6018e3
refactor: a unit holds the addresses it was given, not 65536 of them
Harted Sep 22, 2026
5e85024
docs: the two comments the map shape left behind
Harted Sep 22, 2026
1e0a509
test: the value generator's test sits with the value generator
Harted Sep 22, 2026
c393be7
refactor: what a server holds is one object, not three maps on the tr…
Harted Sep 22, 2026
c908603
refactor: the vector is a function over the registry, not six methods…
Harted Sep 22, 2026
5dabed7
refactor: the RTU server owns its port, its generation and its one wa…
Harted Sep 22, 2026
ed12334
refactor: the TCP listeners own the port map, and the facade is the w…
Harted Sep 22, 2026
ec1ff5d
refactor: the transaction log and the port list leave the client
Harted Sep 22, 2026
fdedb52
refactor: the comments the split left pointing at names that moved, a…
Harted Sep 22, 2026
726fecb
refactor: encoding a register is its own module, beside the decoding …
Harted Sep 22, 2026
89b07bc
refactor: the address rule is a pure function, and seven tests reach it
Harted Sep 22, 2026
130c620
refactor: the byte fold is a function of what it is handed
Harted Sep 22, 2026
49fb399
docs: the comments the client split left pointing at names that moved
Harted Sep 22, 2026
87bda81
refactor: a server is one record, not six records keyed by uuid
Harted Sep 22, 2026
8c19277
fix: an unreadable field costs one server, not every server
Harted Sep 22, 2026
dbe1bae
fix: the server toggle group no longer loops on its own selector
Harted Sep 22, 2026
ef8254e
fix: the copy a bug report carries holds the blob that failed
Harted Sep 22, 2026
d59adf5
docs: the changelog line for a register one server cannot read
Harted Sep 22, 2026
f7a3bd1
docs: a selector that builds its answer throws, it does not just re-r…
Harted Sep 22, 2026
36cc8c3
fix: what main pushes about the client is not written to disk
Harted Sep 22, 2026
1686368
test: the server window case asked nothing, so it proved nothing
Harted Sep 22, 2026
06ef08b
refactor: the setters answer whether the write was taken
Harted Sep 22, 2026
69d8422
fix: Cmd+Z in the host field undoes what was typed
Harted Sep 22, 2026
0c4ae95
feat: undo and redo for the client configuration
Harted Sep 22, 2026
1d3ce37
feat: undo and redo for the server configuration
Harted Sep 22, 2026
176e483
feat: Cmd+Z and Cmd+Shift+Z undo and redo the view on screen
Harted Sep 22, 2026
21091cb
feat: the server steps travel with the server view
Harted Sep 22, 2026
71682be
feat: an undo that cannot go through says why
Harted Sep 22, 2026
0f09556
docs: the changelog lines for undo and redo
Harted Sep 22, 2026
b5ce78f
feat: modbux follows the system's setting to reduce motion
Harted Sep 22, 2026
3fa549f
test: the e2e suite runs with reduced motion, and the host run is one…
Harted Sep 22, 2026
2ee63a6
test: check only the line settings a linux pty keeps
Harted Sep 22, 2026
5fe9d38
fix: a stopped register scan shows its last rows with the stop
Harted Sep 23, 2026
7ee4264
test: keep the ipc tests from running pkexec and logout for real
Harted Sep 22, 2026
b5f79ae
test: ask main again when its inspector drops an evaluate answer
Harted Sep 23, 2026
7e2f36a
docs: the changelog says what a released version did
Harted Sep 23, 2026
2a52955
docs: the unit id scan runs to 255 in two comments
Harted Sep 23, 2026
4de271c
test: the serial specs leave the client on TCP and find socat on arm
Harted Sep 23, 2026
37351d8
test: 28 presses only the snackbar close buttons that are visible
Harted Sep 23, 2026
351e73a
test: 28 reads the Connect button, not the snackbars
Harted Sep 23, 2026
817fe96
chore: upload the out/ run's test results before dist/ empties them
Harted Sep 23, 2026
3d4ac27
test: every launch keeps the app's output, and a failed test keeps a …
Harted Sep 23, 2026
38e58cc
test: every spec file runs against an app launched for it
Harted Sep 23, 2026
b3bbdef
test: 18 counts the grid's rows, not the ones the window draws
Harted Sep 23, 2026
ec641d5
test: one app per worker again, reset in every spec file's beforeAll
Harted Sep 23, 2026
bfbbf87
chore: log before-quit with its stack, and the exit code at quit
Harted Sep 23, 2026
48d1dcd
test: a failing e2e spec no longer stops the run
Harted Sep 23, 2026
9cded59
test: the unit id scan specs wait for the scan they started
Harted Sep 23, 2026
d38db55
test: trace the app on CI only, and screenshot a failure everywhere
Harted Sep 23, 2026
74a9736
fix: a client keeps reconnecting while its device is gone
Harted Sep 23, 2026
a10e4db
docs: name the library versions the RTU server's comments describe
Harted Sep 23, 2026
1f54369
fix: the server lists no longer draw themselves on every value
Harted Sep 23, 2026
33926f9
fix: a scan no longer pauses between requests
Harted Sep 23, 2026
2a11e9b
fix: a config file opens on the side it belongs to, or says which
Harted Sep 23, 2026
ddb00a3
fix: a unit id scan and a write stop at the last address
Harted Sep 23, 2026
c34064a
fix: a unit id scan draws its results in batches
Harted Sep 23, 2026
1991fad
fix: switching read configuration reads nothing by itself
Harted Sep 23, 2026
358e1a7
fix: transactions reach the log in batches, so a fast scan can be sto…
Harted Sep 23, 2026
9103736
fix: exception 11 no longer tells the user to retry later
Harted Sep 23, 2026
b62abc8
test: 18 scrolls to each row it configures, 25 waits out the last sna…
Harted Sep 23, 2026
3313c0f
chore: React Developer Tools in the window's DevTools under yarn dev
Harted Sep 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .claude/skills/prose/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@ way to tell which is the false claim is to go and run the thing.
**In a test file, the sentence naming what the test discriminates stays and the
incident that produced it goes.**

**What the code was is not what it does.** A prop that is gone, a ref that no
longer exists, a test that was deleted, an alternative nobody is about to retry:
that is the file's diary, and git holds it. The commit message is where it goes.
What survives in the comment is the mechanism as it stands, and a past state
earns a line only where a reader would otherwise undo the change.

## Then: can this be written with fewer sentences?

Ask of each sentence:
Expand Down
5 changes: 4 additions & 1 deletion .claude/skills/test/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,10 @@ git status --porcelain <file> # empty, or the restore did not take
- **Edit by line number or by a unique string, not by the first match.** `sed`,
`replace(old, new, 1)` and a first-hit search all take the first one, and a
codebase repeats lines. If a mutation reports _no tests_ rather than a failure,
it broke the file: that is your quoting, not the code.
it broke the file: that is your quoting, not the code. **A mutation that never
applied reports every test green**, which reads exactly like a mutant that
survived, and the wrong answer to that is to write a second test. Diff the file
against the copy before believing a green run.
- **Assert what the code did, not what it said.** The value in the store, the
cell in the grid, the bytes on the wire. Not that a handler was called.
- **Assert what must appear, not what must be absent.** A thing never produced
Expand Down
20 changes: 18 additions & 2 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -146,14 +146,28 @@ jobs:
- name: Build
run: yarn electron-vite build

# --trace retain-on-failure, because the repository config sets
# on-first-retry against retries: 0 and so records nothing. A failure here
# E2E_TRACE=1 makes the fixture trace the app's own context and keep the
# trace of a failed test; --trace retain-on-failure records only the
# contexts Playwright creates, which holds the assertions. A failure here
# cannot be reproduced on the machine you are reading this from.
- name: E2E against out/
if: inputs.mode != 'packaged'
env:
E2E_TRACE: '1'
shell: bash
run: ${{ runner.os == 'Linux' && 'xvfb-run -a' || '' }} yarn playwright test --trace retain-on-failure

# The dist/ run empties test-results/ before it starts, so a failure here
# is uploaded now or not at all.
- uses: actions/upload-artifact@v7
if: failure()
with:
name: playwright-out-${{ matrix.runner }}
path: |
test-results/
playwright-report/
if-no-files-found: ignore

# not cancelled(), rather than success(): a failure in the dev run says
# nothing about the packaged app, and the packaged app is what ships. The
# job still ends red, and now it ends red with both answers in it.
Expand All @@ -166,6 +180,8 @@ jobs:

- name: E2E against dist/
if: "!cancelled() && inputs.mode != 'dev'"
env:
E2E_TRACE: '1'
shell: bash
run: ${{ runner.os == 'Linux' && 'xvfb-run -a' || '' }} yarn playwright test --config playwright.packaged.config.ts --trace retain-on-failure

Expand Down
237 changes: 200 additions & 37 deletions CHANGELOG.md

Large diffs are not rendered by default.

55 changes: 42 additions & 13 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,24 @@ population holds no violation, because a meter that reads no files passes every
rule it has.

**One store selector per field.** `useClientZustand((z) => z.a)` and then
`((z) => z.b)`, never one selector returning an object. An object literal is a
new reference on every render, so a selector that returns one re-renders its
component on every flush of any field. The same goes for a call with no selector
and for `(z) => z`, which take the whole store the long way round. The renderer
has zero of all three and zero `useShallow`, and that is why it draws a
two-thousand-row grid without either.
`((z) => z.b)`, never one selector answering something it built. zustand runs
the selector under `useSyncExternalStore` and compares its answer with
`Object.is`, so a fresh reference is a new snapshot every time React reads it,
and React reads it again on the render that follows: the component does not
render more often, it throws `Maximum update depth exceeded`. Measured at
zustand 5.0.11 on an object literal and on `Object.keys(z.servers)`, which is
the spelling that cost the whole server view on 22 Sep 2026. Anything built in
the selector counts, an array as much as an object, and so do a call with no
selector and `(z) => z`, which take the whole store the long way round. Where a
component wants a list of keys, select a value that compares equal, such as the
joined string, and split it in a `useMemo`. The renderer has zero of all of
these and zero `useShallow`, and that is why it draws a two-thousand-row grid
without either.

`conformance.test.ts` reads the object literal, the bare call and `(z) => z`.
It cannot read a call that builds one, because whether `f(x)` answers a fresh
reference is not in the AST, so a selector calling anything is a reviewer's
question.

**An action is fetched where it runs, not subscribed to.** A selector that hands
back a store function puts that function in the dependency list, and a
Expand All @@ -92,9 +104,16 @@ with nothing to read.

**Every component is wrapped in `meme`.** Props or not, one rule with no
exception to remember. A declaration counts as a component when it is rendered
as JSX somewhere or exported as its file's default. React's bare `memo` does not
satisfy it: `meme` is `memo` with `deepEqual`, and the shallow comparator is what
a mutated row defeats.
as JSX somewhere, exported as its file's default, or handed to MUI as a slot.
React's bare `memo` does not satisfy it: `meme` is `memo` with `deepEqual`, and
the shallow comparator is what a mutated row defeats.

A component handed over as a prop carries no JSX tag and need not be a default
export, so the first two alone would leave it unchecked. Three spellings count:
`slots={{ footer: X }}`, `inputComponent: X` nested inside `slotProps`, and
`inputComponent={X}` straight on the element. The shorthand of the first names
its local for the slot, which is lowercase, and the rule takes capitalised
declarations only.

**A local store is named after its component.** `<name>.zustand.ts`, matching
the global stores in `context/`, and named after the component rather than the
Expand Down Expand Up @@ -122,13 +141,23 @@ counts as carrying it.

**The three lists that name a channel agree, and so do the two that name an
event.** `IPC_CHANNELS`, `IpcHandlerSpec` and the `ipcHandle` calls in
`main/ipc.ts`; `IPC_EVENTS` and `IpcEventPayloadMap`. Typecheck catches one
direction only: a channel in `IPC_CHANNELS` and not in the spec fails to index,
`main/ipc.ts`; `EVENTS_TO_RENDERER` and `EVENTS_TO_MAIN` together against
`IpcEventPayloadMap`. Typecheck catches one direction only: a channel in
`IPC_CHANNELS` and not in the spec fails to index,
while a channel in the spec and not in `IPC_CHANNELS` is dropped by the mapped
type in silence, and one in both with no handler rejects at runtime instead. A
channel and an event name is lowercase segments, because that is where
`snakeToCamel` and `CamelCase` cannot disagree: `set_2wire` would be the method
`set_2wire` and the type `set2wire`.
`set_2wire` and the type `set2wire`. Over those segments `snakeToCamel` is
injective, and the method names are counted against each other and against
`isServerWindow`, which is the one key on `window.api` that is not a channel's.

**An event is in the list for the direction it travels, and both ends exist.**
`EVENTS_TO_RENDERER` is what `windows.send` pushes and `onEvent` hears;
`EVENTS_TO_MAIN` is what `sendEvent` pushes and `onIpcEvent` hears. One list for
both directions typechecked a send nothing listens for, and left "every event
has a sender and a listener" nothing to measure. A `send` counts only on
`windows`, because `send` is a name the platform uses too.

**Every channel that carries an object declares a schema.** TypeScript covers a
bare primitive, and a channel taking no argument has nothing to guard. The rest
Expand Down Expand Up @@ -265,7 +294,7 @@ generic driver claims the device. Every `test:all:*` ends with this round, and
- **New features need tests.** Unit tests for logic, e2e tests for UI behavior.
- **Bug fixes need a regression test.** Prove it was broken, prove it's fixed.
- **Tests must be deterministic.** No flaky tests. No "works on my machine". Use `waitForTimeout()` for UI settling and animations, but use `toPass()` when asserting on data that needs time to arrive.
- **e2e tests run serially** with `maxFailures: 1`. One failure stops the entire suite. This is intentional.
- **e2e tests run serially**, and every spec file starts with a `beforeAll` that calls `resetApp`, which the fixture refuses to run a test without. A failure skips the rest of a `describe.serial` and touches no other file, so one run reports every failing file.

### Writing e2e tests

Expand Down
6 changes: 3 additions & 3 deletions e2e/fixtures/arduino-port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ export type PortChoice = { port: string; reason?: undefined } | { port?: undefin
/**
* The Arduino's serial port, or why there isn't one.
*
* Replaces the page.pause() these specs used to open, which needed a person to
* pick the port by hand and so kept the hardware round out of the unattended
* suites. The vendor ID is what identifies the board: `manufacturer` reads
* The board is found here rather than through a `page.pause()` that waits for
* a person to pick the port, which is what keeps the hardware round in the
* unattended suites. The vendor ID is what identifies it: `manufacturer` reads
* "Microsoft" on Windows, where the generic usbser driver claims the device.
*
* Returns a reason rather than throwing, so the caller decides between skipping
Expand Down
147 changes: 98 additions & 49 deletions e2e/fixtures/electron-app.ts
Original file line number Diff line number Diff line change
@@ -1,58 +1,108 @@
import {
test as base,
_electron as electron,
type ElectronApplication,
type Page
} from '@playwright/test'
import { launchOptions } from './launch'
import { createWriteStream, mkdirSync } from 'fs'
import { join } from 'path'
import { test as base, expect, type ElectronApplication, type Page } from '@playwright/test'
import { launchElectron, evaluateMain } from './launch'
import { cleanServerState, navigateToHome } from './helpers'

export type ElectronFixtures = {
electronApp: ElectronApplication
mainPage: Page
}

/** The spec files whose `beforeAll` ran `resetApp` in this worker. */
const resetFiles = new Set<string>()

const NO_MOTION =
'*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }'

/**
* Everything the app writes, kept next to the traces.
* Brings the worker's app back to what a fresh launch shows, for the spec
* file whose `beforeAll` calls it.
*
* Playwright owns the child process and nothing reads its stdio, so a run that
* ends with "Target page, context or browser has been closed" says only that
* the app is gone. The exit line below is the point of this: a code means the
* app came down on its own, a signal means something else took it.
* The app is shared by every file a worker runs, so each file inherited what
* the one before it left: 27 left the client on RTU, and 12, 15 and 18 passed
* in the suite and failed on their own. Every file under this fixture calls it
* first, and a test in a file that did not is refused.
*
* The workflow uploads test-results/ when a run fails, so the file travels with
* the trace it belongs to.
* Main holds what the renderer's storage does not: the split out server
* window, the client's connection, and every server with its listener. Those
* go first, through the app, and then the storage is cleared and the window
* reloaded, which hands main the default client configuration through the
* store's `init`.
*/
export function keepOutput(app: ElectronApplication): void {
const dir = join(process.cwd(), 'test-results')
mkdirSync(dir, { recursive: true })

const worker = process.env.TEST_WORKER_INDEX ?? '0'
const log = createWriteStream(join(dir, `electron-main-${worker}.log`), { flags: 'a' })
const stamp = (): string => new Date().toISOString()

// The persistence spec launches its own app beside the worker's, and both
// write here, so every line says which process it came from.
const proc = app.process()
const pid = proc.pid
proc.stdout?.on('data', (c: Buffer) => log.write(`[${stamp()}] ${pid} out ${c.toString()}`))
proc.stderr?.on('data', (c: Buffer) => log.write(`[${stamp()}] ${pid} err ${c.toString()}`))
proc.on('exit', (code, signal) => {
log.write(`[${stamp()}] ${pid} exit code=${code} signal=${signal}\n`)
})
export async function resetApp(app: ElectronApplication, page: Page): Promise<void> {
await evaluateMain(() =>
app.evaluate(({ BrowserWindow }) => {
BrowserWindow.getAllWindows()
.filter((w) => w.getTitle() !== 'Modbux')
.forEach((w) => w.close())
})
)
await page.evaluate(() => window.api.disconnect())

await cleanServerState(page)
await page.getByTestId('server-mode-tcp-btn').click()

await page.evaluate(() => localStorage.clear())
await page.reload()
await page.waitForLoadState('domcontentloaded')
await page.addStyleTag({ content: NO_MOTION })
await navigateToHome(page)
await expect(page.getByTestId('home-client-btn')).toBeVisible()

resetFiles.add(base.info().file)
}

export type ElectronFixtures = {
electronApp: ElectronApplication
mainPage: Page
}
/**
* A screenshot of every open window for every test that fails, and a trace of
* the app beside it when `E2E_TRACE=1`.
*
* Playwright's own `trace` option records the contexts it creates itself, and
* the app's is not one of them, so a failed test's trace held its assertions
* and no DOM. Tracing runs on the app's context for the whole worker, one
* chunk per test, and a chunk is written only when its test failed. An app
* that is already gone has no chunk to stop, and its log says why.
*
* Recording cost 06, 11 and 21 together 1.2 and 1.3 min against 1.0 min
* without, so the workflow turns it on and a local run leaves it off: a spec
* that fails locally is run again on its own with `E2E_TRACE=1`.
*/
const tracing = process.env.E2E_TRACE === '1'

type ElectronTestFixtures = { electronTrace: void }

// eslint-disable-next-line @typescript-eslint/ban-types
export const test = base.extend<{}, ElectronFixtures>({
export const test = base.extend<ElectronTestFixtures, ElectronFixtures>({
electronTrace: [
async ({ electronApp }, use, testInfo): Promise<void> => {
if (!resetFiles.has(testInfo.file)) {
throw new Error(`${testInfo.file} has no beforeAll that calls resetApp`)
}
const context = tracing ? electronApp.context().tracing : undefined
await context?.startChunk({ title: testInfo.title })
await use()
if (testInfo.status === testInfo.expectedStatus) {
await context?.stopChunk().catch(() => undefined)
return
}
for (const [i, window] of electronApp.windows().entries()) {
await window
.screenshot({ path: testInfo.outputPath(`window-${i}.png`) })
.catch(() => undefined)
}
await context
?.stopChunk({ path: testInfo.outputPath('electron-trace.zip') })
.catch(() => undefined)
},
{ auto: true }
],
electronApp: [
// eslint-disable-next-line no-empty-pattern
async ({}, use): Promise<void> => {
const app = await electron.launch(launchOptions())
keepOutput(app)
const app = await launchElectron()
if (tracing) await app.context().tracing.start({ snapshots: true })

await app.evaluate((ctx) =>
ctx.session.defaultSession.clearStorageData({ storages: ['localstorage'] })
await evaluateMain(() =>
app.evaluate((ctx) =>
ctx.session.defaultSession.clearStorageData({ storages: ['localstorage'] })
)
)

await use(app)
Expand All @@ -70,8 +120,10 @@ export const test = base.extend<{}, ElectronFixtures>({
searchCount++

// Check BrowserWindow title (HTML <title> was removed to not override it)
const found = await electronApp.evaluate(({ BrowserWindow }) =>
BrowserWindow.getAllWindows().some((w) => w.getTitle() === 'Modbux')
const found = await evaluateMain(() =>
electronApp.evaluate(({ BrowserWindow }) =>
BrowserWindow.getAllWindows().some((w) => w.getTitle() === 'Modbux')
)
)

if (found && electronApp.windows().length === 1) {
Expand All @@ -88,10 +140,7 @@ export const test = base.extend<{}, ElectronFixtures>({
await page.waitForTimeout(500)

// Disable CSS animations/transitions globally for faster test execution
await page.addStyleTag({
content:
'*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }'
})
await page.addStyleTag({ content: NO_MOTION })

await use(page)
},
Expand Down
40 changes: 40 additions & 0 deletions e2e/fixtures/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,28 @@ export async function selectRegisterType(p: Page, name: string): Promise<void> {
await p.waitForTimeout(200)
}

/**
* Clear the client's register configuration.
*
* `confirms` says whether the dialog is expected, which the caller knows and a
* sample after a fixed wait only guesses: the button asks whenever the mapping
* or the name holds anything, and clears at once when neither does. Both
* answers are asserted, so a configuration that is not in the state the caller
* thinks it is fails here rather than several lines later.
*/
export async function clearClientConfig(p: Page, confirms = true): Promise<void> {
await p.getByTestId('clear-config-btn').click()
const confirm = p.getByTestId('clear-config-confirm-btn')

if (!confirms) {
await expect(confirm).toHaveCount(0)
return
}

await confirm.click()
await expect(confirm).toHaveCount(0)
}

/** Locate a specific cell in the MUI DataGrid (client side) */
export function cellLocator(p: Page, rowId: number, field: string): Locator {
return p.locator(`.MuiDataGrid-row[data-id="${rowId}"] [data-field="${field}"]`)
Expand Down Expand Up @@ -840,3 +862,21 @@ export async function splitOutServerWindow(app: ElectronApplication, p: Page): P
await serverPage.waitForLoadState('domcontentloaded')
return serverPage
}

/**
* Close the add register dialog with Escape, pressed inside it.
*
* MUI 9.3.1 puts the Escape handler on the modal root: `getRootProps` in
* `Modal/useModal.js` hands `onKeyDown` to that element, so the key closes the
* dialog only while focus is inside it. Add & Next disables the button under
* the pointer while the submit is in flight, which blurs it to `body`, and
* `FocusTrap` puts focus back from an interval of 50 ms. Measured with a probe:
* `document.activeElement` is `BODY` immediately after the click and a DIV
* inside the modal 300 ms later. `keyboard.press` between those two lands on
* `body` and the dialog stays open. Pressing on the address field focuses it
* first, which is what the assertion below then holds.
*/
export async function closeAddRegisterModal(p: Page): Promise<void> {
await p.getByTestId('add-reg-address-input').locator('input').press('Escape')
await expect(p.getByTestId('add-reg-address-input')).not.toBeVisible()
}
Loading
Loading