From c87a5cbed39001a4783b7ea6e652fb06607ff74c Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Sat, 24 Oct 2026 22:09:12 +0800 Subject: [PATCH 1/2] feat: copy one report of the whole session - The dock header has a copy button for a session report, for when it is not clear which panel holds the problem. - The report has the three newest errors in full, every issue with its count and owner, the five newest timeline actions, the flagged async records, and the failed server calls. A section appears only when its panel has something. - Each panel's report is reused. The error reports move two heading levels down to sit inside their section. - The session report and the stack mapping load when the button is pressed, so the main bundle does not grow. Co-Authored-By: Claude Opus 5.5 --- .changeset/session-report.md | 5 + README.md | 14 ++ src/dev-toolbar/index.tsx | 25 +++ src/dev-toolbar/report/session-report.test.ts | 133 +++++++++++++++ src/dev-toolbar/report/session-report.ts | 155 ++++++++++++++++++ tests/e2e/devtools.spec.ts | 19 +++ 6 files changed, 351 insertions(+) create mode 100644 .changeset/session-report.md create mode 100644 src/dev-toolbar/report/session-report.test.ts create mode 100644 src/dev-toolbar/report/session-report.ts diff --git a/.changeset/session-report.md b/.changeset/session-report.md new file mode 100644 index 0000000..77e494d --- /dev/null +++ b/.changeset/session-report.md @@ -0,0 +1,5 @@ +--- +'@solidjs/start-devtools': minor +--- + +The dock header has a button that copies one report of the whole session for an agent: the newest errors, the issues, the recent timeline actions, the flagged async records and the failed server calls. diff --git a/README.md b/README.md index e08cc6e..41e6258 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,20 @@ does not include the toolbar. The same import is safe in development and production entries. +## Copy the whole session + +The copy button in the dock header puts one Markdown report of everything the devtools +noticed on the clipboard, for when it is not clear which panel holds the problem. + +- The three newest errors in full, with their mapped stacks and source. +- Every issue, with its count and owner. +- The five newest timeline actions, with their writes, re-runs and calls. +- The async records that were flagged, such as a wait with no pending state. +- The server calls that failed. + +A section only appears when its panel has something in it. Values with sensitive names are +redacted, as in the reports of each panel. + ## Demo Two demo apps live in `examples`. diff --git a/src/dev-toolbar/index.tsx b/src/dev-toolbar/index.tsx index 18454c6..62f4e4e 100644 --- a/src/dev-toolbar/index.tsx +++ b/src/dev-toolbar/index.tsx @@ -30,6 +30,8 @@ import { } from './icons.js'; import { AsyncHistory, startAsyncCapture, type AsyncRecord } from './async/tracker.js'; import { addIssues, captureIssues, type IssueEntry } from './issues/tracker.js'; +import { CopyReportButton } from './report/CopyReportButton.js'; +import { pageRoute } from './report/markdown.js'; import { excludeOwner, includeOwner } from './ownership/registry.js'; import { excludeReactiveOwner, includeReactiveOwner } from './reactivity/registry.js'; import { startTimeline, TimelineBuilder, type TimelineEntry } from './timeline/tracker.js'; @@ -366,6 +368,28 @@ export function DevToolbar(props: DevToolbarProps) { const callCount = createMemo(() => Object.keys(instances()).length); + async function copySession(): Promise { + // Loaded on use: mapping stacks needs the source-map reader the error panel loads lazily. + const [{ resolveErrorFrames }, { SESSION_ERRORS, sessionReport }] = await Promise.all([ + import('./report/error-report.js'), + import('./report/session-report.js'), + ]); + const recent = errors().slice(0, SESSION_ERRORS); + const resolved = await Promise.all( + recent.map(async (error) => ({ error, frames: await resolveErrorFrames(error) })), + ); + return sessionReport({ + errors: resolved, + errorCount: errors().length, + issues: issues(), + timeline: timelineEntries(), + asyncRecords: asyncRecords(), + calls: Object.values(instances()).filter((call) => call !== undefined), + route: pageRoute(), + version, + }); + } + const badge = (value: Tab): number => { if (value === 'err') return errors().length; if (value === 'issues') return issueCount(); @@ -435,6 +459,7 @@ export function DevToolbar(props: DevToolbarProps) { {route()}
+ diff --git a/src/dev-toolbar/report/session-report.test.ts b/src/dev-toolbar/report/session-report.test.ts new file mode 100644 index 0000000..8a07e06 --- /dev/null +++ b/src/dev-toolbar/report/session-report.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; +import { AsyncHistory } from '../async/tracker.js'; +import type { ServerFunctionInstance } from '../functions/index.js'; +import { addIssues } from '../issues/tracker.js'; +import { TimelineBuilder } from '../timeline/tracker.js'; +import { demote, sessionReport, type SessionReportInput } from './session-report.js'; + +function input(over: Partial = {}): SessionReportInput { + return { + errors: [], + errorCount: 0, + issues: [], + timeline: [], + asyncRecords: [], + calls: [], + route: '/todos', + version: '1.0.0', + ...over, + }; +} + +describe('demote', () => { + it('moves headings down a level and leaves code blocks alone', () => { + expect(demote('# Title\n\n```\n# not a heading\n```\n## Part')).toBe( + '## Title\n\n```\n# not a heading\n```\n### Part', + ); + }); +}); + +describe('sessionReport', () => { + it('says when there is nothing to report', () => { + const report = sessionReport(input()); + expect(report).toContain('# Solid devtools session'); + expect(report).toContain('- Page: `/todos`'); + expect(report).toContain('Nothing to report'); + }); + + it('has a section for each panel with something in it', () => { + const issues = addIssues( + [], + [ + { + event: { + sequence: 1, + code: 'STRICT_READ_UNTRACKED', + kind: 'strict-read', + severity: 'warn', + message: '[STRICT_READ_UNTRACKED] Read outside tracking.', + ownerPath: ['', ''], + }, + subject: undefined, + at: 1, + }, + ], + ); + + const timeline = new TimelineBuilder(); + const click = { kind: 'interaction' as const, name: 'click', target: 'button#inc', at: 1 }; + timeline.rerun( + { + run: 1, + at: 2, + nodeRuns: 1, + nodeKind: 'memo', + nodeName: 'doubled', + nodeId: 1, + causes: [{ seq: 1, kind: 'write', name: 'count', prev: '0', value: '1', origin: click }], + depCount: 1, + depsAdded: [], + depsRemoved: [], + selfMs: 0.2, + totalMs: 0.2, + changed: true, + phase: 'plain', + held: false, + interaction: click, + }, + undefined, + ); + + const history = new AsyncHistory(); + history.fallback({ at: 1, shownMs: 80, ownerPath: ['', ''] }, undefined); + history.fallback({ at: 1, shownMs: 400 }, undefined); + + const calls: ServerFunctionInstance[] = [ + { + request: { + type: 'request', + id: 'save', + instance: 'save#1', + source: new Request('http://localhost/_server', { method: 'POST' }), + meta: { name: 'save' }, + time: 1, + }, + response: { + type: 'response', + id: 'save', + instance: 'save#1', + source: new Response(null, { status: 500 }), + meta: { name: 'save' }, + time: 2, + }, + }, + ]; + + const report = sessionReport( + input({ + errors: [{ error: new TypeError('boom'), frames: [] }], + errorCount: 4, + issues, + timeline: timeline.snapshot(), + asyncRecords: history.records, + calls, + }), + ); + + expect(report).toContain('## Errors (4)'); + // The error report sits inside its section, two levels down. + expect(report).toContain('### Error in a Solid app'); + expect(report).toContain('TypeError: boom'); + expect(report).toContain('… 3 older errors not shown.'); + expect(report).toContain( + '## Issues (1)\n\n- warn `STRICT_READ_UNTRACKED` in ` > `: Read outside tracking.', + ); + expect(report).toContain( + '## Recent actions, newest first\n\n- `click on button#inc`, 1 write, 1 re-run', + ); + expect(report).toContain( + '## Async concerns (1)\n\n- `fallback at › `, 80.0ms: flash', + ); + expect(report).toContain('## Failed server calls (1)\n\n- POST `save` → 500'); + }); +}); diff --git a/src/dev-toolbar/report/session-report.ts b/src/dev-toolbar/report/session-report.ts new file mode 100644 index 0000000..17dd489 --- /dev/null +++ b/src/dev-toolbar/report/session-report.ts @@ -0,0 +1,155 @@ +import { concernOf, type AsyncRecord } from '../async/tracker.js'; +import type { ServerFunctionInstance } from '../functions/index.js'; +import { issueText, type IssueEntry } from '../issues/tracker.js'; +import type { TimelineEntry } from '../timeline/tracker.js'; +import { asyncDuration, asyncOrigin, asyncTitle } from './async-report.js'; +import { errorReport, type ReportFrame } from './error-report.js'; +import { code } from './markdown.js'; +import { redactText, redactUrl } from './redact.js'; +import { ms, plural } from './timeline-report.js'; + +/** Errors the session report gives in full, newest first. The rest are counted. */ +export const SESSION_ERRORS = 3; +/** Issues the session report lists. */ +export const SESSION_ISSUES = 20; +/** Timeline actions the session report lists, newest first. */ +export const SESSION_ACTIONS = 5; +/** Flagged async records the session report lists. */ +export const SESSION_ASYNC = 10; + +export interface SessionError { + error: unknown; + frames: ReportFrame[]; +} + +export interface SessionReportInput { + /** Newest first, as the error panel keeps them. */ + errors: SessionError[]; + /** How many errors there are in all, which can be more than `errors` holds. */ + errorCount: number; + issues: IssueEntry[]; + timeline: TimelineEntry[]; + asyncRecords: AsyncRecord[]; + calls: ServerFunctionInstance[]; + /** The path and query of the page. */ + route: string; + /** The devtools version, so a reader knows what made the report. */ + version: string; +} + +/** + * Puts a report one heading level lower, so it reads as a section of another + * report. Lines inside code blocks are left alone. + */ +export function demote(markdown: string): string { + let fence: string | undefined; + return markdown + .split('\n') + .map((line) => { + const marks = line.match(/^(`{3,})/)?.[1]; + if (marks && (fence === undefined || marks.length >= fence.length)) { + fence = fence === undefined ? marks : undefined; + return line; + } + return fence === undefined && line.startsWith('#') ? `#${line}` : line; + }) + .join('\n'); +} + +function failedCall(instance: ServerFunctionInstance): string | undefined { + const { request, response } = instance; + const name = code(request.meta?.name ?? request.id); + if (response && !response.source.ok) { + return `- ${request.source.method} ${name} → ${response.source.status}`; + } + const error = request.meta?.error; + if (!response && error !== undefined) { + const message = error instanceof Error ? error.message : String(error); + return `- ${request.source.method} ${name} → no response: ${redactText(message)}`; + } + return undefined; +} + +function actionLine(entry: TimelineEntry): string { + const parts = [ + plural(entry.writes.length, 'write'), + plural(entry.interaction?.runs ?? entry.runs.length + entry.droppedRuns, 're-run'), + ]; + if (entry.calls.length) parts.push(plural(entry.calls.length, 'call')); + const time = entry.interaction?.settledMs ?? entry.flushMs; + const outcome = entry.interaction?.outcome ? `, ${entry.interaction.outcome}` : ''; + const writes = entry.writes.length + ? `: ${entry.writes.map((write) => code(write.name)).join(', ')}` + : ''; + return `- ${code(entry.label)}, ${parts.join(', ')} in ${ms(time)}${outcome}${writes}`; +} + +/** + * One Markdown report of everything the devtools noticed, for an agent to + * read when it is not clear which panel holds the problem. Each panel adds a + * section only when it has something in it. + */ +export function sessionReport(input: SessionReportInput): string { + const out: string[] = ['# Solid devtools session', '']; + out.push(`- Page: ${code(redactUrl(input.route))}`); + out.push(`- Devtools: ${input.version}`); + + const failed = input.calls.map(failedCall).filter((line) => line !== undefined); + const flagged = input.asyncRecords.filter((record) => concernOf(record)); + const empty = + !input.errorCount && + !input.issues.length && + !input.timeline.length && + !flagged.length && + !failed.length; + if (empty) { + out.push('', 'Nothing to report: no errors, issues, actions, async concerns or failed calls.'); + return out.join('\n'); + } + + if (input.errorCount) { + out.push('', `## Errors (${input.errorCount})`); + for (const { error, frames } of input.errors.slice(0, SESSION_ERRORS)) { + out.push('', demote(demote(errorReport({ error, frames, route: input.route })))); + } + const rest = input.errorCount - Math.min(input.errors.length, SESSION_ERRORS); + if (rest > 0) out.push('', `… ${plural(rest, 'older error')} not shown.`); + } + + if (input.issues.length) { + out.push('', `## Issues (${input.issues.length})`, ''); + for (const entry of input.issues.slice(0, SESSION_ISSUES)) { + const { event, count } = entry; + const times = count > 1 ? ` ×${count}` : ''; + const owner = event.ownerPath?.length ? ` in ${code(event.ownerPath.join(' > '))}` : ''; + out.push( + `- ${event.severity} ${code(event.code)}${times}${owner}: ${redactText(issueText(event))}`, + ); + } + const rest = input.issues.length - SESSION_ISSUES; + if (rest > 0) out.push(`- … ${plural(rest, 'more issue')}`); + } + + if (input.timeline.length) { + out.push('', '## Recent actions, newest first', ''); + for (const entry of input.timeline.slice(0, SESSION_ACTIONS)) out.push(actionLine(entry)); + } + + if (flagged.length) { + out.push('', `## Async concerns (${flagged.length})`, ''); + for (const record of flagged.slice(0, SESSION_ASYNC)) { + const origin = asyncOrigin(record); + out.push( + `- ${code(asyncTitle(record))}, ${ms(asyncDuration(record))}: ${concernOf(record)}${origin ? `, started by ${code(origin)}` : ''}`, + ); + } + } + + if (failed.length) { + out.push('', `## Failed server calls (${failed.length})`, ''); + out.push(...failed); + } + + out.push('', 'Each panel of the devtools has a copy button for the full report of one item.'); + return out.join('\n'); +} diff --git a/tests/e2e/devtools.spec.ts b/tests/e2e/devtools.spec.ts index c80b28d..b363c45 100644 --- a/tests/e2e/devtools.spec.ts +++ b/tests/e2e/devtools.spec.ts @@ -596,6 +596,25 @@ test('shows async loads, waits, fallbacks and chains', async ({ page }) => { await expect(page.locator('[data-solid-reactivity-detail]')).toContainText('user'); }); +test('copies a report of the whole session', async ({ page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + const clipboard = () => page.evaluate(() => navigator.clipboard.readText()); + + await page.goto('/'); + await page.locator('#increment-count').click(); + await page.locator('#emit-warning').click(); + + await openTab(page, 'Errors'); + await page.getByRole('button', { name: 'Copy the whole session for an agent' }).click(); + await expect.poll(clipboard).toContain('# Solid devtools session'); + const report = await clipboard(); + expect(report).toContain('## Issues (1)'); + expect(report).toContain('`STRICT_READ_UNTRACKED`'); + expect(report).toContain('## Recent actions, newest first'); + expect(report).toContain('click on button#increment-count'); + expect(report).not.toContain('## Errors'); +}); + test('copies reports for an agent', async ({ page, context }) => { await context.grantPermissions(['clipboard-read', 'clipboard-write']); const clipboard = () => page.evaluate(() => navigator.clipboard.readText()); From 9edb19c8ba2089fd10ffcfa185a18af36dbd9c86 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Sat, 24 Oct 2026 22:13:00 +0800 Subject: [PATCH 2/2] fix(session): leave unnamed updates out of the recent actions - The session report lists the five newest actions. It now skips the updates the timeline hides by default, so runtime updates do not push real actions out. - An e2e test checks that the timeline hides those updates and brings them back. Co-Authored-By: Claude Opus 5.5 --- src/dev-toolbar/report/session-report.ts | 10 ++++++---- tests/e2e/devtools.spec.ts | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/dev-toolbar/report/session-report.ts b/src/dev-toolbar/report/session-report.ts index 17dd489..72b3a76 100644 --- a/src/dev-toolbar/report/session-report.ts +++ b/src/dev-toolbar/report/session-report.ts @@ -1,7 +1,7 @@ import { concernOf, type AsyncRecord } from '../async/tracker.js'; import type { ServerFunctionInstance } from '../functions/index.js'; import { issueText, type IssueEntry } from '../issues/tracker.js'; -import type { TimelineEntry } from '../timeline/tracker.js'; +import { isUnnamedUpdate, type TimelineEntry } from '../timeline/tracker.js'; import { asyncDuration, asyncOrigin, asyncTitle } from './async-report.js'; import { errorReport, type ReportFrame } from './error-report.js'; import { code } from './markdown.js'; @@ -95,11 +95,13 @@ export function sessionReport(input: SessionReportInput): string { out.push(`- Devtools: ${input.version}`); const failed = input.calls.map(failedCall).filter((line) => line !== undefined); + // The same updates the timeline hides by default, so the newest actions are real ones. + const actions = input.timeline.filter((entry) => !isUnnamedUpdate(entry)); const flagged = input.asyncRecords.filter((record) => concernOf(record)); const empty = !input.errorCount && !input.issues.length && - !input.timeline.length && + !actions.length && !flagged.length && !failed.length; if (empty) { @@ -130,9 +132,9 @@ export function sessionReport(input: SessionReportInput): string { if (rest > 0) out.push(`- … ${plural(rest, 'more issue')}`); } - if (input.timeline.length) { + if (actions.length) { out.push('', '## Recent actions, newest first', ''); - for (const entry of input.timeline.slice(0, SESSION_ACTIONS)) out.push(actionLine(entry)); + for (const entry of actions.slice(0, SESSION_ACTIONS)) out.push(actionLine(entry)); } if (flagged.length) { diff --git a/tests/e2e/devtools.spec.ts b/tests/e2e/devtools.spec.ts index b363c45..ed5ee54 100644 --- a/tests/e2e/devtools.spec.ts +++ b/tests/e2e/devtools.spec.ts @@ -615,6 +615,24 @@ test('copies a report of the whole session', async ({ page, context }) => { expect(report).not.toContain('## Errors'); }); +test('hides updates that only touched unnamed signals', async ({ page }) => { + await page.goto('/'); + // The Loading boundary settles through Solid's own unnamed signals. + await page.locator('#show-async').click(); + await expect(page.locator('#async-posts')).toHaveText('post of user 1'); + + await openTab(page, 'Timeline'); + const rows = page.locator('[data-solid-timeline-row]'); + const toggle = page.locator('[data-solid-timeline-unnamed]'); + await expect(toggle).toContainText(/Show \d+ unnamed update/); + await expect(rows.filter({ hasText: /^update/ })).toHaveCount(0); + await expect(rows.filter({ hasText: 'button#show-async' })).toHaveCount(1); + + await toggle.click(); + await expect(toggle).toContainText(/Hide \d+ unnamed update/); + expect(await rows.count()).toBeGreaterThan(1); +}); + test('copies reports for an agent', async ({ page, context }) => { await context.grantPermissions(['clipboard-read', 'clipboard-write']); const clipboard = () => page.evaluate(() => navigator.clipboard.readText());