Visitar URL original
feat: copy one report of the whole session · solidjs/solid-start-devtools@eb1484b · GitHub
Skip to content

Commit eb1484b

Browse files
lxsmnsycclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 344d916 commit eb1484b

6 files changed

Lines changed: 351 additions & 0 deletions

File tree

‎.changeset/session-report.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@solidjs/start-devtools': minor
3+
---
4+
5+
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.

‎README.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,20 @@ does not include the toolbar.
3838

3939
The same import is safe in development and production entries.
4040

41+
## Copy the whole session
42+
43+
The copy button in the dock header puts one Markdown report of everything the devtools
44+
noticed on the clipboard, for when it is not clear which panel holds the problem.
45+
46+
- The three newest errors in full, with their mapped stacks and source.
47+
- Every issue, with its count and owner.
48+
- The five newest timeline actions, with their writes, re-runs and calls.
49+
- The async records that were flagged, such as a wait with no pending state.
50+
- The server calls that failed.
51+
52+
A section only appears when its panel has something in it. Values with sensitive names are
53+
redacted, as in the reports of each panel.
54+
4155
## Demo
4256

4357
Two demo apps live in `examples`.

‎src/dev-toolbar/index.tsx‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import {
2929
} from './icons.js';
3030
import { AsyncHistory, startAsyncCapture, type AsyncRecord } from './async/tracker.js';
3131
import { addIssues, captureIssues, type IssueEntry } from './issues/tracker.js';
32+
import { CopyReportButton } from './report/CopyReportButton.js';
33+
import { pageRoute } from './report/markdown.js';
3234
import { excludeOwner, includeOwner } from './ownership/registry.js';
3335
import { excludeReactiveOwner, includeReactiveOwner } from './reactivity/registry.js';
3436
import { startTimeline, TimelineBuilder, type TimelineEntry } from './timeline/tracker.js';
@@ -317,6 +319,28 @@ export function DevToolbar(props: DevToolbarProps) {
317319

318320
const callCount = createMemo(() => Object.keys(instances()).length);
319321

322+
async function copySession(): Promise<string> {
323+
// Loaded on use: mapping stacks needs the source-map reader the error panel loads lazily.
324+
const [{ resolveErrorFrames }, { SESSION_ERRORS, sessionReport }] = await Promise.all([
325+
import('./report/error-report.js'),
326+
import('./report/session-report.js'),
327+
]);
328+
const recent = errors().slice(0, SESSION_ERRORS);
329+
const resolved = await Promise.all(
330+
recent.map(async (error) => ({ error, frames: await resolveErrorFrames(error) })),
331+
);
332+
return sessionReport({
333+
errors: resolved,
334+
errorCount: errors().length,
335+
issues: issues(),
336+
timeline: timelineEntries(),
337+
asyncRecords: asyncRecords(),
338+
calls: Object.values(instances()).filter((call) => call !== undefined),
339+
route: pageRoute(),
340+
version,
341+
});
342+
}
343+
320344
const badge = (value: Tab): number => {
321345
if (value === 'err') return errors().length;
322346
if (value === 'issues') return issueCount();
@@ -384,6 +408,7 @@ export function DevToolbar(props: DevToolbarProps) {
384408
{route()}
385409
</span>
386410
<div data-solid-dev-toolbar-spacer />
411+
<CopyReportButton label="the whole session" report={copySession} />
387412
<IconButton onClick={close}>
388413
<MinimizeIcon title="Close Start Devtools" />
389414
</IconButton>
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { AsyncHistory } from '../async/tracker.js';
3+
import type { ServerFunctionInstance } from '../functions/index.js';
4+
import { addIssues } from '../issues/tracker.js';
5+
import { TimelineBuilder } from '../timeline/tracker.js';
6+
import { demote, sessionReport, type SessionReportInput } from './session-report.js';
7+
8+
function input(over: Partial<SessionReportInput> = {}): SessionReportInput {
9+
return {
10+
errors: [],
11+
errorCount: 0,
12+
issues: [],
13+
timeline: [],
14+
asyncRecords: [],
15+
calls: [],
16+
route: '/todos',
17+
version: '1.0.0',
18+
...over,
19+
};
20+
}
21+
22+
describe('demote', () => {
23+
it('moves headings down a level and leaves code blocks alone', () => {
24+
expect(demote('# Title\n\n```\n# not a heading\n```\n## Part')).toBe(
25+
'## Title\n\n```\n# not a heading\n```\n### Part',
26+
);
27+
});
28+
});
29+
30+
describe('sessionReport', () => {
31+
it('says when there is nothing to report', () => {
32+
const report = sessionReport(input());
33+
expect(report).toContain('# Solid devtools session');
34+
expect(report).toContain('- Page: `/todos`');
35+
expect(report).toContain('Nothing to report');
36+
});
37+
38+
it('has a section for each panel with something in it', () => {
39+
const issues = addIssues(
40+
[],
41+
[
42+
{
43+
event: {
44+
sequence: 1,
45+
code: 'STRICT_READ_UNTRACKED',
46+
kind: 'strict-read',
47+
severity: 'warn',
48+
message: '[STRICT_READ_UNTRACKED] Read outside tracking.',
49+
ownerPath: ['<App>', '<Draft>'],
50+
},
51+
subject: undefined,
52+
at: 1,
53+
},
54+
],
55+
);
56+
57+
const timeline = new TimelineBuilder();
58+
const click = { kind: 'interaction' as const, name: 'click', target: 'button#inc', at: 1 };
59+
timeline.rerun(
60+
{
61+
run: 1,
62+
at: 2,
63+
nodeRuns: 1,
64+
nodeKind: 'memo',
65+
nodeName: 'doubled',
66+
nodeId: 1,
67+
causes: [{ seq: 1, kind: 'write', name: 'count', prev: '0', value: '1', origin: click }],
68+
depCount: 1,
69+
depsAdded: [],
70+
depsRemoved: [],
71+
selfMs: 0.2,
72+
totalMs: 0.2,
73+
changed: true,
74+
phase: 'plain',
75+
held: false,
76+
interaction: click,
77+
},
78+
undefined,
79+
);
80+
81+
const history = new AsyncHistory();
82+
history.fallback({ at: 1, shownMs: 80, ownerPath: ['<App>', '<Loading>'] }, undefined);
83+
history.fallback({ at: 1, shownMs: 400 }, undefined);
84+
85+
const calls: ServerFunctionInstance[] = [
86+
{
87+
request: {
88+
type: 'request',
89+
id: 'save',
90+
instance: 'save#1',
91+
source: new Request('http://localhost/_server', { method: 'POST' }),
92+
meta: { name: 'save' },
93+
time: 1,
94+
},
95+
response: {
96+
type: 'response',
97+
id: 'save',
98+
instance: 'save#1',
99+
source: new Response(null, { status: 500 }),
100+
meta: { name: 'save' },
101+
time: 2,
102+
},
103+
},
104+
];
105+
106+
const report = sessionReport(
107+
input({
108+
errors: [{ error: new TypeError('boom'), frames: [] }],
109+
errorCount: 4,
110+
issues,
111+
timeline: timeline.snapshot(),
112+
asyncRecords: history.records,
113+
calls,
114+
}),
115+
);
116+
117+
expect(report).toContain('## Errors (4)');
118+
// The error report sits inside its section, two levels down.
119+
expect(report).toContain('### Error in a Solid app');
120+
expect(report).toContain('TypeError: boom');
121+
expect(report).toContain('… 3 older errors not shown.');
122+
expect(report).toContain(
123+
'## Issues (1)\n\n- warn `STRICT_READ_UNTRACKED` in `<App> > <Draft>`: Read outside tracking.',
124+
);
125+
expect(report).toContain(
126+
'## Recent actions, newest first\n\n- `click on button#inc`, 1 write, 1 re-run',
127+
);
128+
expect(report).toContain(
129+
'## Async concerns (1)\n\n- `fallback at <App> › <Loading>`, 80.0ms: flash',
130+
);
131+
expect(report).toContain('## Failed server calls (1)\n\n- POST `save` → 500');
132+
});
133+
});
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { concernOf, type AsyncRecord } from '../async/tracker.js';
2+
import type { ServerFunctionInstance } from '../functions/index.js';
3+
import { issueText, type IssueEntry } from '../issues/tracker.js';
4+
import type { TimelineEntry } from '../timeline/tracker.js';
5+
import { asyncDuration, asyncOrigin, asyncTitle } from './async-report.js';
6+
import { errorReport, type ReportFrame } from './error-report.js';
7+
import { code } from './markdown.js';
8+
import { redactText, redactUrl } from './redact.js';
9+
import { ms, plural } from './timeline-report.js';
10+
11+
/** Errors the session report gives in full, newest first. The rest are counted. */
12+
export const SESSION_ERRORS = 3;
13+
/** Issues the session report lists. */
14+
export const SESSION_ISSUES = 20;
15+
/** Timeline actions the session report lists, newest first. */
16+
export const SESSION_ACTIONS = 5;
17+
/** Flagged async records the session report lists. */
18+
export const SESSION_ASYNC = 10;
19+
20+
export interface SessionError {
21+
error: unknown;
22+
frames: ReportFrame[];
23+
}
24+
25+
export interface SessionReportInput {
26+
/** Newest first, as the error panel keeps them. */
27+
errors: SessionError[];
28+
/** How many errors there are in all, which can be more than `errors` holds. */
29+
errorCount: number;
30+
issues: IssueEntry[];
31+
timeline: TimelineEntry[];
32+
asyncRecords: AsyncRecord[];
33+
calls: ServerFunctionInstance[];
34+
/** The path and query of the page. */
35+
route: string;
36+
/** The devtools version, so a reader knows what made the report. */
37+
version: string;
38+
}
39+
40+
/**
41+
* Puts a report one heading level lower, so it reads as a section of another
42+
* report. Lines inside code blocks are left alone.
43+
*/
44+
export function demote(markdown: string): string {
45+
let fence: string | undefined;
46+
return markdown
47+
.split('\n')
48+
.map((line) => {
49+
const marks = line.match(/^(`{3,})/)?.[1];
50+
if (marks && (fence === undefined || marks.length >= fence.length)) {
51+
fence = fence === undefined ? marks : undefined;
52+
return line;
53+
}
54+
return fence === undefined && line.startsWith('#') ? `#${line}` : line;
55+
})
56+
.join('\n');
57+
}
58+
59+
function failedCall(instance: ServerFunctionInstance): string | undefined {
60+
const { request, response } = instance;
61+
const name = code(request.meta?.name ?? request.id);
62+
if (response && !response.source.ok) {
63+
return `- ${request.source.method} ${name} → ${response.source.status}`;
64+
}
65+
const error = request.meta?.error;
66+
if (!response && error !== undefined) {
67+
const message = error instanceof Error ? error.message : String(error);
68+
return `- ${request.source.method} ${name} → no response: ${redactText(message)}`;
69+
}
70+
return undefined;
71+
}
72+
73+
function actionLine(entry: TimelineEntry): string {
74+
const parts = [
75+
plural(entry.writes.length, 'write'),
76+
plural(entry.interaction?.runs ?? entry.runs.length + entry.droppedRuns, 're-run'),
77+
];
78+
if (entry.calls.length) parts.push(plural(entry.calls.length, 'call'));
79+
const time = entry.interaction?.settledMs ?? entry.flushMs;
80+
const outcome = entry.interaction?.outcome ? `, ${entry.interaction.outcome}` : '';
81+
const writes = entry.writes.length
82+
? `: ${entry.writes.map((write) => code(write.name)).join(', ')}`
83+
: '';
84+
return `- ${code(entry.label)}, ${parts.join(', ')} in ${ms(time)}${outcome}${writes}`;
85+
}
86+
87+
/**
88+
* One Markdown report of everything the devtools noticed, for an agent to
89+
* read when it is not clear which panel holds the problem. Each panel adds a
90+
* section only when it has something in it.
91+
*/
92+
export function sessionReport(input: SessionReportInput): string {
93+
const out: string[] = ['# Solid devtools session', ''];
94+
out.push(`- Page: ${code(redactUrl(input.route))}`);
95+
out.push(`- Devtools: ${input.version}`);
96+
97+
const failed = input.calls.map(failedCall).filter((line) => line !== undefined);
98+
const flagged = input.asyncRecords.filter((record) => concernOf(record));
99+
const empty =
100+
!input.errorCount &&
101+
!input.issues.length &&
102+
!input.timeline.length &&
103+
!flagged.length &&
104+
!failed.length;
105+
if (empty) {
106+
out.push('', 'Nothing to report: no errors, issues, actions, async concerns or failed calls.');
107+
return out.join('\n');
108+
}
109+
110+
if (input.errorCount) {
111+
out.push('', `## Errors (${input.errorCount})`);
112+
for (const { error, frames } of input.errors.slice(0, SESSION_ERRORS)) {
113+
out.push('', demote(demote(errorReport({ error, frames, route: input.route }))));
114+
}
115+
const rest = input.errorCount - Math.min(input.errors.length, SESSION_ERRORS);
116+
if (rest > 0) out.push('', `… ${plural(rest, 'older error')} not shown.`);
117+
}
118+
119+
if (input.issues.length) {
120+
out.push('', `## Issues (${input.issues.length})`, '');
121+
for (const entry of input.issues.slice(0, SESSION_ISSUES)) {
122+
const { event, count } = entry;
123+
const times = count > 1 ? ` ×${count}` : '';
124+
const owner = event.ownerPath?.length ? ` in ${code(event.ownerPath.join(' > '))}` : '';
125+
out.push(
126+
`- ${event.severity} ${code(event.code)}${times}${owner}: ${redactText(issueText(event))}`,
127+
);
128+
}
129+
const rest = input.issues.length - SESSION_ISSUES;
130+
if (rest > 0) out.push(`- … ${plural(rest, 'more issue')}`);
131+
}
132+
133+
if (input.timeline.length) {
134+
out.push('', '## Recent actions, newest first', '');
135+
for (const entry of input.timeline.slice(0, SESSION_ACTIONS)) out.push(actionLine(entry));
136+
}
137+
138+
if (flagged.length) {
139+
out.push('', `## Async concerns (${flagged.length})`, '');
140+
for (const record of flagged.slice(0, SESSION_ASYNC)) {
141+
const origin = asyncOrigin(record);
142+
out.push(
143+
`- ${code(asyncTitle(record))}, ${ms(asyncDuration(record))}: ${concernOf(record)}${origin ? `, started by ${code(origin)}` : ''}`,
144+
);
145+
}
146+
}
147+
148+
if (failed.length) {
149+
out.push('', `## Failed server calls (${failed.length})`, '');
150+
out.push(...failed);
151+
}
152+
153+
out.push('', 'Each panel of the devtools has a copy button for the full report of one item.');
154+
return out.join('\n');
155+
}

‎tests/e2e/devtools.spec.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,25 @@ test('shows async loads, waits, fallbacks and chains', async ({ page }) => {
539539
await expect(page.locator('[data-solid-reactivity-detail]')).toContainText('user');
540540
});
541541

542+
test('copies a report of the whole session', async ({ page, context }) => {
543+
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
544+
const clipboard = () => page.evaluate(() => navigator.clipboard.readText());
545+
546+
await page.goto('/');
547+
await page.locator('#increment-count').click();
548+
await page.locator('#emit-warning').click();
549+
550+
await openTab(page, 'Errors');
551+
await page.getByRole('button', { name: 'Copy the whole session for an agent' }).click();
552+
await expect.poll(clipboard).toContain('# Solid devtools session');
553+
const report = await clipboard();
554+
expect(report).toContain('## Issues (1)');
555+
expect(report).toContain('`STRICT_READ_UNTRACKED`');
556+
expect(report).toContain('## Recent actions, newest first');
557+
expect(report).toContain('click on button#increment-count');
558+
expect(report).not.toContain('## Errors');
559+
});
560+
542561
test('copies reports for an agent', async ({ page, context }) => {
543562
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
544563
const clipboard = () => page.evaluate(() => navigator.clipboard.readText());

0 commit comments

Comments
 (0)