Studio: fix the Windows-only path failures in the frontend test suite, and catch the shape from Linux - #8980
Conversation
|
Windows evidence, since Frontend CI is ubuntu-only and cannot see either Windows failure. Replicated the branch onto a staging repo and ran the frontend suite on real runners. At this head,
The control reverted only the
The 12 were 9 from The staging PRs are closed, never merged. |
|
Still current at |
…9026) * Repair the prompt-queue contract test against the queueing refactor Repo tests (CPU) is red on main. test_composer_only_queues_behind_the _current_chat greps thread.tsx for code that #8952 moved or replaced, so it fails on any branch regardless of what that branch changes. It is currently red on #8935, #8964, #8980 and #8983 for this reason alone. The behaviour it guards is intact, and in two of the three cases the code that replaced it is stronger than what the test still asserted, so the assertions are repointed rather than dropped: - Queueing moved out of handleSubmit into an extracted queueComposerText, so the Cmd/Ctrl+Enter path could share it. Assert the delegation in handleSubmit and the queueing inside queueComposerText, which keeps this a contract on behaviour rather than on where the code sits. - promptQueueStartPendingRef.current.has(reservationKey) became a .get(reservationKey) === identity comparison. A reservation can be replaced between the start and the callback, and acting on the successor would dispatch the wrong prompt; presence alone never caught that. - temporary: useChatRuntimeStore.getState().incognito became temporary: incognitoAtQueueStart, captured when the queue starts instead of read live at dispatch. A chat toggled out of temporary mid-queue must not have its queued prompts persisted. Each rewritten assertion was checked against a deliberately broken tree rather than assumed to discriminate. Removing the delegation, swapping the identity check back to presence, reading temporary live again, and stopping queueComposerText from queueing each fail the file. tests/studio 11 passed for this file, whole directory green. * Read the identity check out of the dispatch guard, not the whole file Reported on this PR and correct. The assertion searched thread.tsx for promptQueueStartPendingRef.current.get(reservationKey) === anywhere, and the abort and cleanup branches beside the dispatch carry the same comparison. The dispatch guard could regress to .has(reservationKey) on its own, which is exactly the bug this assertion exists to catch, and the test would still pass on its neighbours. It now slices the if condition that guards the startPromptQueue call and asserts the identity comparison there, with .has excluded from that guard. All three comparisons are still required by count, because the other two are load-bearing too: abort without it reports the successor's start as this one's failure, and cleanup without it deletes the successor's entry. 11 passed. Rewriting the dispatch guard to .has fails it; it passed before. --------- Co-authored-by: danielhanchen <unslothshared@gmail.com>
microphone-permission-reset.test.ts builds its dynamic import specifier with fileURLToPath, the same mistake copy-to-clipboard.test.ts made. On Windows that is a "D:\\..." path, which node's ESM loader rejects, and the three tests that load the module fail. The suite now also asserts both shapes are absent, from any platform, so Frontend CI on ubuntu can see a Windows-only path defect being reintroduced.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0fae7b1b51
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const tainted = new Set<string>(); | ||
| const isTainted = (expr: ts.Node): boolean => | ||
| subtreeHas(expr, seed) || | ||
| identifiersIn(expr).some((name) => tainted.has(name)); |
There was a problem hiding this comment.
Track taint by binding rather than identifier text
When one file uses const path = location.pathname in one scope and later uses an unrelated const path = new URL(...) with readFile(path) in another, this file-wide Set<string> marks both bindings as tainted and rejects the portable URL read. Because common names such as path, file, and files are likely to be reused across tests, track declaration bindings or lexical scopes rather than identifier text to avoid blocking valid changes.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct, and the collision is realistic here: path, file and files are all reused across this suite. Fixed in 5232e71 by resolving identifiers lexically. There is now a per-scope declaration table (variable declarations, parameters, function declarations, import bindings), a name is resolved by walking out through enclosing scopes, and the taint set holds declaration nodes rather than text. An unresolved name is never tainted, which is the safe direction.
The file's own detector cases cover it: they include a const path = url.pathname used only for display in one function and a portable const path = new URL(...) passed to readFile in another. Swapping the resolver back to the name-keyed lookup fails that case with "the pathname rule fired on a correct file".
| const target = n.arguments[0]; | ||
| if (target && reaches(target, urlPathnames, isPathnameRead)) { | ||
| result.pathnameToFs.push(at(n)); |
There was a problem hiding this comment.
Scan every filesystem path argument
For multi-path APIs already included in FS_PATH_APIS, such as copyFile and cp, only inspecting arguments[0] misses an equally non-portable destination: copyFile(sourceUrl, destinationUrl.pathname) still fails on Windows while this guard remains green. Inspect all path-bearing argument positions for these APIs so the new portability check enforces its stated guarantee.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Agreed. Fixed in 5232e71. FS_PATH_APIS is now a map from entry point to how many of its leading arguments are a path, so copyFile, cp, rename, link and symlink are scanned in both positions and the rest in one.
Covered by a new detector case, copyFile(sourceUrl, destination.pathname), where only the destination is wrong. Reverting the scan to arguments[0] fails it.
Review: a file-wide set of names let 'const path = url.pathname' in one scope reject an unrelated 'const path = new URL(...)' read in another, and scanning only arguments[0] missed copyFile(source, destination.pathname). Resolve identifiers through their enclosing scopes instead, and map each fs entry point to how many of its leading arguments are a path. Both are covered by the file's own detector cases: reverting either fails them.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5232e7187e
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const assigned = (n: ts.Node): ts.Node | null => | ||
| ts.isVariableDeclaration(n) && | ||
| ts.isIdentifier(n.name) && | ||
| n.initializer !== undefined && | ||
| isTainted(n.initializer) |
There was a problem hiding this comment.
Track taint through assignment expressions
When a test builds a value in multiple steps, such as let path = ""; path = url.pathname; await readFile(path), this analysis stays green because assigned only examines initializers on VariableDeclaration nodes and ignores subsequent assignment expressions. Track writes back to the resolved binding as well, or this common refactoring can reintroduce the Windows-only failure without triggering the guard.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Agreed, and it is the likelier refactor of the two. Fixed in cab5194: taint now propagates through assignment expressions as well as initializers, resolved to the same binding.
Detector case added: let where = ""; where = new URL(...).pathname; await readFile(where, "utf8"). Dropping the assignment step fails it.
| function calleeName(call: ts.CallExpression): string | null { | ||
| if (ts.isIdentifier(call.expression)) return call.expression.text; | ||
| if (ts.isPropertyAccessExpression(call.expression)) | ||
| return call.expression.name.text; | ||
| return null; |
There was a problem hiding this comment.
Resolve aliased filesystem imports
When an fs function is imported under an alias, for example import { readFile as read } from "node:fs/promises", calleeName returns read, so read(new URL("./x", import.meta.url).pathname) is not recognized as an fs call and the portability test passes. Resolve call sites to their import bindings rather than matching only the local callee spelling.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct. Fixed in cab5194: the callee is resolved through the declaration table, and when it lands on an import specifier the name matched is the one on the module's side of the as. fs.readFile through a namespace import was already handled by the property access branch.
Detector case added: import { readFile as read } followed by read(url.pathname). Removing the import-specifier branch fails it.
| // arguments are one. Every one of them breaks the same way when handed a Windows | ||
| // `file:` pathname, and a destination breaks exactly as a source does, so | ||
| // copyFile(sourceUrl, destination.pathname) has to be caught as well. | ||
| const FS_PATH_APIS = new Map([ |
There was a problem hiding this comment.
Cover the remaining path-taking filesystem APIs
When a test uses an omitted fs API such as createReadStream(new URL("./fixture", import.meta.url).pathname), the same Windows failure occurs but this map does not classify the call, so the new guard remains green. Include the other path-taking entry points—particularly createReadStream and createWriteStream, as well as APIs such as readlink, truncate, and watch—so the assertion actually covers filesystem reads and writes rather than only this subset.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Agreed. Fixed in cab5194. The map now covers createReadStream, createWriteStream, readlink, truncate, watch, watchFile, unwatchFile, rmdir, mkdtemp, openAsBlob, glob, chmod, chown and utimes alongside the read/write core, each with its argument count so the two-path ones stay scanned in both positions.
Detector case added for createReadStream(url.pathname). Removing that one entry fails it.
Review: a value built in steps (let path = ""; path = url.pathname) was invisible, an fs entry point imported under an alias was not recognised as one, and the entry point list stopped at the read/write core. Track assignment as well as initialization, resolve a callee through its import binding so the module's own name is what is matched, and extend the list to the stream, link, glob and metadata entry points. Each has its own detector case; reverting any one of the three fails them.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cab5194ee0
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const isFileURLToPathCall = (n: ts.Node): boolean => | ||
| ts.isCallExpression(n) && | ||
| ((ts.isIdentifier(n.expression) && n.expression.text === "fileURLToPath") || | ||
| (ts.isPropertyAccessExpression(n.expression) && | ||
| n.expression.name.text === "fileURLToPath")); |
There was a problem hiding this comment.
Resolve aliases before seeding native-path taint
When fileURLToPath is imported under an alias, such as import { fileURLToPath as toPath } from "node:url", a call to toPath(...) does not satisfy this spelling-based predicate. Consequently, const modulePath = toPath(...); import(modulePath) passes the new portability test even though it still produces the Windows-only ERR_UNSUPPORTED_ESM_URL_SCHEME; resolve the callee through its import binding as is already done for aliased filesystem calls.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct, and inconsistent of me to have alias-resolved the fs side and not this one. Fixed in a17c165: the seed now resolves the callee to its import binding and requires the module to be node:url, the same shape as the fs rule.
Detector case added: import { fileURLToPath as toPath } followed by import(toPath(...)). Reverting the seed to the spelling check fails it.
| if (ts.isPropertyAccessExpression(call.expression)) { | ||
| return call.expression.name.text; // fs.readFile(...) |
There was a problem hiding this comment.
Limit path API matches to node:fs bindings
When a test calls an unrelated method whose name happens to appear in FS_PATH_APIS, this branch classifies it as a filesystem call without checking the receiver. For example, router.open(new URL("https://example.test/x").pathname) is reported as a Windows filesystem violation even though it performs no filesystem access, so future tests using common method names such as open, watch, or link can be blocked incorrectly; verify that the member is reached through a node:fs or node:fs/promises binding before applying the rule.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Agreed, and this is the direction that matters most: a rule that fires on correct code is worse than no rule. Fixed in a17c165. A call is classified as filesystem only when the callee resolves to a binding imported from node:fs, node:fs/promises, fs or fs/promises, for the named, default and namespace forms alike. An unresolved or unrelated receiver is never classified.
Two detector cases: fs.readFileSync(url.pathname) through import * as fs must fire, and router.open(url.pathname) plus router.link(url.pathname) on a plain object must not. Dropping the module check fails the second; dropping namespace-import resolution fails the first.
The scan still sees 341 filesystem calls and 150 dynamic imports across 390 files after the restriction, which the file's own anti-vacuity assertions hold it to.
Review: an aliased fileURLToPath did not seed the native-path taint, and an unrelated method sharing a name with an fs entry point (router.open, dom.link) was classified as one. A call is now resolved to its import binding and to the module that binding came from, so node:url and node:fs decide both rules and a local spelling decides neither. Namespace imports are resolved too. Three more detector cases cover it: aliased fileURLToPath, fs.readFileSync through a namespace import, and a router.open on a URL pathname that must stay quiet.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a17c16548b
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const binding = | ||
| assigned(n) ?? reassigned(n) ?? collected(n) ?? iterated(n); |
There was a problem hiding this comment.
Propagate taint through local helper parameters
When a pathname crosses a local helper boundary, such as function load(path) { return readFile(path); } load(new URL("./x", import.meta.url).pathname), none of these four propagation cases taints the parameter, so the filesystem call is reported as clean even though it still fails on Windows. Map tainted call arguments to the resolved function parameters, including the analogous helper pattern around dynamic import().
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Agreed. Fixed in c7d3d12: taint now flows from a call argument to the parameter it lands on, for a locally declared function or an arrow bound to a const. The fixpoint loop already existed, so it composes with the other steps; the propagation functions return every binding they find rather than one, since a single call can taint two parameters.
It is an over-approximation on purpose, and the comment says so: a helper called with a tainted argument at one site and a clean one at another taints every use inside it. That is the right direction here, because the tainted call site is itself the defect.
Two cases added, one per rule: function load(target) { return readFile(target, "utf8"); } called with a pathname, and const load = (specifier) => import(specifier) called with a fileURLToPath result. Removing the step fails both.
| const record = (name: ts.Node, declaration: ts.Node): void => { | ||
| if (!ts.isIdentifier(name)) return; |
There was a problem hiding this comment.
Record bindings introduced by destructuring
When a test uses the idiomatic const { pathname } = new URL("./x", import.meta.url); await readFile(pathname), the variable declaration reaches record, but its ObjectBindingPattern is discarded here and assigned likewise accepts only identifier names. The later identifier therefore cannot resolve to a tainted binding, allowing the same Windows-only /D:/... read to pass this guard.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct on both halves, and they needed separate fixes. In c7d3d12 the declaration table now walks binding patterns and records each element as its own declaration, so const { pathname, href } = url taints pathname and leaves href clean rather than tainting or missing the pair. And an object binding element whose property is pathname now seeds the taint directly, since destructuring reads the property without producing a member access for the old seed to match.
Binding elements also inherit a tainted right-hand side, so const [first] = taintedList propagates.
Three cases: const { pathname } = url, the renamed const { pathname: where } = url, and const { href, origin } = url feeding import(), which must stay quiet. Reverting either half fails them.
| const isTainted = (expr: ts.Node): boolean => | ||
| subtreeHas(expr, seed) || | ||
| identifiersIn(expr).some((use) => { | ||
| const declaration = resolve(use, table); | ||
| return declaration !== null && tainted.has(declaration); | ||
| }); |
There was a problem hiding this comment.
Stop native-path taint after pathToFileURL
When code correctly sanitizes a native path before importing it—for example, const native = fileURLToPath(url); const specifier = pathToFileURL(native).href; await import(specifier)—this expression still taints specifier merely because it reads native. The guard then rejects a valid cross-platform file URL, blocking the standard conversion needed when the module location originates as a filesystem path; model pathToFileURL(...).href as a sanitizer rather than propagating taint through it.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Agreed, and this is the direction that actually gets a rule deleted. Fixed in c7d3d12. The traversal now takes a barrier that stops the descent at a node and everything under it, and pathToFileURL from node:url is one for the native-path rule, so the round trip is accepted however the value is built.
One correction to the suggestion though. It is a sanitizer for that rule only, and I have deliberately not applied it to the pathname rule: on Windows a URL pathname is /D:/..., which is not a native path, so pathToFileURL(url.pathname) yields a URL for a file that does not exist. Treating it as a barrier there would silence a real defect.
A sanitizer is the one change that can quietly turn a rule off, since silencing it and repairing it look identical from outside, so it has its own test asserting all three halves: the repaired pathToFileURL(native).href stops firing; a native path still reaching import() in the same file, with pathToFileURL imported and used elsewhere in it, still fires; and readFile(pathToFileURL(pathname)) still fires. Removing the barrier fails the first, over-applying it to the pathname rule fails the third.
Re-checked afterwards that the fix has not blunted anything: reverting each of copy-to-clipboard.test.ts, desktop-stop-intent.test.ts and microphone-permission-reset.test.ts to its main version still fires the right rule. The scan still parses 390 files, 150 dynamic imports and 341 filesystem calls, unchanged by the narrowing.
Windows before and after, paired, on the same jobFrontend CI runs on ubuntu only, so neither of these failures is visible in this repo's own checks. Both arms below ran the frontend unit suite on
The after arm was run twice, on two independent staging repositories, and both report 3598 of 3598 with nothing skipped. The test count rises from 3594 to 3598 because the two PRs add four tests. The 13 that fail on mainClipboard and Tauri writer selection dominate, with one marker-key case and two permission cases. None of them is in the streaming or rendering path, and the same set fails on unrelated branches, so they are pre-existing on What this leavesThe failures themselves are one half. The other half is that nothing in CI would have reported them, which is what #9099 addresses by running the frontend unit tests on Windows. Landing this PR without that one fixes the tests and leaves the blind spot, so the pair belongs together. Numbers here are measurement. The claim that the same 13 fail on unrelated branches is measurement as well, taken from staging runs on two other branches. Nothing in this comment rests on a job that cannot fail: the control arm is a real red, which is what makes the after arm mean anything. |
|
Three corrections to the grouping in the comment above. The counts and the two arms are right; the prose splits them wrongly. It is three permission cases, not two. The prose says "one marker-key case and two permission cases", which only accounts for 12 of the 13. The list itself is right: They are microphone permission, not notification permission. All three come from "Clipboard and Tauri writer selection dominate" reads as three unrelated problems. There are two, and neither is about the behaviour named. Twelve of the 13 are one mechanism: A native path from The 13th, So the accurate summary is two shapes of path and URL confusion across three files, not three feature areas. One detail on the test count. 3594 to 3598 is four tests, and all four are from this PR, the |
Two corrections to the comment aboveThe 13 are two mechanisms, not three groups, and the third set is microphone permission rather than notification permission. Nothing in this suite references The accurate breakdown:
The 12 split as 9 in That distinction decides the test-versus-source question. The error is raised by There is also a 14th failure, intermittent, which was not in the original count. Worth recording that the old version of that test measured nothing on either platform: with |
…ToFileURL
Review, three items on the portability rules.
A pathname crossing a local helper boundary was invisible: the argument is
tainted, the parameter it lands on was not. Taint now flows from a call argument
to the parameter of a locally declared function or arrow, which also covers the
same shape around dynamic import().
Destructuring introduced bindings the declaration table discarded, so
'const { pathname } = url' followed by readFile(pathname) passed. Binding
elements are now recorded individually, and an object binding element naming
pathname seeds the taint the way a member access does, so only that element is
tainted and its siblings stay clean.
The opposite risk, a false positive that would get the rule weakened: taint now
stops at pathToFileURL, which is the exact inverse of fileURLToPath, so the
standard round trip for a module location that starts life as a path is accepted.
Deliberately not a barrier for the pathname rule, where a Windows pathname is
'/D:/...' and converting it produces a URL for a file that does not exist.
Each has its own case, and the sanitizer has an inverse one asserting the
unrepaired forms still fire.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c7d3d1261f
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const isPathnameRead = (n: ts.Node): boolean => | ||
| (ts.isPropertyAccessExpression(n) && n.name.text === "pathname") || |
There was a problem hiding this comment.
Restrict pathname taint to URL-derived values
This predicate treats every property named pathname as a file-URL pathname without inspecting its receiver. Consequently, valid code such as storing a native path in { pathname: fileURLToPath(url) } and passing entry.pathname to readFile is rejected even though it is portable; track whether the receiver is derived from a URL rather than seeding taint solely from the property name.
Useful? React with 👍 / 👎.
| const passed = (n: ts.Node): ts.Node[] => { | ||
| if (!ts.isCallExpression(n) || !ts.isIdentifier(n.expression)) return []; | ||
| const parameters = parametersOf(resolve(n.expression, table)); | ||
| if (!parameters) return []; |
There was a problem hiding this comment.
Propagate taint out of local helper returns
The helper propagation only maps tainted call arguments into parameters and never carries a tainted return expression back to the call result. Thus common wrappers such as function modulePath() { return fileURLToPath(url); } await import(modulePath()) or a helper returning url.pathname to readFile pass this guard despite retaining the Windows-only failure; resolve local calls' return expressions and taint the receiving expression or binding.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 677d93ae31
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (!tainted.has(binding)) { | ||
| tainted.add(binding); | ||
| changed = true; |
There was a problem hiding this comment.
Clear taint after an in-place sanitized overwrite
When a native-path variable is repaired in place, such as let specifier = fileURLToPath(url); specifier = pathToFileURL(specifier).href; await import(specifier), the first write permanently adds its declaration to this monotonic set and the clean second write can never remove it. The current sanitizer handling therefore still rejects this valid cross-platform conversion unless callers introduce a new variable; account for sanitized overwrites rather than retaining taint from every historical value.
Useful? React with 👍 / 👎.
| if (ts.isPropertyAccessExpression(call.expression)) { | ||
| const receiver = call.expression.expression; | ||
| const declaration = ts.isIdentifier(receiver) | ||
| ? resolve(receiver, table) | ||
| : null; |
There was a problem hiding this comment.
Resolve filesystem calls through the promises facade
When a test uses the standard import fs from "node:fs"; fs.promises.readFile(url.pathname) form, the receiver here is the nested fs.promises property access rather than an identifier, so declaration is null and fsPathArguments skips the call. The bad /D:/... pathname still fails on Windows, so resolve the root imported binding through the promises facade as well.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Include statfs in the filesystem path API map
Even after the expanded API list, the current map still omits Node's statfs and statfsSync path-taking entry points. A test using statfs(new URL("./fixture", import.meta.url).pathname) is consequently ignored and can retain the same Windows-only pathname failure; add both synchronous and promise/callback spellings to the map.
Useful? React with 👍 / 👎.
…sts (#8979) * Studio: stop a settings module reading a chat store key before it exists * Fix two module resolution failures in the frontend test suite * Fix the third Windows-only path failure in the frontend test suite * Drop the #8980 content this branch no longer needs * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Trim the comments this PR added --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: danielhanchen <unslothshared@gmail.com>
… the build gates (#8983) * Fix two module resolution failures in the frontend test suite * Studio CI: make a failing browser smoke say why, and stop it skipping the build gates * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix the third Windows-only path failure in the frontend test suite * Drop the #8980 content this branch no longer needs * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Snapshot the vite tail before printing it, and put the startup bundle gate ahead of the smokes The failure dump iterated the live deque the drain thread is still appending to, while writing each line to stdout, so a vite server that was still talking during the dump raised deque mutated during iteration and dropped the tail in exactly the noisy failure the dump was added for. list() of a deque is atomic; take it first. Startup bundle budget still ran after the browser smokes, and every step carries an implicit success(), so a red smoke skipped it. It only needs dist/, so it moves up with the other build gates. * Tighten comments in the smoke diagnostics changes * Upload the failing smoke's own report, not every report but that one The failure upload globbed logs/playwright-*, which four of the five browser smokes write. playwright_settings_tabs.py writes logs/settings_tabs_report.json and, for the blocked-chunk arm, logs/settings_tabs_blocked_report.json. So when either settings smoke failed the artifact contained the reports of the smokes that had passed and not the one that had just failed, which is the opposite of what this upload is for. Add both names to the upload path, and guard it: the new test reads every logs/ path the wired-up smokes actually write out of their own source and fails if one is not matched by an upload pattern. Shown red on the bare glob first, naming logs/settings_tabs_report.json. * Upload the non-blocking smoke's report on the runs where it is the point The stream-pacing smoke is continue-on-error, which rewrites its CONCLUSION to success while leaving its OUTCOME as failure. The artifact upload was gated on a bare failure(), so on the runs where that smoke was the only thing that failed -- exactly the runs where its report is the whole point -- the upload was skipped and logs/playwright-stream-pacing went nowhere. Give the step an id and OR its raw outcome into the upload condition. Guarded: the new test walks every continue-on-error browser smoke and fails if it has no id, or if its outcome is not named in the upload condition. Shown red two independent ways first, reverting the condition to bare failure() and separately deleting the step id, each naming the step. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: danielhanchen <unslothshared@gmail.com>
* Fix two module resolution failures in the frontend test suite
* Fix the third Windows-only path failure in the frontend test suite
* Measure how the chat thread's interaction cost grows with message count
Studio's chat UI is reported as sluggish on Windows 11 and worsening as the
thread fills, while token generation is unaffected. That shape says the cost is
per-message renderer work, so the thing to establish first is the curve.
New smoke page mounts the real Thread against a synthetic local runtime, seeded
to N messages, each carrying prose plus one code fence plus one KaTeX block so
Streamdown, Shiki and KaTeX all pay their per-message price. No backend, no
auth, no router.
New harness runs four scripted actions at N in {10, 50, 200, 500} under 6x CDP
CPU throttling: one keystroke into the composer, one scroll gesture, one message
action menu opened and closed, and one message delete. Each is bracketed by
Performance.getMetrics, which separates the two families of cost: work that
grows because layout is uncontained lands in LayoutDuration, work that grows
because a listener or an export is O(messages) lands in TaskDuration alone.
It measures, it does not gate. There are no timing budgets: it prints the table
and exits 0 unless the harness itself broke. Budgets belong in a later change,
set from numbers taken on real hardware. What it does fail on is measuring
nothing, since that is the failure mode that reads as good news: a seed that did
not render, a menu that never opened, a delete that deleted nothing, a scroll
that did not move, or four columns that do not rise with N.
Measured on this tree, N=10 to N=500: menu open+close 1309ms to 33199ms,
delete 438ms to 8491ms, keystroke median 86ms to 268ms.
Every metric recorded reaches the printed table, and the harness contract test
now enforces that mechanically by parsing the recorded keys out of the source
and requiring each one in the table.
CDP CPU throttling and longtask are Chromium-only, so Firefox and WebKit runs of
this file are correctness checks and not performance ones.
* Stop the thread-weight harness charging its own cost to the app
A review of the first commit found four ways the measurement could look clean
while reporting something other than the app, and two of them were forging part
of the curve. All were reproduced before being fixed.
The local runtime does have a remote id. It synthesises `__LOCALID_...`, which is
truthy, so the per-message fork-count GET fires after all: seeding 20 messages
issued 10 requests, against a comment claiming nothing reached the network. They
were being answered by a Playwright route handler, so each one paused the
renderer for a round trip to another process, once per assistant message. The
page now answers them itself, before anything mounts, and the harness fails if a
single request escapes during a measured action.
Closing the menu was timed from after the Escape dispatch. Radix dismisses
synchronously inside it, so the layer teardown, focus restore and re-render --
the O(messages) fan-out this issue is about -- were excluded from the number
meant to capture them.
Every timing carries a ~33ms floor, since a double rAF cannot resolve faster than
two vsync intervals, and CPU throttling does not move it. An action that never
happened therefore reported ~33ms, which reads as a plausible measurement rather
than as a failure. The floor is now measured per N, printed, subtracted before
every growth ratio, and a keystroke at or under it fails the run.
The keystroke check read the DOM value back, which is what the harness itself
wrote. It now compares against the runtime's own composer state, so a keystroke
that reaches the textarea but not React is caught.
Also removed from the timed regions: a per-frame document-wide querySelector in
both poll loops, replaced by a MutationObserver flag and an isConnected check; a
counts() call per frame in the seed gate; an animated scrollIntoView still in
flight when the menu window opened; and a console warning per action-bar render,
by giving the page the router its useNavigate calls expect. Long tasks are now
read after a yield, since the observer delivers on a later task and the tail
entry was being dropped.
Corrected curve, N=10 to N=500, floors removed where they apply: menu open+close
1021ms to 33591ms, delete 297ms to 8563ms, keystroke 48ms to 283ms, scroll worst
frame 5ms to 126ms. Layout stays flat and tiny throughout; the growth is in
style recalc and task time.
* Stop mounting the assistant action bar for every message
At rest the full assistant action bar was mounted under every assistant
message. Each one carries around eight tooltips, and every tooltip holds a
useSyncExternalStore subscription to the shared modal-layer store, which
re-walks its ancestors reading style.pointerEvents whenever Radix puts the
body on the modal layer. A 500-message thread therefore mounted 250 bars and
1503 tooltip triggers, and every menu open fanned out across all of them.
autohide unmounts rather than hides (ActionBarRoot returns null on the hidden
status), so passing it removes the nodes and the subscriptions together. The
user bar has always done this.
Not unconditionally "always", though: this bar carries the only Stop reading
control, which is why it already passes hideWhenRunning={!speaking} and why
DeleteMessageButton guards the same case. With "always", moving the pointer
off a message being read aloud would take that control away. At most one
message speaks at a time, so exempting it costs nothing.
Measured with tests/studio/playwright_thread_weight.py at 6x CPU throttle,
before -> after, at 500 messages:
action bars 250 -> 0
tooltip triggers 1503 -> 3
DOM nodes 56332 -> 41082
delete ms 8595.8 -> 3642.7
scroll worst frame 159.5 -> 87.3
menu open+close ms 33624.5 -> 25279.3
keystroke median ms 316.2 -> 241.0
Note what did not move: menu style recalc, 23725.8 -> 22567.7 ms. A 27% cut
in DOM buys 5% there, so the bar is not what makes that number grow. It is the
document-wide invalidation from Radix writing pointer-events onto the body,
and it is still the dominant cost at large N.
The index.css comment is corrected in passing: it justified forcing
content-visibility: visible on every code block with "thread length is
bounded", which is the assumption this issue disproves. The rule is kept for
the flicker it was really fixing.
* Studio chat: one fork-count subscription per thread, not one per message
The fork badge registered its own CHAT_HISTORY_UPDATED_EVENT listener and issued
its own GET, and it is mounted once per message. A delete on a 200-message thread
therefore fired 200 requests before anything could repaint, and streaming raises
that event once per chunk.
Badges now share one debounced subscription per thread and one request that
returns every fork count of that thread, so the cost is flat in thread length.
* Studio chat: derive research-message ownership once per thread revision
useOwnsResearchMessage exported the whole thread from inside a per-message render
body, so one render pass over N messages exported N times and inspected N*N items.
Streaming re-renders the thread once per chunk, so that pass is hot.
The answer is a property of the thread revision, so derive it once for the message
list every message in the pass already shares. Measured on a synthetic thread: 200
exports and 0.80ms per pass becomes 1 export and 0.014ms; at 1000 messages 16.4ms
becomes 0.02ms.
* Studio chat: stop deep-cloning the thread on every delete and every save
exportedItemToRecord ran JSON.parse(JSON.stringify(...)) over every message's
content and attachments on its way to a PUT that serializes the same records
again, and syncExportedRepositoryToBackend ensured the thread row that
syncStoredChatMessages already ensures, so every save paid for GET /threads/{id}
twice.
The parts are replaced rather than mutated, so a copy of the list is snapshot
enough and the bytes on the wire are identical (asserted in the new test).
Measured on a 200-message thread of ~4KB messages: the record step drops from
1.17ms to 0.015ms, and a delete makes one thread-row read instead of two.
* Studio chat: open the message action menu non-modally
A modal Radix menu writes pointer-events:none on <body>. That is an inherited
property, so every open and close invalidates style for the whole document, and
on a long thread the recalc is the bulk of the cost. Non-modal never writes it.
Also teaches the harness the difference between a cost that was removed and a
page that never mounted one, so the after-tree does not read as broken.
* Pin the message action menu to the non-modal layer
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Drop the #8980 content this branch no longer needs
* Print the hovered trigger count and keep the modal layer load-bearing in the verdict
The contract test caught both: a metric recorded but not printed, and the verdict
no longer reading body_pointer_events_while_open after the non-modal fix removed the
old check. The layer is now compared ACROSS N instead, since either mode is
legitimate but mixing them means the columns measure different mechanisms.
* Stop the rapid-submit settle wait measuring the action bar instead of the reply
This branch autohides the assistant action bar, and that turned the last
wait of the rapid-submit step into a 7-of-7 failure on Windows CI. The wait
is not what the step proves, and the clause that broke was not measuring
what it claimed.
innerText of a [data-role=assistant] root spans the whole subtree, and the
action bar sits inside it, so 'every reply has non-empty innerText' was
satisfied by button labels regardless of what the model returned.
Instrumented at that point on the CI runners, two runs on this branch's
merge base read:
content=[0, 0] innerText=[73, 73] clause held, BOTH replies empty
content=[0, 19] innerText=[73, 89] clause held, first reply empty
gemma-3-270m-it answers 'Reply with exactly: rapid-first' with an empty
completion in 3 of 8 sampled runs, on the merge base as much as here, and
the clause held every time. So the empty reply is the model, is pre-existing,
and was simply masked. With the bar autohidden the subtree is content only,
and the same empty completion now fails.
Dropped rather than repointed at the content element: an empty completion is
the model's behaviour, so a content assertion would be flakier than what it
replaces. What is left is exactly the settle this wait is for, two bubbles,
nothing streaming, nothing queued. The behaviour the step exists to prove,
that a 100 ms follow-up queues behind a held first turn, is state.queueSeen
above and is untouched.
Test-only. No Studio code changes, so the menu open+close and nodes-at-rest
wins are unaffected. Verified on Windows CI at this branch's head: 4 of 4
Chat UI Tests jobs green with this change, against 7 of 7 red without it.
* Validate the delete measurement at every size, not just the last
* Keep the thread's fork counts across the autohidden badges
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Exempt the thread-weight harness from the CI-coverage check
* Keep the newest reply's action bar in the tab order
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Load the crypto polyfill on the thread-weight smoke page
The page this branch adds was the only one in studio/frontend without
<script src="/crypto-boot.js"></script> in its head, so
crypto-uuid-boot.test.ts fails with "smoke-thread-weight.html must load
/crypto-boot.js".
The rule is not cosmetic: the polyfill has to run before the module entry
or crypto.randomUUID is missing on the older WebViews Desktop embeds, and
a smoke page without it measures a page that differs from production in
the one respect the harness is meant to hold constant.
Reproduced on this branch and verified: the named assertion fails before
the change and the file's four tests pass after it.
* Debounce the fork count refresh instead of throttling it
onHistoryUpdated returned while a timer existed, which is a leading edge
throttle rather than a debounce. Streaming raises the history event once per
chunk, so the timer expired mid stream and the next chunk armed another one,
costing a whole thread fork count fetch every 300ms for as long as the reply
ran. Fork counts cannot change during generation, so all of those were waste.
Clear and reschedule on every event, as the sidebar refresh already does.
The existing burst test fired all 20 events inside one window, where a
throttle and a debounce behave identically, which is why this survived. The
new case spreads the events across the window like a real stream: 20 chunks
cost 10 refetches before this change and none after, with one refresh in the
quiet window that follows.
* Reveal the action bar on focus, not only on hover
Unmounting the bar on every message but the newest took its controls out of
the tab order, and there was no non-pointer way to bring them back, so Copy,
Edit, Refresh, Delete, Read aloud and More were unreachable by keyboard or
screen reader on every older reply. Deferred rendering is fine while the user
can still ask for what is deferred, and tabbing is asking.
Measured on the thread weight smoke page at 20 messages, older reply: the
accessibility tree exposed no bar controls before this change and still none
after focus entered the message; it now gains Copy, Refresh, Delete, Edit
response, Read aloud and More. Three tabs from an older reply used to walk
its two code fence buttons and step to the next message without ever
entering a bar; the same walk now lands on the bar's Copy.
The library has no focus path, so this drives its own isHovering flag from
focus within the message root. Two writers share that flag, so a pointer
leaving while focus is inside re-asserts it from a listener registered in an
effect, which runs after the primitive's own mouseleave in the same dispatch
and so never renders the intermediate false that would unmount the element
holding focus. The clear is a one frame watchdog reading activeElement
rather than a relatedTarget test, because relatedTarget is null both for
browser chrome and for a portal, and no focusout fires at all when the
focused element is removed, which is how the menu closes.
At rest this stays at one mounted bar of ten, and at one again after a focus
round trip, so the weight this branch is here to remove is unchanged: 1950
DOM nodes and 9 tooltip triggers, the same as before.
* Give a plain prose reply a way into the tab order
The focus reveal only fires once focus is inside the message, and a reply
whose body is plain prose contains nothing focusable after autohide unmounts
its action bar. The earlier measurement of two focusable controls per message
was an artefact of a fixture where every reply carried a code fence, and
Streamdown ships one Copy button per fence. Seeded with a prose only reply it
is zero, and Tab walks straight past the message into the next reply, so
Copy, Edit, Refresh, Delete, Read aloud and More stay unreachable.
tabIndex on the message root rather than a visually hidden button: it adds no
DOM node, which matters for a branch that exists to cut per message weight,
and it draws nothing at rest. The app's own focus-visible rule gives it the
same 1px keyboard indicator every other focusable container already has, and
focus-visible means a mouse click still draws nothing.
Measured at rest, unchanged: one action bar of ten, 1950 DOM nodes, 9 tooltip
triggers. Outline is none with neither focus nor hover, and none after a
click. The cost is one extra tab stop per assistant message, which is the
price of the controls being reachable at all.
The fixture gains an opt in plain prose variant so the existing weight
measurements keep the exact thread they had.
* Bound how long a fork change can wait behind an unrelated stream
CHAT_HISTORY_UPDATED_EVENT fires once per streaming chunk, and the fork-count refresh was a pure
trailing-edge debounce, so a reply running in a background thread reset the timer on every chunk.
Deleting a fork from the sidebar while looking at its parent changes the displayed count, and the
refresh was postponed until the unrelated stream went quiet, which on a long or queued run is
minutes. That is starvation, not slowness.
The event is a bare Event with no detail and six other consumers, so telling fork changes apart
from chunks means changing a contract well outside this store. FORK_COUNT_REFRESH_MAX_WAIT_MS
bounds the wait inside it instead: a second timer, started by the first event of a burst and
deliberately not restarted by the ones after it, races the debounce, and whichever fires first
cancels the other. A second timer rather than a Date.now() deadline so it runs off the same clock
as the debounce and is testable without a fake Date.
2000ms because the bound costs one whole-thread fetch per window while a stream runs. At the
300ms debounce that is the per-chunk traffic this store exists to remove; at 2000 it is under a
sixth of it, and only while something is streaming.
The existing continuous-stream test asserted ZERO mid-stream fetches, which is the behaviour the
review flagged, so it now measures the price of the bound instead of claiming there is none: it
pins the count against both what the ceiling allows and what a leading-edge throttle would have
cost, so a regression in either direction is a failure.
Four assertions were made to fail on their own broken tree before being kept: the ceiling
removed, the ceiling restarted per chunk so it never expires, the losing timer left uncancelled
when the other fires, and the ceiling left running past unsubscribe. That last one was vacuous at
first, since the entries map is empty after unsubscribe and a leaked timer refreshes nothing; it
now subscribes a second thread inside the ceiling window, which is both observable and the case
that actually costs a user a request.
* Scope the popup lookup to the action bar
The watchdog treated any expanded descendant as this message's open menu. Reasoning cards and
tool-fallback cards are Radix CollapsibleTriggers and render aria-expanded=true for as long as
the reader leaves them open, which is the resting state of a message whose tool output has been
expanded. decide() therefore found a popup every frame, rescheduled itself every frame, held
focusWithinRef and the synthetic hover set, and left the bar mounted indefinitely, at the cost of
a DOM query per frame per such message. Scoped to .aui-assistant-action-bar-root, which is where
the trigger the hook has to hand focus back to actually lives.
Proving this took three attempts and the first two were wrong, which is worth recording because
the failure was in the test rather than in the fix.
isHovering has two writers. This hook is one; assistant-ui's own MessagePrimitive.Root mouseleave
handler is the other, and it writes false directly. So a phase that reveals the bar by HOVERING
and then moves the pointer away sees the bar unmount on both trees, because the library unmounted
it. On the broken tree the watchdog genuinely spins forever, and the bar still goes away. An
assertion on the mounted bar count cannot attribute that outcome to this branch, and C2 passed on
the fixed and broken trees alike.
The phase now keeps the pointer off the message entirely and reveals the bar by focus, which the
tabIndex on the message root makes possible. With no mouseleave to fire, focus is the only writer
and the bar's fate is decided by the watchdog alone. C2 is green on the fixed tree and red under
--break widepopup, 1 bar still mounted and held indefinitely. Two guards sit in front of it: one
asserts the pointer really is off the message, the other that the bar really was mounted and
focused, so the phase fails loudly rather than passing vacuously if either precondition breaks.
Also fixes a pre-existing crash the sweep was hiding. Under --break eagerclear the bar is gone by
the time A8 runs, and a bare more.focus() on an undefined element threw a Playwright TypeError
that killed the process before phases P, B and C ever ran, so the break reported fewer reds than
it earns and ended in a traceback rather than a red result. It is more?.focus() now and the
trigger's absence is folded into A8's condition, so eagerclear completes and A8 goes red on its
own merits.
Full sweep: head 14/23, notabindex 19/23, restring 21/23, focusring 22/23, leakflag 21/23,
noreassert 22/23, eagerclear 21/23, widepopup 22/23 with C2 the only red. thread.tsx checksummed
before and after all eight runs, identical every time.
* Reveal the action bar from the backward traversal too
The tabIndex on the message root only worked going FORWARD. A container is reached before its own
descendants, so Shift+Tab arriving from the message below landed on the last tabbable thing in the
message, and with the bar unmounted that is the root, which sits BEFORE the bar in DOM order.
Focusing it mounted the controls and the next Shift+Tab then stepped straight past them to the
previous message. Copy, Edit, Delete and More were reachable going forward and unreachable going
backward, which is worse than being unreachable outright, because the forward pass makes it look
solved.
A sentinel span after the bar is what makes the backward pass land inside the message: focus stops
there, the bar mounts, and the next Shift+Tab goes into the last control rather than out. It is
deliberately NOT a focus redirect to that control, which would trap the forward pass in a loop
between the last button and the sentinel. It carries no onFocus of its own because React's onFocus
is focusin and already bubbles to the root, and no role, because it performs no action; the
aria-label is what stops it being an unannounced stop.
It DOES cost one DOM node per assistant message, and this branch is about per-message weight, so
that is asserted rather than absorbed: the at-rest guard now requires exactly 1950 baseline nodes
plus one sentinel per reply and nothing else, measured 1960 for 10 replies, with the sentinel count
checked separately so the extra nodes are attributed rather than tolerated.
Three assertions, each proven red. nosentinel takes D2 and D3 red while D1 stays green, which is
precisely the reported asymmetry: focus still enters the reply, it just skips the bar. D1 says
something weaker, so it needs noentry, which removes the sentinel and the tabIndex together and
leaves nothing in the message reachable at all; D1, D2 and D3 all go red there.
Full sweep, 26 assertions: fixed 26/26, nosentinel 23/26, noentry 18/26, notabindex 24/26, head
15/26, widepopup 25/26 with C2 alone, eagerclear 21/26, noreassert 25/26, leakflag 24/26, restring
24/26, focusring 25/26. thread.tsx checksummed before and after every run, identical throughout.
* Draw a focus indicator on the backward reveal sentinel
The shared soft-outline rule is :where(div, main, section, aside, ul, ol):focus-visible, which
never matched a span, so the sentinel was a real tab stop that drew nothing: Shift+Tab into a
message made focus visibly disappear for one stop before the next press reached the action bar.
That is a focus-visible failure, not a cosmetic one.
The element stays 0x0 and the ring is drawn by outline-offset. Outlines take no part in layout,
so the indicator appears without shifting the message, which giving the span dimensions on focus
would have done. Still nothing at rest, and :focus-visible means a mouse click draws nothing
either.
Measured: with keyboard focus the ring spans 14px against the UA default's 2px on a zero-sized
span, which is the difference between an indicator and no indicator. D4 asserts the SPAN of the
drawn ring rather than merely that an outline style exists, because the broken tree still reports
outline-style auto and would satisfy a presence check while showing nothing.
Proven red by --break blindsentinel, which removes the indicator rule and leaves the tab stop
itself intact, so only D4 fails.
* Stop tracking the generated Studio test database
.studio-test-root/studio.db is written at test time by
tests/studio/install/test_selection_logic.py, which points storage_roots.studio_root
at that path. It is a mutable SQLite runtime database, not a fixture: nothing
reads it, any test run or Studio start rewrites it and dirties the checkout, a
later accidental commit could capture real local chat or settings data, and it
puts 221 KB into every clone while exercising nothing.
It was not in the tree deliberately. It arrived in the merge commit here because
that commit was staged with `git add -A` after running the suite, and no
gitignore rule covered the path. Main does not track it.
Untracked and ignored, and the file is left on disk since creating it is normal.
---------
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <unslothshared@gmail.com>
Co-authored-by: oobabooga <112222186+oobabooga@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@users.noreply.github.com>

Windows-only path failures in the frontend test suite. All of them are invisible here, because Frontend CI is ubuntu-only.
Measurement
Frontend CI runs on
ubuntu-latestonly, so nothing in the org has ever executed this suite on Windows. A staging replica ran it onwindows-latestagainst currentmain:mainUbuntu and macOS are 0 failed on both. The 13 are three files and two shapes.
The failures
tests/copy-to-clipboard.test.ts, 9 tests. It builds its dynamic import specifier withfileURLToPath, which yields a native path. On Linux and macOS that is/..., which Node accepts. On Windows it isD:\..., which it does not:tests/microphone-permission-reset.test.ts, 3 tests. The same mistake, in the three tests that load the module under test. Added by #9006, which is itself a Windows fix: WebView2 stores "Don't allow" and has no site-settings UI, soAllow microphoneclears the saved answer before asking again. The test for it could not run on Windows.tests/desktop-stop-intent.test.ts, 1 test. It walkssrc/asfile:URLs but collectsurl.pathname, which is/D:/a/...on Windows.readFilereads that as drive-relative and opensD:\D:\a\...:The walk then finds no owner for the storage key at all, and the test asserting exactly one owner fails.
It is the same mistake in opposite directions: two convert a URL to a path where a URL was needed, one treats a URL as a path.
Both are test defects, not product defects. Nothing here asserts a browser or platform policy, and no source behaviour changes: the three modules under test are imported and read the same way on every platform once the specifier is legal.
Change
copy-to-clipboard.test.tsandmicrophone-permission-reset.test.ts: keep thefile://URL instead of converting it to a path.new URL(...).hrefis a legal specifier on every platform, and the?bust=Ncache-busting suffix these tests rely on only means anything on a URL in the first place.desktop-stop-intent.test.ts: keep the walk's entries asURLobjects.readFileaccepts afile:URL directly on every platform.pathnameis still what the assertion slices on, since it is/separated everywhere, and it is now read only at that point.tests/windows-path-portability.test.ts, new: assert both shapes are absent from the whole suite, from any platform.Why the new test
Fixing three files does not stop a fourth. Both shapes are decidable from the source, so the suite now scans itself for them and fails on Linux, where CI already runs, rather than on a Windows runner nobody has.
It is a rule about data flow, not a ban on the words.
fileURLToPathreachingexistsSyncis correct and stays allowed; onlyfileURLToPathreachingimport()is not.pathnamereaching a string slice is correct;pathnamereachingreadFileis not, including via an array populated in one function and read in another, which is exactly how thedesktop-stop-intentfailure was written.The rules were checked against a deliberately broken tree, one file at a time:
mainversioncopy-to-clipboard.test.tsmicrophone-permission-reset.test.tsdesktop-stop-intent.test.tsThe rule is a data-flow one, so it follows the value: through initialization, later assignment, destructuring, into and back out of a collection, and across a local helper's parameters. It stops at
pathToFileURL, which is the exact inverse offileURLToPath, so the standard round trip for a module location that starts life as a path is accepted rather than rejected. That barrier is deliberately not applied to the pathname rule, where a Windows pathname is/D:/...and converting it yields a URL for a file that does not exist.The file also carries its own anti-vacuity checks: it asserts the walk saw more than 200 files, more than 50 dynamic imports and more than 50 fs calls, and currently measures 390, 150 and 341. It runs both detectors over fourteen known-bad snippets and one known-good one, and the sanitizer has an inverse test of its own asserting the unrepaired forms still fire. Stubbing out either detector fails two tests.
Testing
Locally:
npm test3599 passed 0 failed,npm run typecheckclean.On real runners, since Frontend CI cannot see any of this: the staging table above. Re-run on
windows-latestafter each review round; the head of this branch is 3598 passed, 0 failed there.Ubuntu and macOS unchanged throughout.