From e61bc23163604828861f4f96077939181b60314a Mon Sep 17 00:00:00 2001 From: alpha Date: Wed, 26 Aug 2026 17:54:34 -0400 Subject: [PATCH] fix(test): read source-level fixtures with line endings normalised `test/language-switch.test.mjs` anchors two `methodBody` lookups on a literal newline, and nothing normalised the file it read. `core.autocrlf` is true with no `.gitattributes`, so those files arrive CRLF on a Windows checkout and LF on the Ubuntu runner: twelve checks passed in CI and failed for anyone who pulled. Green CI plus a suite that looks broken to whoever just merged is the worst direction for this to fail in, and it is the direction it failed in - it surfaced on `main` immediately after the merge, not on the branch, because the branch's working tree still held the files as they were written. `readSource` normalises on read, and every source-level test goes through it rather than each matcher learning about `\r`: the matchers are written against the source as it reads on screen and that is the useful thing about them. A check pins it, and fails without the normalisation. The alternative is a `.gitattributes` that normalises the whole tree, which is a bigger and noisier change than the problem. Co-Authored-By: Claude Opus 5 --- test/audio-device-switch.test.mjs | 14 +++++++------- test/helpers.mjs | 16 ++++++++++++++++ test/language-switch.test.mjs | 25 +++++++++++-------------- test/language.test.mjs | 9 ++------- test/rtl-rendering.test.mjs | 6 ++---- 5 files changed, 38 insertions(+), 32 deletions(-) diff --git a/test/audio-device-switch.test.mjs b/test/audio-device-switch.test.mjs index ceffb10..8aafa47 100644 --- a/test/audio-device-switch.test.mjs +++ b/test/audio-device-switch.test.mjs @@ -11,16 +11,13 @@ * by permissions - it leaves the interview with no microphone at all, mid-answer, having been * asked only to change one. */ -import { readFileSync } from 'node:fs'; - -import { codeOnly, createChecker, methodBody } from './helpers.mjs'; +import { codeOnly, createChecker, methodBody, readSource } from './helpers.mjs'; export async function run() { const { check, failures } = createChecker('audio-device-switch'); - const source = readFileSync( - new URL('../src/renderer/services/live-transcription.service.ts', import.meta.url), - 'utf8' + const source = readSource( + new URL('../src/renderer/services/live-transcription.service.ts', import.meta.url) ); const body = methodBody(source, 'async setAudioInputDevice('); @@ -61,7 +58,10 @@ export async function run() { // off - with the config naming the other one. The UI disables the picker while a swap is in // flight, but that is a guard in a different layer from the bug. const bodyCode = codeOnly(body); - check('a generation is taken before the awaits', /const seq = \+\+this\.micSwitchSeq;/.test(bodyCode)); + check( + 'a generation is taken before the awaits', + /const seq = \+\+this\.micSwitchSeq;/.test(bodyCode) + ); check( 'the generation is taken before getUserMedia', bodyCode.indexOf('this.micSwitchSeq') < bodyCode.indexOf('getUserMedia') diff --git a/test/helpers.mjs b/test/helpers.mjs index 1926995..90a470f 100644 --- a/test/helpers.mjs +++ b/test/helpers.mjs @@ -109,6 +109,22 @@ export function createChecker(name) { }; } +/** + * Read a source file for the source-level checks, with line endings normalised. + * + * `core.autocrlf` is true and there is no `.gitattributes`, so a checkout on Windows - which is + * where this project is developed - materialises these files with CRLF while CI materialises + * them with LF. Any anchor containing a literal newline therefore matches on the runner and + * fails on the developer's machine, which is the worst direction for it to fail in: green CI, + * and a suite that looks broken to whoever just pulled. + * + * Normalised on read rather than making each matcher line-ending aware, because the matchers are + * written against the source as it reads on screen and that is the useful thing about them. + */ +export function readSource(url) { + return fs.readFileSync(url, 'utf8').replace(/\r\n?/g, '\n'); +} + /** * Strip comments from TypeScript source before matching against it. * diff --git a/test/language-switch.test.mjs b/test/language-switch.test.mjs index a3492da..f0d3a89 100644 --- a/test/language-switch.test.mjs +++ b/test/language-switch.test.mjs @@ -15,18 +15,21 @@ * but the hook only sets that after awaiting the config write, so a second pick lands in * between. That is a guard in a different layer from the bug, which is why it is not the fix. */ -import { readFileSync } from 'node:fs'; - -import { codeOnly, createChecker, methodBody } from './helpers.mjs'; +import { codeOnly, createChecker, methodBody, readSource } from './helpers.mjs'; export async function run() { const { check, failures } = createChecker('language-switch'); - const source = readFileSync( - new URL('../src/renderer/services/live-transcription.service.ts', import.meta.url), - 'utf8' + const source = readSource( + new URL('../src/renderer/services/live-transcription.service.ts', import.meta.url) ); + // The anchors below contain literal newlines, which is only safe because `readSource` + // normalises: `core.autocrlf` is on with no `.gitattributes`, so this source arrives CRLF on a + // Windows checkout and LF in CI. Read raw, every one of them matches on the runner and fails + // on the machine the project is developed on. + check('the source is read with its line endings normalised', !source.includes('\r')); + // Both classes declare `async setLanguage(language: Language)`. The channel's is the first in // the file and the service's wraps it, so they are picked apart by what follows the brace. const body = methodBody(source, 'async setLanguage(language: Language): Promise {\n if'); @@ -143,10 +146,7 @@ export async function run() { // switch the user has already moved off clears the warning raised by the one that replaced it // and re-enables the trigger while that one is still reconnecting. const hook = codeOnly( - readFileSync( - new URL('../src/renderer/hooks/use-interview-language.ts', import.meta.url), - 'utf8' - ) + readSource(new URL('../src/renderer/hooks/use-interview-language.ts', import.meta.url)) ); check('the hook takes a generation per switch', /const seq = \+\+switchSeq\.current;/.test(hook)); check( @@ -165,10 +165,7 @@ export async function run() { // Its sibling has the identical shape and the identical race - `micSwitchSeq` abandons a // superseded swap in the service, and the hook has to stop reporting on it here. const deviceHook = codeOnly( - readFileSync( - new URL('../src/renderer/hooks/use-audio-input-device.ts', import.meta.url), - 'utf8' - ) + readSource(new URL('../src/renderer/hooks/use-audio-input-device.ts', import.meta.url)) ); check( 'the microphone hook takes the same guard', diff --git a/test/language.test.mjs b/test/language.test.mjs index dc99f8f..3615e8f 100644 --- a/test/language.test.mjs +++ b/test/language.test.mjs @@ -5,9 +5,7 @@ * that a stored language actually reaches the request bodies, and that an unknown one resolves to * English here rather than travelling to the backend and the ASR URL. */ -import { readFileSync } from 'node:fs'; - -import { createChecker, loadMain } from './helpers.mjs'; +import { createChecker, loadMain, readSource } from './helpers.mjs'; export async function run() { const { check, failures } = createChecker('language'); @@ -37,10 +35,7 @@ export async function run() { // no renderer entry renders a blank trigger, and a renderer entry with no enum member is an // option that resolves straight back to English when picked. The renderer source is read as // text because it is never built into electron-dist, which is all `loadMain` can reach. - const rendererSource = readFileSync( - new URL('../src/renderer/types/language.ts', import.meta.url), - 'utf8' - ); + const rendererSource = readSource(new URL('../src/renderer/types/language.ts', import.meta.url)); const rendererCodes = [...rendererSource.matchAll(/code: Language\.\w+, name: '/g)].length; const rendererEnum = [...rendererSource.matchAll(/^ \w+ = '([a-z]{2})',$/gm)].map((m) => m[1]); diff --git a/test/rtl-rendering.test.mjs b/test/rtl-rendering.test.mjs index ee35db1..0264b1c 100644 --- a/test/rtl-rendering.test.mjs +++ b/test/rtl-rendering.test.mjs @@ -17,11 +17,9 @@ * Source-level checks, like `audio-device-switch.test.mjs`: this is renderer code and the * renderer has no runtime harness in this directory. */ -import { readFileSync } from 'node:fs'; +import { codeOnly, createChecker, readSource } from './helpers.mjs'; -import { codeOnly, createChecker } from './helpers.mjs'; - -const read = (path) => codeOnly(readFileSync(new URL(path, import.meta.url), 'utf8')); +const read = (path) => codeOnly(readSource(new URL(path, import.meta.url))); export async function run() { const { check, failures } = createChecker('rtl-rendering');