openpencil/tests/e2e/settings/diagnostics.spec.ts

142 lines
6 KiB
TypeScript
Raw Normal View History

feat(settings): configure tool access, MCP failures, and step limits * feat(settings): configure tool access and agent step limits Built-in AI exposed only a hardcoded subset of the tool registry, and the maximum agent steps was a constant, so users could neither enable extended tools such as create_component nor adjust long-running tasks. Built-in AI and the local MCP server now keep independent, locally saved tool permissions over one shared catalog, with searchable read-only and side-effect groups and per-target defaults. Chat settings gain a validated maximum-steps field whose captured value drives the stop condition, remaining-step warnings, and limit detection for each message. Tool access, the local server, browser access, and MCP connections are grouped under a single Automation settings page. Closes #573 Closes #584 * refactor(settings): split automation into MCP and Tool access pages The Automation page mixed a permission matrix with server endpoints behind a Tools/Connections switch, and the view switch was indistinguishable from the provider switch. The nested scroll region showed three of 110 tools. Rename the MCP-facing page to MCP and give tool permissions their own Tool access page. The page owns a fixed toolbar for the target, count, defaults, and search, so the list uses the full dialog body and no row is clipped. * fix(automation): explain MCP startup failures with localized guidance Every startup failure collapsed into "MCP server did not become healthy": the spawn layer recorded the real error but the runtime discarded it, and health probes could not distinguish a rejected token from a missing server. The message also surfaced raw English text as the alert heading. Classify failures by reason (not installed, denied command, early exit, startup timeout, rejected token, unexpected response, unreachable) and render translated heading and guidance from the catalog, keeping captured stderr or HTTP status as labeled diagnostic detail. * refactor(ui): share one collapsible disclosure primitive Six features each wired Reka's collapsible with their own motion classes and one settings-only theme token, so the same interaction drifted in spacing, icon size, and reduced-motion handling. Add AppCollapsible with a family theme and move the settings disclosure and the model editor's advanced settings onto it. Chat and frame-preset call sites keep their distinct visuals for a follow-up. * fix(automation): explain MCP failures with localized details The failure alert carried raw English error text as its heading, and the diagnostic payload sat in a sibling block outside the alert with no relationship to it. Classify failures by reason, render translated heading and guidance from the catalog, and keep the payload in a collapsible inside the alert, which unmounts while collapsed so the live region announces only the summary. Add a copy action for issue reports. Find the executable where a graphical launch can: extend PATH with the common global bin directories before the lookup and report the searched directories as diagnostic detail. * fix(automation): keep MCP failure details out of reasons already explained An unreachable address and a rejected token already name their cause in the translated guidance, so repeating it under Details added noise. Details now carry only output the summary cannot: stderr, HTTP status, or an unknown error message. * test(settings): browse every MCP failure reason in Storybook The failure copy lived inside the settings panel, so reviewing the eight reasons meant reproducing each failure and the mapping could only be checked through the panel's dependencies. Extract MCPFailureAlert, which owns the reason-to-copy mapping, detail visibility, copy action, and restart action, and add a story covering every reason plus the collapsed-details behavior. * fix(ui): order alert details above the recovery actions The alert rendered its action buttons before the details slot, so the collapsible explanation of a failure appeared under the controls it explains. Details now render directly after the description. * fix(automation): correct MCP failure classification and detail Review follow-ups on the failure diagnostics. Only 401 and 403 mean the server refused our token; any other status now reports an unexpected response instead of telling the user to replace a token that was never the problem. The install hint rendered the whole diagnostic detail as its package argument, so searched directories appeared inside the install command. The install target is now a domain constant and the searched directories stay as detail, which not-installed failures surface again since they are the actionable desktop diagnostic. Exited failures also record the process exit code and signal so copied diagnostics stay conclusive when stderr is empty. The bundled PATH test now covers the append branch instead of only the unchanged path. * feat(settings): accept custom values for presets and retention Retention was a closed set of three counts while the AI step limit was a free number, so two bounded numeric preferences looked and behaved differently for no product reason. Add a shared preset-or-custom field: presets stay one click, the escape hatch reveals a validated numeric field, and the model carries only the resolved number. Diagnostics retention becomes a bounded number (50 to 20,000) with the presets as shortcuts, and the hardcoded revalidation in the panel is replaced by one domain resolver. * fix(settings): label the preset and custom fields Replacing the labeled provider field with the shared control left the AI step limit as a bare select with a detached hint paragraph, outside the settings group, so nothing on screen said what the number meant. The accessibility name came from aria-label, which is why behavior tests passed while the panel was unreadable. Move both controls into labeled settings rows with their descriptions, and give the revealed field its own accessible name so the two controls in one row differ. The specs now assert the control lives inside the row that names it, which is the check that would have caught this. * fix(mcp): allow the desktop app origin by default A server started manually bound the port and answered curl but the app webview could not use it: no CORS origin was configured, so the browser blocked every fetch and the app reported the server as unhealthy. The workaround required an undocumented environment variable. Allow the desktop app origins by default, accept a comma-separated override, and document the default in the CLI help and the security notes. Authenticated requests still need the bearer token, and browsers set Origin themselves, so only the app webview can present these origins. * fix(settings): address review findings on the new controls Copy details awaited nothing and confirmed the copy before the write finished. VueUse never rejects and falls back to a legacy write, so the await is what makes the confirmation honest rather than an error branch. The preset field only left custom mode when a preset arrived; a non-preset value assigned from the owner left the select showing a value absent from its options with the field still hidden. The watcher now follows the model in both directions. The story play functions queried the revealed field by the row label, which Testing Library matches as a whole string, so those interactions could not find it. The Storybook smoke assertion also assumed a button or tab, which skipped every story built from other primitives.
2026-09-17 20:55:58 +00:00
import type { Page } from '@playwright/test'
feat(app): record runtime errors in diagnostics and make events readable (#874) * feat(app): record runtime errors in diagnostics with their stack Uncaught errors and unhandled rejections only showed a toast, Vue component errors after boot only reached the console, and a failed chat kept just its error name, so a failure like WebKit's 'Attempting to define property on object that is not extensible.' left nothing to diagnose. They now record a runtime.error, and chat.failed its code, message, and stack. Messages and stacks are scrubbed of URL queries, key- and token-like strings, and home folder names and bounded; AI SDK and provider errors keep no message, since it can quote prompts or responses. Copied diagnostics start with the app version, shell, browser, and language. * feat(app): label, filter, and page diagnostics events Every row in Settings → Diagnostics read 'Technical event': the summary looked labels up under diagnostics-prefixed keys the messages do not have, and only a few event kinds had labels at all. Each event now has a specific label and a short detail, such as 'Tool: render · 162 ms', 'Model step · <model>', or an error's message, expands to its recorded fields and stack, and the list filters by level and category and grows a page at a time. The copy action passes the environment header, which moves out of the recorder so tooling that compiles it needs no build-time globals. * test(app): stream a reasoning reply in WebKit without page errors Errors such as WebKit's "not extensible" TypeError appear only in that engine, so run a streamed reasoning reply there and fail on any page or console error. * fix(app): scrub queries on bare paths in diagnostic errors Only URLs had their query removed, so a message like 'Failed to load /Designs/app.fig?token=…' kept the token. * fix(app): count diagnostics recorded before Settings opens The event count and size updated only on new events, so the panel showed 0 events beside a full list. * feat(app): record failed AI tool calls as problems, with the stack of engine errors A tool catches what it throws and returns only the message to the model, so diagnostics saw a failed tool as an info event without details. The adapter now passes the thrown value to the tool log. A failed call is a warning; a TypeError, ReferenceError, or RangeError, which comes from a bug in OpenPencil rather than a wrong call, is an error with its message and stack. Other tool errors keep only their name, since their messages quote layer names and arguments. * fix(core): log tool calls that return an error as failed Most tools report a failure by returning { error } rather than throwing, such as describe with an unknown node, so the tool log and diagnostics counted them as successful calls while the chat showed them failed. * feat(app): scrub cloud keys, JWTs, private keys, URL credentials, and emails from diagnostics The scrubber caught keys by shape only, so 20-character AWS access key IDs, user:pass@ in URLs, and emails reached the log, and a JWT's payload survived because its dots split it into short runs. It moves into its own module with rules grouped by what they protect. The added credential formats follow gitleaks; keys the shape rules already catch, such as GitHub, OpenAI, Anthropic, and Stripe ones, get no separate rule. No maintained browser library fits: secretlint needs Node built-ins and adds at least 23 KB gzipped, and the PII redactors miss tokens. The scrubber is 0.8 KB gzipped. * refactor(app): name how a tool call is recorded and import diagnostics from its index * fix(app): record demo document loads in diagnostics The preparation event's schema listed its kinds, phases, cancel reasons, and failure codes by hand and lacked demo-load, so every demo load failed validation and was dropped. The schema now validates against the same lists the preparation types derive from. * feat(app): label document preparation events in Settings diagnostics Preparation events showed their raw name, editor.preparation.finished, because the summary had no label for them. They now read as their kind, such as Switch page, with the outcome and duration below. Event names are a typed union and the labels a map keyed by it, so recording a new event without a label fails type-checking; names stored by older versions still fall back to the raw name. * fix(app): keep source paths and scrub provider stacks, auth headers, and spaced home folders Review follow-up. A provider error's message was dropped but repeated on its stack's first line, so it is now removed there too. The long-run rule redacted source paths of 40 or more characters, losing the failing file; a run with slashes now loses only its key-like segments. The bare-path query rule cut optional chaining such as a.b?.c and now needs name= after the question mark. Authorization header values in any scheme, credential assignments such as api_key= or password:, and home folder names with spaces are now scrubbed. * fix(app): suppress repeats of alternating runtime errors Repeat suppression compared each error only with the previous one, so a loop alternating between two errors recorded every occurrence. Recent errors are now kept in a small bounded map. * test(app): validate copied diagnostics and wait for the copy to finish Master now rejects JSON.parse with a type assertion, so the copied report is read through a Valibot schema. The uncaught-error test read the clipboard before its copy finished and could see the previous test's report; it now waits for the confirmation, as the export test does.
2026-10-05 07:59:44 +00:00
import * as v from 'valibot'
fix: protect unsaved documents and defer credential access (#713) * fix(app): protect unsaved documents when closing Mark tabs with unsaved content updates and ask whether to save before closing them. The prompt now covers tab closes, the desktop window close button, and the application Quit action, which previously discarded work when autosave had no writable target. Track a content revision separately from scene and recovery versions so a save only clears the indicator when it wrote the revision it captured. Cancelled pickers, failed writes, and edits made during a save keep the document open. Desktop uses the platform alert; the browser keeps the styled dialog. The desktop menu replaces the predefined Quit item so the accelerator and Dock-independent quit path request confirmation instead of exiting. * fix(ai): resolve credentials only when used Opening a document, creating a chat, or browsing chat history connected the provider and read saved secrets, which triggered system credential prompts without user intent. Startup now reads credential status only, migration runs inside the first explicit resolution, and the chat panel initializes local history without creating a transport. Stock-photo keys resolve per search instead of at settings refresh, and credentials still marked legacy count as configured so upgrading does not appear to lose them. * refactor(ai): export diagnostics from Settings only Chat kept its own debug log, copied mixed app-wide usage into a conversation export, and reported a missing cache rate as zero. Remove that surface and record AI requests, model steps, and tool activity as correlated diagnostic events instead. Settings remains the single export location, usage summaries can now distinguish unreported telemetry from zero, and transcript or tool payloads are no longer part of the export. * fix(ai): clear legacy credentials for real Clearing a Pexels, Unsplash, or provider key only removed the current store entry. A value that still lived in legacy storage kept the key configured, so a later search migrated and used the credential the user had just removed. Migrate before mutating so clearing also removes the legacy value, and share one in-flight migration so the media and provider paths cannot migrate the same plaintext twice. * fix(ai): scope credential migration per source Sharing one migration promise process-wide let a second storage return the first migration's result, leaving its own legacy keys unmigrated while reporting success. Track in-flight migrations per storage and serialize them, because every migration writes to the same store and concurrent runs could overwrite each other. * fix(app): destroy the window after a confirmed close Tauri's onCloseRequested helper destroys the window itself when a handler returns without preventing the event. Approving a close therefore invoked plugin:window|destroy, which the capability set did not grant, so the window stayed open with a permission error after saving. Always intercept the request and destroy the window explicitly once the choice is confirmed, and grant core:window:allow-destroy in place of the now-unused close permission. * fix(app): show a filled dot for unsaved tabs The unsaved indicator used a stroked Lucide circle whose fill attribute kept it an empty outline, reading as a disabled control. Draw the indicator as a filled accent dot matching the status dots used elsewhere in the app. * refactor(app): focus the unsaved prompt with VueUse Replace the manual watcher, nextTick, and component $el focus with useFocus, which focuses the Save button when the dialog mounts. Assert the focus in the close-protection test so the Return-saves behavior stays covered. * refactor(app): route Quit through the shared menu channel The Quit item emitted a bespoke app:request-exit event and the close module listened for it, while every other native item travels as a menu-event id dispatched by the shell and editor menu composables. Emit menu-event "quit" for both the Quit item and the platform exit request, handle it in useShellMenu beside check-updates, and share one confirmAppExit so window closes and app exits agree on a single approval. * refactor(app): generate the macOS app menu entries The application menu hardcoded its labels and the Quit accelerator in Rust while every other menu entry is generated from APP_MENU_SCHEMA. Move the custom app entries (About, Check for Updates, Quit) into APP_MENU_APP_ITEMS and emit desktop/generated/app-menu.json, keyed by id so the native builder cannot silently drop a label. Placement stays in Rust because the OS-predefined items sit between them, and the menu title now comes from the packaged product name. * build(tauri-menu): check generated menus against the schema The generated menu files are committed but nothing verified them, so a schema edit could silently leave desktop/generated stale until the next release build regenerated it. Split the renderers from the write step, register the tool as a workspace so its dependencies resolve, and compare the committed files with the schema in a test that runs with the other tool checks. * fix(app): serialize exit confirmations The window close handler and the Quit item both call confirmAppExit, and the per-handler closing flag does not cover the two paths. Both could run close preparation, so an unsaved document could be prompted twice. Share one in-flight confirmation and clear it when it settles, so a cancelled or failed attempt still prompts again on the next request.
2026-09-17 12:25:15 +00:00
feat(settings): configure tool access, MCP failures, and step limits * feat(settings): configure tool access and agent step limits Built-in AI exposed only a hardcoded subset of the tool registry, and the maximum agent steps was a constant, so users could neither enable extended tools such as create_component nor adjust long-running tasks. Built-in AI and the local MCP server now keep independent, locally saved tool permissions over one shared catalog, with searchable read-only and side-effect groups and per-target defaults. Chat settings gain a validated maximum-steps field whose captured value drives the stop condition, remaining-step warnings, and limit detection for each message. Tool access, the local server, browser access, and MCP connections are grouped under a single Automation settings page. Closes #573 Closes #584 * refactor(settings): split automation into MCP and Tool access pages The Automation page mixed a permission matrix with server endpoints behind a Tools/Connections switch, and the view switch was indistinguishable from the provider switch. The nested scroll region showed three of 110 tools. Rename the MCP-facing page to MCP and give tool permissions their own Tool access page. The page owns a fixed toolbar for the target, count, defaults, and search, so the list uses the full dialog body and no row is clipped. * fix(automation): explain MCP startup failures with localized guidance Every startup failure collapsed into "MCP server did not become healthy": the spawn layer recorded the real error but the runtime discarded it, and health probes could not distinguish a rejected token from a missing server. The message also surfaced raw English text as the alert heading. Classify failures by reason (not installed, denied command, early exit, startup timeout, rejected token, unexpected response, unreachable) and render translated heading and guidance from the catalog, keeping captured stderr or HTTP status as labeled diagnostic detail. * refactor(ui): share one collapsible disclosure primitive Six features each wired Reka's collapsible with their own motion classes and one settings-only theme token, so the same interaction drifted in spacing, icon size, and reduced-motion handling. Add AppCollapsible with a family theme and move the settings disclosure and the model editor's advanced settings onto it. Chat and frame-preset call sites keep their distinct visuals for a follow-up. * fix(automation): explain MCP failures with localized details The failure alert carried raw English error text as its heading, and the diagnostic payload sat in a sibling block outside the alert with no relationship to it. Classify failures by reason, render translated heading and guidance from the catalog, and keep the payload in a collapsible inside the alert, which unmounts while collapsed so the live region announces only the summary. Add a copy action for issue reports. Find the executable where a graphical launch can: extend PATH with the common global bin directories before the lookup and report the searched directories as diagnostic detail. * fix(automation): keep MCP failure details out of reasons already explained An unreachable address and a rejected token already name their cause in the translated guidance, so repeating it under Details added noise. Details now carry only output the summary cannot: stderr, HTTP status, or an unknown error message. * test(settings): browse every MCP failure reason in Storybook The failure copy lived inside the settings panel, so reviewing the eight reasons meant reproducing each failure and the mapping could only be checked through the panel's dependencies. Extract MCPFailureAlert, which owns the reason-to-copy mapping, detail visibility, copy action, and restart action, and add a story covering every reason plus the collapsed-details behavior. * fix(ui): order alert details above the recovery actions The alert rendered its action buttons before the details slot, so the collapsible explanation of a failure appeared under the controls it explains. Details now render directly after the description. * fix(automation): correct MCP failure classification and detail Review follow-ups on the failure diagnostics. Only 401 and 403 mean the server refused our token; any other status now reports an unexpected response instead of telling the user to replace a token that was never the problem. The install hint rendered the whole diagnostic detail as its package argument, so searched directories appeared inside the install command. The install target is now a domain constant and the searched directories stay as detail, which not-installed failures surface again since they are the actionable desktop diagnostic. Exited failures also record the process exit code and signal so copied diagnostics stay conclusive when stderr is empty. The bundled PATH test now covers the append branch instead of only the unchanged path. * feat(settings): accept custom values for presets and retention Retention was a closed set of three counts while the AI step limit was a free number, so two bounded numeric preferences looked and behaved differently for no product reason. Add a shared preset-or-custom field: presets stay one click, the escape hatch reveals a validated numeric field, and the model carries only the resolved number. Diagnostics retention becomes a bounded number (50 to 20,000) with the presets as shortcuts, and the hardcoded revalidation in the panel is replaced by one domain resolver. * fix(settings): label the preset and custom fields Replacing the labeled provider field with the shared control left the AI step limit as a bare select with a detached hint paragraph, outside the settings group, so nothing on screen said what the number meant. The accessibility name came from aria-label, which is why behavior tests passed while the panel was unreadable. Move both controls into labeled settings rows with their descriptions, and give the revealed field its own accessible name so the two controls in one row differ. The specs now assert the control lives inside the row that names it, which is the check that would have caught this. * fix(mcp): allow the desktop app origin by default A server started manually bound the port and answered curl but the app webview could not use it: no CORS origin was configured, so the browser blocked every fetch and the app reported the server as unhealthy. The workaround required an undocumented environment variable. Allow the desktop app origins by default, accept a comma-separated override, and document the default in the CLI help and the security notes. Authenticated requests still need the bearer token, and browsers set Origin themselves, so only the app webview can present these origins. * fix(settings): address review findings on the new controls Copy details awaited nothing and confirmed the copy before the write finished. VueUse never rejects and falls back to a legacy write, so the await is what makes the confirmation honest rather than an error branch. The preset field only left custom mode when a preset arrived; a non-preset value assigned from the owner left the select showing a value absent from its options with the field still hidden. The watcher now follows the model in both directions. The story play functions queried the revealed field by the row label, which Testing Library matches as a whole string, so those interactions could not find it. The Storybook smoke assertion also assumed a button or tab, which skipped every story built from other primitives.
2026-09-17 20:55:58 +00:00
import { CanvasHelper } from '#tests/helpers/canvas'
import { expect, test } from '#tests/helpers/chat/fixture'
fix: protect unsaved documents and defer credential access (#713) * fix(app): protect unsaved documents when closing Mark tabs with unsaved content updates and ask whether to save before closing them. The prompt now covers tab closes, the desktop window close button, and the application Quit action, which previously discarded work when autosave had no writable target. Track a content revision separately from scene and recovery versions so a save only clears the indicator when it wrote the revision it captured. Cancelled pickers, failed writes, and edits made during a save keep the document open. Desktop uses the platform alert; the browser keeps the styled dialog. The desktop menu replaces the predefined Quit item so the accelerator and Dock-independent quit path request confirmation instead of exiting. * fix(ai): resolve credentials only when used Opening a document, creating a chat, or browsing chat history connected the provider and read saved secrets, which triggered system credential prompts without user intent. Startup now reads credential status only, migration runs inside the first explicit resolution, and the chat panel initializes local history without creating a transport. Stock-photo keys resolve per search instead of at settings refresh, and credentials still marked legacy count as configured so upgrading does not appear to lose them. * refactor(ai): export diagnostics from Settings only Chat kept its own debug log, copied mixed app-wide usage into a conversation export, and reported a missing cache rate as zero. Remove that surface and record AI requests, model steps, and tool activity as correlated diagnostic events instead. Settings remains the single export location, usage summaries can now distinguish unreported telemetry from zero, and transcript or tool payloads are no longer part of the export. * fix(ai): clear legacy credentials for real Clearing a Pexels, Unsplash, or provider key only removed the current store entry. A value that still lived in legacy storage kept the key configured, so a later search migrated and used the credential the user had just removed. Migrate before mutating so clearing also removes the legacy value, and share one in-flight migration so the media and provider paths cannot migrate the same plaintext twice. * fix(ai): scope credential migration per source Sharing one migration promise process-wide let a second storage return the first migration's result, leaving its own legacy keys unmigrated while reporting success. Track in-flight migrations per storage and serialize them, because every migration writes to the same store and concurrent runs could overwrite each other. * fix(app): destroy the window after a confirmed close Tauri's onCloseRequested helper destroys the window itself when a handler returns without preventing the event. Approving a close therefore invoked plugin:window|destroy, which the capability set did not grant, so the window stayed open with a permission error after saving. Always intercept the request and destroy the window explicitly once the choice is confirmed, and grant core:window:allow-destroy in place of the now-unused close permission. * fix(app): show a filled dot for unsaved tabs The unsaved indicator used a stroked Lucide circle whose fill attribute kept it an empty outline, reading as a disabled control. Draw the indicator as a filled accent dot matching the status dots used elsewhere in the app. * refactor(app): focus the unsaved prompt with VueUse Replace the manual watcher, nextTick, and component $el focus with useFocus, which focuses the Save button when the dialog mounts. Assert the focus in the close-protection test so the Return-saves behavior stays covered. * refactor(app): route Quit through the shared menu channel The Quit item emitted a bespoke app:request-exit event and the close module listened for it, while every other native item travels as a menu-event id dispatched by the shell and editor menu composables. Emit menu-event "quit" for both the Quit item and the platform exit request, handle it in useShellMenu beside check-updates, and share one confirmAppExit so window closes and app exits agree on a single approval. * refactor(app): generate the macOS app menu entries The application menu hardcoded its labels and the Quit accelerator in Rust while every other menu entry is generated from APP_MENU_SCHEMA. Move the custom app entries (About, Check for Updates, Quit) into APP_MENU_APP_ITEMS and emit desktop/generated/app-menu.json, keyed by id so the native builder cannot silently drop a label. Placement stays in Rust because the OS-predefined items sit between them, and the menu title now comes from the packaged product name. * build(tauri-menu): check generated menus against the schema The generated menu files are committed but nothing verified them, so a schema edit could silently leave desktop/generated stale until the next release build regenerated it. Split the renderers from the write step, register the tool as a workspace so its dependencies resolve, and compare the committed files with the schema in a test that runs with the other tool checks. * fix(app): serialize exit confirmations The window close handler and the Quit item both call confirmAppExit, and the per-handler closing flag does not cover the two paths. Both could run close preparation, so an unsaved document could be prompted twice. Share one in-flight confirmation and clear it when it settles, so a cancelled or failed attempt still prompts again on the next request.
2026-09-17 12:25:15 +00:00
feat(app): record runtime errors in diagnostics and make events readable (#874) * feat(app): record runtime errors in diagnostics with their stack Uncaught errors and unhandled rejections only showed a toast, Vue component errors after boot only reached the console, and a failed chat kept just its error name, so a failure like WebKit's 'Attempting to define property on object that is not extensible.' left nothing to diagnose. They now record a runtime.error, and chat.failed its code, message, and stack. Messages and stacks are scrubbed of URL queries, key- and token-like strings, and home folder names and bounded; AI SDK and provider errors keep no message, since it can quote prompts or responses. Copied diagnostics start with the app version, shell, browser, and language. * feat(app): label, filter, and page diagnostics events Every row in Settings → Diagnostics read 'Technical event': the summary looked labels up under diagnostics-prefixed keys the messages do not have, and only a few event kinds had labels at all. Each event now has a specific label and a short detail, such as 'Tool: render · 162 ms', 'Model step · <model>', or an error's message, expands to its recorded fields and stack, and the list filters by level and category and grows a page at a time. The copy action passes the environment header, which moves out of the recorder so tooling that compiles it needs no build-time globals. * test(app): stream a reasoning reply in WebKit without page errors Errors such as WebKit's "not extensible" TypeError appear only in that engine, so run a streamed reasoning reply there and fail on any page or console error. * fix(app): scrub queries on bare paths in diagnostic errors Only URLs had their query removed, so a message like 'Failed to load /Designs/app.fig?token=…' kept the token. * fix(app): count diagnostics recorded before Settings opens The event count and size updated only on new events, so the panel showed 0 events beside a full list. * feat(app): record failed AI tool calls as problems, with the stack of engine errors A tool catches what it throws and returns only the message to the model, so diagnostics saw a failed tool as an info event without details. The adapter now passes the thrown value to the tool log. A failed call is a warning; a TypeError, ReferenceError, or RangeError, which comes from a bug in OpenPencil rather than a wrong call, is an error with its message and stack. Other tool errors keep only their name, since their messages quote layer names and arguments. * fix(core): log tool calls that return an error as failed Most tools report a failure by returning { error } rather than throwing, such as describe with an unknown node, so the tool log and diagnostics counted them as successful calls while the chat showed them failed. * feat(app): scrub cloud keys, JWTs, private keys, URL credentials, and emails from diagnostics The scrubber caught keys by shape only, so 20-character AWS access key IDs, user:pass@ in URLs, and emails reached the log, and a JWT's payload survived because its dots split it into short runs. It moves into its own module with rules grouped by what they protect. The added credential formats follow gitleaks; keys the shape rules already catch, such as GitHub, OpenAI, Anthropic, and Stripe ones, get no separate rule. No maintained browser library fits: secretlint needs Node built-ins and adds at least 23 KB gzipped, and the PII redactors miss tokens. The scrubber is 0.8 KB gzipped. * refactor(app): name how a tool call is recorded and import diagnostics from its index * fix(app): record demo document loads in diagnostics The preparation event's schema listed its kinds, phases, cancel reasons, and failure codes by hand and lacked demo-load, so every demo load failed validation and was dropped. The schema now validates against the same lists the preparation types derive from. * feat(app): label document preparation events in Settings diagnostics Preparation events showed their raw name, editor.preparation.finished, because the summary had no label for them. They now read as their kind, such as Switch page, with the outcome and duration below. Event names are a typed union and the labels a map keyed by it, so recording a new event without a label fails type-checking; names stored by older versions still fall back to the raw name. * fix(app): keep source paths and scrub provider stacks, auth headers, and spaced home folders Review follow-up. A provider error's message was dropped but repeated on its stack's first line, so it is now removed there too. The long-run rule redacted source paths of 40 or more characters, losing the failing file; a run with slashes now loses only its key-like segments. The bare-path query rule cut optional chaining such as a.b?.c and now needs name= after the question mark. Authorization header values in any scheme, credential assignments such as api_key= or password:, and home folder names with spaces are now scrubbed. * fix(app): suppress repeats of alternating runtime errors Repeat suppression compared each error only with the previous one, so a loop alternating between two errors recorded every occurrence. Recent errors are now kept in a small bounded map. * test(app): validate copied diagnostics and wait for the copy to finish Master now rejects JSON.parse with a type assertion, so the copied report is read through a Valibot schema. The uncaught-error test read the clipboard before its copy finished and could see the previous test's report; it now waits for the confirmation, as the export test does.
2026-10-05 07:59:44 +00:00
/** The JSON that Copy diagnostics puts on the clipboard, as far as these tests read it. */
const ReportJSON = v.pipe(
v.string(),
v.parseJson(),
v.object({
environment: v.object({ app: v.string(), shell: v.string() }),
events: v.array(
v.object({
name: v.string(),
sessionId: v.optional(v.string()),
runId: v.optional(v.string()),
attributes: v.record(v.string(), v.unknown())
})
)
})
)
fix: protect unsaved documents and defer credential access (#713) * fix(app): protect unsaved documents when closing Mark tabs with unsaved content updates and ask whether to save before closing them. The prompt now covers tab closes, the desktop window close button, and the application Quit action, which previously discarded work when autosave had no writable target. Track a content revision separately from scene and recovery versions so a save only clears the indicator when it wrote the revision it captured. Cancelled pickers, failed writes, and edits made during a save keep the document open. Desktop uses the platform alert; the browser keeps the styled dialog. The desktop menu replaces the predefined Quit item so the accelerator and Dock-independent quit path request confirmation instead of exiting. * fix(ai): resolve credentials only when used Opening a document, creating a chat, or browsing chat history connected the provider and read saved secrets, which triggered system credential prompts without user intent. Startup now reads credential status only, migration runs inside the first explicit resolution, and the chat panel initializes local history without creating a transport. Stock-photo keys resolve per search instead of at settings refresh, and credentials still marked legacy count as configured so upgrading does not appear to lose them. * refactor(ai): export diagnostics from Settings only Chat kept its own debug log, copied mixed app-wide usage into a conversation export, and reported a missing cache rate as zero. Remove that surface and record AI requests, model steps, and tool activity as correlated diagnostic events instead. Settings remains the single export location, usage summaries can now distinguish unreported telemetry from zero, and transcript or tool payloads are no longer part of the export. * fix(ai): clear legacy credentials for real Clearing a Pexels, Unsplash, or provider key only removed the current store entry. A value that still lived in legacy storage kept the key configured, so a later search migrated and used the credential the user had just removed. Migrate before mutating so clearing also removes the legacy value, and share one in-flight migration so the media and provider paths cannot migrate the same plaintext twice. * fix(ai): scope credential migration per source Sharing one migration promise process-wide let a second storage return the first migration's result, leaving its own legacy keys unmigrated while reporting success. Track in-flight migrations per storage and serialize them, because every migration writes to the same store and concurrent runs could overwrite each other. * fix(app): destroy the window after a confirmed close Tauri's onCloseRequested helper destroys the window itself when a handler returns without preventing the event. Approving a close therefore invoked plugin:window|destroy, which the capability set did not grant, so the window stayed open with a permission error after saving. Always intercept the request and destroy the window explicitly once the choice is confirmed, and grant core:window:allow-destroy in place of the now-unused close permission. * fix(app): show a filled dot for unsaved tabs The unsaved indicator used a stroked Lucide circle whose fill attribute kept it an empty outline, reading as a disabled control. Draw the indicator as a filled accent dot matching the status dots used elsewhere in the app. * refactor(app): focus the unsaved prompt with VueUse Replace the manual watcher, nextTick, and component $el focus with useFocus, which focuses the Save button when the dialog mounts. Assert the focus in the close-protection test so the Return-saves behavior stays covered. * refactor(app): route Quit through the shared menu channel The Quit item emitted a bespoke app:request-exit event and the close module listened for it, while every other native item travels as a menu-event id dispatched by the shell and editor menu composables. Emit menu-event "quit" for both the Quit item and the platform exit request, handle it in useShellMenu beside check-updates, and share one confirmAppExit so window closes and app exits agree on a single approval. * refactor(app): generate the macOS app menu entries The application menu hardcoded its labels and the Quit accelerator in Rust while every other menu entry is generated from APP_MENU_SCHEMA. Move the custom app entries (About, Check for Updates, Quit) into APP_MENU_APP_ITEMS and emit desktop/generated/app-menu.json, keyed by id so the native builder cannot silently drop a label. Placement stays in Rust because the OS-predefined items sit between them, and the menu title now comes from the packaged product name. * build(tauri-menu): check generated menus against the schema The generated menu files are committed but nothing verified them, so a schema edit could silently leave desktop/generated stale until the next release build regenerated it. Split the renderers from the write step, register the tool as a workspace so its dependencies resolve, and compare the committed files with the schema in a test that runs with the other tool checks. * fix(app): serialize exit confirmations The window close handler and the Quit item both call confirmAppExit, and the per-handler closing flag does not cover the two paths. Both could run close preparation, so an unsaved document could be prompted twice. Share one in-flight confirmation and clear it when it settles, so a cancelled or failed attempt still prompts again on the next request.
2026-09-17 12:25:15 +00:00
test('Settings exports correlated chat telemetry without conversation content', async ({
configuredChat: chat,
page,
context
}) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
await chat.submit('Private conversation content must not enter diagnostics')
await expect(chat.assistantMessage()).toBeVisible()
await expect(chat.input).toBeEnabled()
await chat.submit('A second request in the same conversation')
await expect(page.getByTestId('chat-message-assistant')).toHaveCount(2)
await page.getByTestId('app-settings-trigger').click()
await page.getByTestId('settings-section-diagnostics').click()
await page.getByRole('button', { name: 'Copy diagnostics', exact: true }).click()
await expect(page.getByText('Diagnostics copied to clipboard.', { exact: true })).toBeVisible()
const text = await page.evaluate(() => navigator.clipboard.readText())
feat(app): record runtime errors in diagnostics and make events readable (#874) * feat(app): record runtime errors in diagnostics with their stack Uncaught errors and unhandled rejections only showed a toast, Vue component errors after boot only reached the console, and a failed chat kept just its error name, so a failure like WebKit's 'Attempting to define property on object that is not extensible.' left nothing to diagnose. They now record a runtime.error, and chat.failed its code, message, and stack. Messages and stacks are scrubbed of URL queries, key- and token-like strings, and home folder names and bounded; AI SDK and provider errors keep no message, since it can quote prompts or responses. Copied diagnostics start with the app version, shell, browser, and language. * feat(app): label, filter, and page diagnostics events Every row in Settings → Diagnostics read 'Technical event': the summary looked labels up under diagnostics-prefixed keys the messages do not have, and only a few event kinds had labels at all. Each event now has a specific label and a short detail, such as 'Tool: render · 162 ms', 'Model step · <model>', or an error's message, expands to its recorded fields and stack, and the list filters by level and category and grows a page at a time. The copy action passes the environment header, which moves out of the recorder so tooling that compiles it needs no build-time globals. * test(app): stream a reasoning reply in WebKit without page errors Errors such as WebKit's "not extensible" TypeError appear only in that engine, so run a streamed reasoning reply there and fail on any page or console error. * fix(app): scrub queries on bare paths in diagnostic errors Only URLs had their query removed, so a message like 'Failed to load /Designs/app.fig?token=…' kept the token. * fix(app): count diagnostics recorded before Settings opens The event count and size updated only on new events, so the panel showed 0 events beside a full list. * feat(app): record failed AI tool calls as problems, with the stack of engine errors A tool catches what it throws and returns only the message to the model, so diagnostics saw a failed tool as an info event without details. The adapter now passes the thrown value to the tool log. A failed call is a warning; a TypeError, ReferenceError, or RangeError, which comes from a bug in OpenPencil rather than a wrong call, is an error with its message and stack. Other tool errors keep only their name, since their messages quote layer names and arguments. * fix(core): log tool calls that return an error as failed Most tools report a failure by returning { error } rather than throwing, such as describe with an unknown node, so the tool log and diagnostics counted them as successful calls while the chat showed them failed. * feat(app): scrub cloud keys, JWTs, private keys, URL credentials, and emails from diagnostics The scrubber caught keys by shape only, so 20-character AWS access key IDs, user:pass@ in URLs, and emails reached the log, and a JWT's payload survived because its dots split it into short runs. It moves into its own module with rules grouped by what they protect. The added credential formats follow gitleaks; keys the shape rules already catch, such as GitHub, OpenAI, Anthropic, and Stripe ones, get no separate rule. No maintained browser library fits: secretlint needs Node built-ins and adds at least 23 KB gzipped, and the PII redactors miss tokens. The scrubber is 0.8 KB gzipped. * refactor(app): name how a tool call is recorded and import diagnostics from its index * fix(app): record demo document loads in diagnostics The preparation event's schema listed its kinds, phases, cancel reasons, and failure codes by hand and lacked demo-load, so every demo load failed validation and was dropped. The schema now validates against the same lists the preparation types derive from. * feat(app): label document preparation events in Settings diagnostics Preparation events showed their raw name, editor.preparation.finished, because the summary had no label for them. They now read as their kind, such as Switch page, with the outcome and duration below. Event names are a typed union and the labels a map keyed by it, so recording a new event without a label fails type-checking; names stored by older versions still fall back to the raw name. * fix(app): keep source paths and scrub provider stacks, auth headers, and spaced home folders Review follow-up. A provider error's message was dropped but repeated on its stack's first line, so it is now removed there too. The long-run rule redacted source paths of 40 or more characters, losing the failing file; a run with slashes now loses only its key-like segments. The bare-path query rule cut optional chaining such as a.b?.c and now needs name= after the question mark. Authorization header values in any scheme, credential assignments such as api_key= or password:, and home folder names with spaces are now scrubbed. * fix(app): suppress repeats of alternating runtime errors Repeat suppression compared each error only with the previous one, so a loop alternating between two errors recorded every occurrence. Recent errors are now kept in a small bounded map. * test(app): validate copied diagnostics and wait for the copy to finish Master now rejects JSON.parse with a type assertion, so the copied report is read through a Valibot schema. The uncaught-error test read the clipboard before its copy finished and could see the previous test's report; it now waits for the confirmation, as the export test does.
2026-10-05 07:59:44 +00:00
const { environment, events } = v.parse(ReportJSON, text)
expect(environment).toMatchObject({ app: expect.any(String), shell: 'browser' })
fix: protect unsaved documents and defer credential access (#713) * fix(app): protect unsaved documents when closing Mark tabs with unsaved content updates and ask whether to save before closing them. The prompt now covers tab closes, the desktop window close button, and the application Quit action, which previously discarded work when autosave had no writable target. Track a content revision separately from scene and recovery versions so a save only clears the indicator when it wrote the revision it captured. Cancelled pickers, failed writes, and edits made during a save keep the document open. Desktop uses the platform alert; the browser keeps the styled dialog. The desktop menu replaces the predefined Quit item so the accelerator and Dock-independent quit path request confirmation instead of exiting. * fix(ai): resolve credentials only when used Opening a document, creating a chat, or browsing chat history connected the provider and read saved secrets, which triggered system credential prompts without user intent. Startup now reads credential status only, migration runs inside the first explicit resolution, and the chat panel initializes local history without creating a transport. Stock-photo keys resolve per search instead of at settings refresh, and credentials still marked legacy count as configured so upgrading does not appear to lose them. * refactor(ai): export diagnostics from Settings only Chat kept its own debug log, copied mixed app-wide usage into a conversation export, and reported a missing cache rate as zero. Remove that surface and record AI requests, model steps, and tool activity as correlated diagnostic events instead. Settings remains the single export location, usage summaries can now distinguish unreported telemetry from zero, and transcript or tool payloads are no longer part of the export. * fix(ai): clear legacy credentials for real Clearing a Pexels, Unsplash, or provider key only removed the current store entry. A value that still lived in legacy storage kept the key configured, so a later search migrated and used the credential the user had just removed. Migrate before mutating so clearing also removes the legacy value, and share one in-flight migration so the media and provider paths cannot migrate the same plaintext twice. * fix(ai): scope credential migration per source Sharing one migration promise process-wide let a second storage return the first migration's result, leaving its own legacy keys unmigrated while reporting success. Track in-flight migrations per storage and serialize them, because every migration writes to the same store and concurrent runs could overwrite each other. * fix(app): destroy the window after a confirmed close Tauri's onCloseRequested helper destroys the window itself when a handler returns without preventing the event. Approving a close therefore invoked plugin:window|destroy, which the capability set did not grant, so the window stayed open with a permission error after saving. Always intercept the request and destroy the window explicitly once the choice is confirmed, and grant core:window:allow-destroy in place of the now-unused close permission. * fix(app): show a filled dot for unsaved tabs The unsaved indicator used a stroked Lucide circle whose fill attribute kept it an empty outline, reading as a disabled control. Draw the indicator as a filled accent dot matching the status dots used elsewhere in the app. * refactor(app): focus the unsaved prompt with VueUse Replace the manual watcher, nextTick, and component $el focus with useFocus, which focuses the Save button when the dialog mounts. Assert the focus in the close-protection test so the Return-saves behavior stays covered. * refactor(app): route Quit through the shared menu channel The Quit item emitted a bespoke app:request-exit event and the close module listened for it, while every other native item travels as a menu-event id dispatched by the shell and editor menu composables. Emit menu-event "quit" for both the Quit item and the platform exit request, handle it in useShellMenu beside check-updates, and share one confirmAppExit so window closes and app exits agree on a single approval. * refactor(app): generate the macOS app menu entries The application menu hardcoded its labels and the Quit accelerator in Rust while every other menu entry is generated from APP_MENU_SCHEMA. Move the custom app entries (About, Check for Updates, Quit) into APP_MENU_APP_ITEMS and emit desktop/generated/app-menu.json, keyed by id so the native builder cannot silently drop a label. Placement stays in Rust because the OS-predefined items sit between them, and the menu title now comes from the packaged product name. * build(tauri-menu): check generated menus against the schema The generated menu files are committed but nothing verified them, so a schema edit could silently leave desktop/generated stale until the next release build regenerated it. Split the renderers from the write step, register the tool as a workspace so its dependencies resolve, and compare the committed files with the schema in a test that runs with the other tool checks. * fix(app): serialize exit confirmations The window close handler and the Quit item both call confirmAppExit, and the per-handler closing flag does not cover the two paths. Both could run close preparation, so an unsaved document could be prompted twice. Share one in-flight confirmation and clear it when it settles, so a cancelled or failed attempt still prompts again on the next request.
2026-09-17 12:25:15 +00:00
const completed = events.filter((event) => event.name === 'chat.completed')
expect(completed).toHaveLength(2)
expect(completed[0].sessionId).toEqual(expect.any(String))
expect(completed[1].sessionId).toBe(completed[0].sessionId)
expect(completed[0].runId).toEqual(expect.any(String))
expect(completed[1].runId).toEqual(expect.any(String))
expect(completed[1].runId).not.toBe(completed[0].runId)
expect(text).not.toContain('Private conversation content')
expect(text).not.toContain('A second request')
})
feat(settings): configure tool access, MCP failures, and step limits * feat(settings): configure tool access and agent step limits Built-in AI exposed only a hardcoded subset of the tool registry, and the maximum agent steps was a constant, so users could neither enable extended tools such as create_component nor adjust long-running tasks. Built-in AI and the local MCP server now keep independent, locally saved tool permissions over one shared catalog, with searchable read-only and side-effect groups and per-target defaults. Chat settings gain a validated maximum-steps field whose captured value drives the stop condition, remaining-step warnings, and limit detection for each message. Tool access, the local server, browser access, and MCP connections are grouped under a single Automation settings page. Closes #573 Closes #584 * refactor(settings): split automation into MCP and Tool access pages The Automation page mixed a permission matrix with server endpoints behind a Tools/Connections switch, and the view switch was indistinguishable from the provider switch. The nested scroll region showed three of 110 tools. Rename the MCP-facing page to MCP and give tool permissions their own Tool access page. The page owns a fixed toolbar for the target, count, defaults, and search, so the list uses the full dialog body and no row is clipped. * fix(automation): explain MCP startup failures with localized guidance Every startup failure collapsed into "MCP server did not become healthy": the spawn layer recorded the real error but the runtime discarded it, and health probes could not distinguish a rejected token from a missing server. The message also surfaced raw English text as the alert heading. Classify failures by reason (not installed, denied command, early exit, startup timeout, rejected token, unexpected response, unreachable) and render translated heading and guidance from the catalog, keeping captured stderr or HTTP status as labeled diagnostic detail. * refactor(ui): share one collapsible disclosure primitive Six features each wired Reka's collapsible with their own motion classes and one settings-only theme token, so the same interaction drifted in spacing, icon size, and reduced-motion handling. Add AppCollapsible with a family theme and move the settings disclosure and the model editor's advanced settings onto it. Chat and frame-preset call sites keep their distinct visuals for a follow-up. * fix(automation): explain MCP failures with localized details The failure alert carried raw English error text as its heading, and the diagnostic payload sat in a sibling block outside the alert with no relationship to it. Classify failures by reason, render translated heading and guidance from the catalog, and keep the payload in a collapsible inside the alert, which unmounts while collapsed so the live region announces only the summary. Add a copy action for issue reports. Find the executable where a graphical launch can: extend PATH with the common global bin directories before the lookup and report the searched directories as diagnostic detail. * fix(automation): keep MCP failure details out of reasons already explained An unreachable address and a rejected token already name their cause in the translated guidance, so repeating it under Details added noise. Details now carry only output the summary cannot: stderr, HTTP status, or an unknown error message. * test(settings): browse every MCP failure reason in Storybook The failure copy lived inside the settings panel, so reviewing the eight reasons meant reproducing each failure and the mapping could only be checked through the panel's dependencies. Extract MCPFailureAlert, which owns the reason-to-copy mapping, detail visibility, copy action, and restart action, and add a story covering every reason plus the collapsed-details behavior. * fix(ui): order alert details above the recovery actions The alert rendered its action buttons before the details slot, so the collapsible explanation of a failure appeared under the controls it explains. Details now render directly after the description. * fix(automation): correct MCP failure classification and detail Review follow-ups on the failure diagnostics. Only 401 and 403 mean the server refused our token; any other status now reports an unexpected response instead of telling the user to replace a token that was never the problem. The install hint rendered the whole diagnostic detail as its package argument, so searched directories appeared inside the install command. The install target is now a domain constant and the searched directories stay as detail, which not-installed failures surface again since they are the actionable desktop diagnostic. Exited failures also record the process exit code and signal so copied diagnostics stay conclusive when stderr is empty. The bundled PATH test now covers the append branch instead of only the unchanged path. * feat(settings): accept custom values for presets and retention Retention was a closed set of three counts while the AI step limit was a free number, so two bounded numeric preferences looked and behaved differently for no product reason. Add a shared preset-or-custom field: presets stay one click, the escape hatch reveals a validated numeric field, and the model carries only the resolved number. Diagnostics retention becomes a bounded number (50 to 20,000) with the presets as shortcuts, and the hardcoded revalidation in the panel is replaced by one domain resolver. * fix(settings): label the preset and custom fields Replacing the labeled provider field with the shared control left the AI step limit as a bare select with a detached hint paragraph, outside the settings group, so nothing on screen said what the number meant. The accessibility name came from aria-label, which is why behavior tests passed while the panel was unreadable. Move both controls into labeled settings rows with their descriptions, and give the revealed field its own accessible name so the two controls in one row differ. The specs now assert the control lives inside the row that names it, which is the check that would have caught this. * fix(mcp): allow the desktop app origin by default A server started manually bound the port and answered curl but the app webview could not use it: no CORS origin was configured, so the browser blocked every fetch and the app reported the server as unhealthy. The workaround required an undocumented environment variable. Allow the desktop app origins by default, accept a comma-separated override, and document the default in the CLI help and the security notes. Authenticated requests still need the bearer token, and browsers set Origin themselves, so only the app webview can present these origins. * fix(settings): address review findings on the new controls Copy details awaited nothing and confirmed the copy before the write finished. VueUse never rejects and falls back to a legacy write, so the await is what makes the confirmation honest rather than an error branch. The preset field only left custom mode when a preset arrived; a non-preset value assigned from the owner left the select showing a value absent from its options with the field still hidden. The watcher now follows the model in both directions. The story play functions queried the revealed field by the row label, which Testing Library matches as a whole string, so those interactions could not find it. The Storybook smoke assertion also assumed a button or tab, which skipped every story built from other primitives.
2026-09-17 20:55:58 +00:00
async function openDiagnostics(page: Page) {
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+,' : 'Control+,')
await page.getByTestId('settings-section-diagnostics').click()
}
async function reloadAndOpenDiagnostics(page: Page) {
await page.reload()
await new CanvasHelper(page).waitForInit()
await openDiagnostics(page)
}
test('diagnostics retention offers presets and accepts a bounded custom value', async ({
page
}) => {
await page.goto('/?test')
await new CanvasHelper(page).waitForInit()
await openDiagnostics(page)
const row = page
.locator('[data-slot="settings-row"]')
.filter({ hasText: 'Diagnostics retention' })
await expect(row).toContainText('Keep up to this many recent events locally.')
const retention = row.getByRole('combobox', { name: 'Diagnostics retention' })
await expect(retention).toHaveText('500')
// A custom value is validated against the supported range before it is kept.
await retention.click()
await page.getByRole('option', { name: 'Custom…' }).click()
const custom = row.getByRole('spinbutton', { name: /Diagnostics retention/ })
await custom.fill('10')
await custom.press('Enter')
await expect(custom).toHaveAttribute('aria-invalid', 'true')
await expect(custom).toHaveAccessibleDescription(/Enter a whole number from 50 to 20000/)
await custom.fill('750')
await custom.press('Enter')
await expect(custom).toHaveAttribute('aria-invalid', 'false')
await reloadAndOpenDiagnostics(page)
await expect(retention).toHaveText('Custom…')
await expect(row.getByRole('spinbutton', { name: /Diagnostics retention/ })).toHaveValue('750')
// Presets remain one click and persist without revealing the field.
await retention.click()
await page.getByRole('option', { name: '1000', exact: true }).click()
await expect(retention).toHaveText('1000')
await reloadAndOpenDiagnostics(page)
await expect(retention).toHaveText('1000')
await expect(row.getByRole('spinbutton')).toHaveCount(0)
})
feat(app): record runtime errors in diagnostics and make events readable (#874) * feat(app): record runtime errors in diagnostics with their stack Uncaught errors and unhandled rejections only showed a toast, Vue component errors after boot only reached the console, and a failed chat kept just its error name, so a failure like WebKit's 'Attempting to define property on object that is not extensible.' left nothing to diagnose. They now record a runtime.error, and chat.failed its code, message, and stack. Messages and stacks are scrubbed of URL queries, key- and token-like strings, and home folder names and bounded; AI SDK and provider errors keep no message, since it can quote prompts or responses. Copied diagnostics start with the app version, shell, browser, and language. * feat(app): label, filter, and page diagnostics events Every row in Settings → Diagnostics read 'Technical event': the summary looked labels up under diagnostics-prefixed keys the messages do not have, and only a few event kinds had labels at all. Each event now has a specific label and a short detail, such as 'Tool: render · 162 ms', 'Model step · <model>', or an error's message, expands to its recorded fields and stack, and the list filters by level and category and grows a page at a time. The copy action passes the environment header, which moves out of the recorder so tooling that compiles it needs no build-time globals. * test(app): stream a reasoning reply in WebKit without page errors Errors such as WebKit's "not extensible" TypeError appear only in that engine, so run a streamed reasoning reply there and fail on any page or console error. * fix(app): scrub queries on bare paths in diagnostic errors Only URLs had their query removed, so a message like 'Failed to load /Designs/app.fig?token=…' kept the token. * fix(app): count diagnostics recorded before Settings opens The event count and size updated only on new events, so the panel showed 0 events beside a full list. * feat(app): record failed AI tool calls as problems, with the stack of engine errors A tool catches what it throws and returns only the message to the model, so diagnostics saw a failed tool as an info event without details. The adapter now passes the thrown value to the tool log. A failed call is a warning; a TypeError, ReferenceError, or RangeError, which comes from a bug in OpenPencil rather than a wrong call, is an error with its message and stack. Other tool errors keep only their name, since their messages quote layer names and arguments. * fix(core): log tool calls that return an error as failed Most tools report a failure by returning { error } rather than throwing, such as describe with an unknown node, so the tool log and diagnostics counted them as successful calls while the chat showed them failed. * feat(app): scrub cloud keys, JWTs, private keys, URL credentials, and emails from diagnostics The scrubber caught keys by shape only, so 20-character AWS access key IDs, user:pass@ in URLs, and emails reached the log, and a JWT's payload survived because its dots split it into short runs. It moves into its own module with rules grouped by what they protect. The added credential formats follow gitleaks; keys the shape rules already catch, such as GitHub, OpenAI, Anthropic, and Stripe ones, get no separate rule. No maintained browser library fits: secretlint needs Node built-ins and adds at least 23 KB gzipped, and the PII redactors miss tokens. The scrubber is 0.8 KB gzipped. * refactor(app): name how a tool call is recorded and import diagnostics from its index * fix(app): record demo document loads in diagnostics The preparation event's schema listed its kinds, phases, cancel reasons, and failure codes by hand and lacked demo-load, so every demo load failed validation and was dropped. The schema now validates against the same lists the preparation types derive from. * feat(app): label document preparation events in Settings diagnostics Preparation events showed their raw name, editor.preparation.finished, because the summary had no label for them. They now read as their kind, such as Switch page, with the outcome and duration below. Event names are a typed union and the labels a map keyed by it, so recording a new event without a label fails type-checking; names stored by older versions still fall back to the raw name. * fix(app): keep source paths and scrub provider stacks, auth headers, and spaced home folders Review follow-up. A provider error's message was dropped but repeated on its stack's first line, so it is now removed there too. The long-run rule redacted source paths of 40 or more characters, losing the failing file; a run with slashes now loses only its key-like segments. The bare-path query rule cut optional chaining such as a.b?.c and now needs name= after the question mark. Authorization header values in any scheme, credential assignments such as api_key= or password:, and home folder names with spaces are now scrubbed. * fix(app): suppress repeats of alternating runtime errors Repeat suppression compared each error only with the previous one, so a loop alternating between two errors recorded every occurrence. Recent errors are now kept in a small bounded map. * test(app): validate copied diagnostics and wait for the copy to finish Master now rejects JSON.parse with a type assertion, so the copied report is read through a Valibot schema. The uncaught-error test read the clipboard before its copy finished and could see the previous test's report; it now waits for the confirmation, as the export test does.
2026-10-05 07:59:44 +00:00
test('uncaught errors are listed with their stack and can be filtered and copied', async ({
configuredChat: chat,
page,
context
}) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
await chat.submit('A request that records info events')
await expect(chat.assistantMessage()).toBeVisible()
await page.evaluate(() => {
setTimeout(() => {
throw new TypeError('Attempting to define property on object that is not extensible.')
})
})
await openDiagnostics(page)
const events = page.locator('[data-slot="diagnostics-events"]')
const failure = events.getByRole('button', { name: /Error: TypeError/ })
await expect(failure).toContainText(
'Attempting to define property on object that is not extensible.'
)
await failure.click()
await expect(events.locator('pre')).toContainText('TypeError')
// Events recorded before Settings opened are counted.
await expect(page.getByText(/^[1-9]\d* events · /)).toBeVisible()
// Info events such as the completed chat drop out when only problems are shown.
await expect(events.getByRole('button', { name: /AI chat completed/ })).toBeVisible()
await events.getByRole('button', { name: 'Errors and warnings' }).click()
await expect(events.getByRole('button', { name: /AI chat completed/ })).toHaveCount(0)
await expect(failure).toBeVisible()
await page.getByRole('button', { name: 'Copy diagnostics', exact: true }).click()
// The clipboard outlives the test's page, so read it only once this copy has finished.
await expect(page.getByText('Diagnostics copied to clipboard.', { exact: true })).toBeVisible()
const report = v.parse(ReportJSON, await page.evaluate(() => navigator.clipboard.readText()))
const runtime = report.events.find((event) => event.name === 'runtime.error')
expect(runtime?.attributes).toMatchObject({ source: 'window', errorName: 'TypeError' })
expect(String(runtime?.attributes.stack)).toContain('TypeError')
})