From 95da448087583ea7a1a141ad902d2eb446ed4338 Mon Sep 17 00:00:00 2001 From: PoffM Date: Sat, 6 Sep 2025 04:31:59 -0400 Subject: [PATCH 01/98] test recorder WIP current behavior: - click the record button, it turns red - on click in web view, selector is generated and sent to the extension process. - extension gets the paused location; todo from here --- .../src/panel-controller/panel-router.ts | 22 +++ .../src/util/debug-session-tracker.ts | 57 ++++--- packages/web-view-vite/package.json | 1 + packages/web-view-vite/src/App.tsx | 3 + .../web-view-vite/src/components/Toolbar.tsx | 16 +- .../web-view-vite/src/inspector/Inspector.tsx | 4 +- .../web-view-vite/src/lib/panel-client.ts | 3 +- .../web-view-vite/src/recorder/recorder.ts | 94 +++++++++++ pnpm-lock.yaml | 148 ++++-------------- 9 files changed, 204 insertions(+), 144 deletions(-) create mode 100644 packages/web-view-vite/src/recorder/recorder.ts diff --git a/packages/extension/src/panel-controller/panel-router.ts b/packages/extension/src/panel-controller/panel-router.ts index de605ba..29b0f60 100644 --- a/packages/extension/src/panel-controller/panel-router.ts +++ b/packages/extension/src/panel-controller/panel-router.ts @@ -173,6 +173,28 @@ export const panelRouter = t.router({ .mutation(async ({ ctx }) => { ctx.storage.set('stylePromptDismissed', false) }), + + recordInputAsCode: t.procedure + .input( + z.object({ + event: z.string(), + query: z.tuple([z.string(), z.array(z.unknown())]), + }), + ) + .mutation(async ({ ctx, input }) => { + const { event, query: [method, args] } = input + const code = `fireEvent.${method}(${args.map(arg => JSON.stringify(arg)).join(', ')})` + + const pausedLocation = await ctx.sessionTracker.getPausedLocation() + + console.log('code is ', code) + console.log('todo generate code at ', pausedLocation) + + // const resultStr = await ctx.sessionTracker.runDebugExpression( + // `globalThis.__recordInputAsCode(${JSON.stringify(method)}, ${JSON.stringify(args)})`, + // ) + // ctx.flushPatches() + }), }) export type PanelRouter = typeof panelRouter diff --git a/packages/extension/src/util/debug-session-tracker.ts b/packages/extension/src/util/debug-session-tracker.ts index 1f75c98..0c8a0ad 100644 --- a/packages/extension/src/util/debug-session-tracker.ts +++ b/packages/extension/src/util/debug-session-tracker.ts @@ -33,25 +33,8 @@ export function startDebugSessionTracker( }, }) - /** - * Find the UI test session based on whichever session is connected to a breakpoint. - * TODO find a better way to do this. - */ - async function getUiTestSession() { - const bps = vscode.debug.breakpoints - for (const session of sessions) { - for (const bp of bps) { - const dbp = await session.getDebugProtocolBreakpoint(bp) - if (dbp && Reflect.get(dbp, 'verified') === true) { - return session - } - } - } - return null - } - async function runDebugExpression(expression: string) { - const uiSession = await getUiTestSession() + const uiSession = vscode.debug.activeDebugSession if (!uiSession) { throw new Error('Internal extension error: Could not find UI test session') } @@ -75,9 +58,45 @@ export function startDebugSessionTracker( return result } + interface DebugPauseLocation { + fileUri: string + lineNumber: number + indent: number + } + + async function getPausedLocation(): Promise { + const session = vscode.debug.activeDebugSession + if (!session) { + return null + } + try { + const response = await session.customRequest('stackTrace', { + threadId: 1, // TODO is this always the right threadId? + startFrame: 0, + levels: 1, + }) + if (response.stackFrames && response.stackFrames.length > 0) { + const frame = response.stackFrames[0] + const fileUri = frame.source?.path + const lineNumber = frame.line + const indent = frame.column - 1 + vscode.window.showInformationMessage(`Stopped at ${fileUri}:${lineNumber}`) + return { fileUri, lineNumber, indent } + } + else { + vscode.window.showInformationMessage('No stack frames') + return null + } + } + catch (error) { + vscode.window.showErrorMessage(`Error: ${error}`) + return null + } + } + return { - getUiTestSession, runDebugExpression, + getPausedLocation, dispose: () => { frameIdTracker.dispose() onChangeActive.dispose() diff --git a/packages/web-view-vite/package.json b/packages/web-view-vite/package.json index 4114f71..88860a1 100644 --- a/packages/web-view-vite/package.json +++ b/packages/web-view-vite/package.json @@ -12,6 +12,7 @@ "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/map": "^0.7.2", "@solid-primitives/mutation-observer": "^1.2.2", + "@testing-library/dom": "^10.4.1", "@trpc/client": "^11.5.0", "@trpc/server": "^11.5.0", "@vscode/webview-ui-toolkit": "^1.4.0", diff --git a/packages/web-view-vite/src/App.tsx b/packages/web-view-vite/src/App.tsx index 06822d0..1f48018 100644 --- a/packages/web-view-vite/src/App.tsx +++ b/packages/web-view-vite/src/App.tsx @@ -6,6 +6,7 @@ import { createInspectorHeight } from './inspector/inspector-height' import { Toolbar } from './components/Toolbar' import { Inspector } from './inspector/Inspector' import { Resizer } from './inspector/Resizer' +import { createRecorder } from './recorder/recorder' // Importing the router type from the server file @@ -38,6 +39,8 @@ export const { export const inspector = createInspectorHeight() +export const recorder = createRecorder(shadowHost) + export function App() { return (
diff --git a/packages/web-view-vite/src/components/Toolbar.tsx b/packages/web-view-vite/src/components/Toolbar.tsx index e6e39ef..9c6f66c 100644 --- a/packages/web-view-vite/src/components/Toolbar.tsx +++ b/packages/web-view-vite/src/components/Toolbar.tsx @@ -2,8 +2,9 @@ import Sun from 'lucide-solid/icons/sun' import Moon from 'lucide-solid/icons/moon' import RefreshCw from 'lucide-solid/icons/refresh-cw' import Code from 'lucide-solid/icons/code' +import Circle from 'lucide-solid/icons/circle' import type { ParentProps } from 'solid-js' -import { firstPatchReceived, inspector, refreshShadow, theme, toggleTheme } from '../App' +import { firstPatchReceived, inspector, recorder, refreshShadow, theme, toggleTheme } from '../App' import { StyleIcon, StylePicker } from './StylePicker' import { Tooltip, TooltipContent, TooltipTrigger } from './solid-ui/tooltip' @@ -42,6 +43,19 @@ export function Toolbar() { )} /> +
+ {/* Spacer */} +
+ recorder.toggle(!recorder.isRecording())} + label={recorder.isRecording() ? 'Stop recording' : '(Experimental) Record input as code'} + > +
+
) } diff --git a/packages/web-view-vite/src/inspector/Inspector.tsx b/packages/web-view-vite/src/inspector/Inspector.tsx index 2201242..45208c1 100644 --- a/packages/web-view-vite/src/inspector/Inspector.tsx +++ b/packages/web-view-vite/src/inspector/Inspector.tsx @@ -2,7 +2,7 @@ import { makeEventListener } from '@solid-primitives/event-listener' import { ReactiveWeakMap } from '@solid-primitives/map' import { createMutationObserver } from '@solid-primitives/mutation-observer' import { Show, createEffect, createSignal, onCleanup, onMount } from 'solid-js' -import { shadowHost } from '../App' +import { recorder, shadowHost } from '../App' import { type InspectedNode, getNewDomTree } from './inspector-dom-tree' import { createInspectorSearch } from './inspector-search' import { SearchToolbar } from './SearchToolbar' @@ -101,7 +101,7 @@ export function Inspector() {
)} - + {(node) => { function newRect() { return node instanceof Text diff --git a/packages/web-view-vite/src/lib/panel-client.ts b/packages/web-view-vite/src/lib/panel-client.ts index 1959f70..f03dfdf 100644 --- a/packages/web-view-vite/src/lib/panel-client.ts +++ b/packages/web-view-vite/src/lib/panel-client.ts @@ -1,8 +1,7 @@ +import { makeEventListener } from '@solid-primitives/event-listener' import type { TRPCLink } from '@trpc/client' import { TRPCClientError, createTRPCProxyClient } from '@trpc/client' import { observable } from '@trpc/server/observable' - -import { makeEventListener } from '@solid-primitives/event-listener' import type { PanelRouter } from '../../../extension/src/panel-controller/panel-router' import { vscode } from './vscode' diff --git a/packages/web-view-vite/src/recorder/recorder.ts b/packages/web-view-vite/src/recorder/recorder.ts new file mode 100644 index 0000000..31469c5 --- /dev/null +++ b/packages/web-view-vite/src/recorder/recorder.ts @@ -0,0 +1,94 @@ +import { createEffect, createSignal } from 'solid-js' +import { createEventListener } from '@solid-primitives/event-listener' +import type { QueryArgs } from '@testing-library/dom' +import { getSuggestedQuery } from '@testing-library/dom' +import { deepElementFromPoint } from '../inspector/util' +import { client } from '../lib/panel-client' +// import { SelectorComputer } from './selector-gen/SelectorComputer' + +export function createRecorder(shadowHost: HTMLDivElement) { + const [isRecording, setIsRecording] = createSignal(false) + + createEffect(() => { + if (isRecording()) { + createEventListener(shadowHost.shadowRoot!, 'click', (e: Event) => { + if (!(e instanceof MouseEvent)) { + return + } + const clickedEl = (shadowHost.shadowRoot && deepElementFromPoint(shadowHost.shadowRoot, e.clientX, e.clientY)) ?? e.target + if (!(clickedEl instanceof Element)) { + return + } + emitEvent('click', clickedEl) + }) + } + }) + + async function emitEvent(type: 'click', target: Element) { + console.log('target', target) + + // TODO Do I need this? + // const selectors = selectorComputer.getSelectors(target) + // console.log('selector', selectors) + + if (target instanceof HTMLElement) { + // Generate the selector + const suggestedQuery = getSuggestedQuery(target) + if (suggestedQuery) { + const queryArgs = serializeQueryArgs(suggestedQuery.queryArgs) + // Send the selector to the extension process to record as code + await client.recordInputAsCode.mutate({ + event: type, + query: [ + suggestedQuery.queryMethod, + queryArgs, + ], + }) + } + } + if (type === 'click') { + console.log('click', target) + } + } + + return { + isRecording, + toggle: (recording: boolean) => { + setIsRecording(recording) + console.log('recording:', recording) + }, + } +} + +// const selectorComputer = new SelectorComputer({ +// getAccessibleName: (node: Node) => { +// if (node instanceof Element) { +// const label = node.getAttribute('aria-label') +// const labelledby = node.getAttribute('aria-labelledby') + +// if (label) { return label } +// if (labelledby) { return document.getElementById(labelledby)?.textContent ?? '' } +// } +// return node.textContent ?? '' +// }, +// getAccessibleRole: (node: Node) => { +// if (node instanceof Element) { +// return node.getAttribute('role') ?? '' +// } +// return '' +// }, +// }) + +export function serializeQueryArgs(queryArgs: QueryArgs): [string, { [key: string]: string | boolean }?] { + const [query, options] = queryArgs + if (!options) { return [query] } + const serializedOptions = Object.entries(options).reduce((prev, curr) => { + const val = curr[1] + if (val !== undefined) { + prev[curr[0]] = String(curr[1]) + } + return prev + }, {} as Record) + + return [query, serializedOptions] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 550f650..258783a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -73,10 +73,10 @@ importers: version: 6.8.0 '@testing-library/react': specifier: ^16.3.0 - version: 16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@18.3.0)(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.3.0)(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@testing-library/user-event': specifier: ^14.6.1 - version: 14.6.1(@testing-library/dom@10.4.0) + version: 14.6.1(@testing-library/dom@10.4.1) '@types/jest': specifier: ^30.0.0 version: 30.0.0 @@ -110,7 +110,7 @@ importers: devDependencies: '@testing-library/react': specifier: ^16.3.0 - version: 16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@18.3.0)(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.3.0)(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@types/jest': specifier: ^30.0.0 version: 30.0.0 @@ -147,7 +147,7 @@ importers: version: 6.8.0 '@testing-library/react': specifier: ^16.3.0 - version: 16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@18.3.0)(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.3.0)(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@types/jest': specifier: ^30.0.0 version: 30.0.0 @@ -184,7 +184,7 @@ importers: version: 7.27.1(@babel/core@7.28.3) '@testing-library/react': specifier: ^16.3.0 - version: 16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@18.3.0)(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.3.0)(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@types/jest': specifier: ^30.0.0 version: 30.0.0 @@ -221,7 +221,7 @@ importers: version: 7.27.1(@babel/core@7.28.3) '@testing-library/react': specifier: ^16.3.0 - version: 16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@18.3.0)(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.3.0)(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@types/jest': specifier: ^30.0.0 version: 30.0.0 @@ -249,7 +249,7 @@ importers: devDependencies: '@testing-library/react': specifier: ^16.3.0 - version: 16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@18.3.0)(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.3.0)(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@types/react': specifier: ^19.1.12 version: 19.1.12 @@ -295,7 +295,7 @@ importers: version: 4.1.12(vite@7.1.2(@types/node@24.3.0)(jiti@2.5.1)(less@4.4.1)(lightningcss@1.30.1)(sass@1.91.0)(stylus@0.64.0)(yaml@2.4.2)) '@testing-library/react': specifier: ^16.3.0 - version: 16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@18.3.0)(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.3.0)(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@types/react': specifier: ^19.1.12 version: 19.1.12 @@ -482,6 +482,9 @@ importers: '@solid-primitives/mutation-observer': specifier: ^1.2.2 version: 1.2.2(solid-js@1.9.9) + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 '@trpc/client': specifier: ^11.5.0 version: 11.5.0(@trpc/server@11.5.0(typescript@5.9.2))(typescript@5.9.2) @@ -672,14 +675,6 @@ packages: resolution: {integrity: sha512-lfZtncCSmKvW31Bh3iUBkeTf+Myt85YsamMkGNZ0ayTO5MirOGBgTa3BgUth0kWFBQuhZIRfi5B95INZ+ppkjw==} engines: {node: '>=16'} - '@babel/code-frame@7.23.5': - resolution: {integrity: sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==} - engines: {node: '>=6.9.0'} - - '@babel/code-frame@7.24.2': - resolution: {integrity: sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ==} - engines: {node: '>=6.9.0'} - '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -845,14 +840,6 @@ packages: resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.22.20': - resolution: {integrity: sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.24.5': - resolution: {integrity: sha512-3q93SSKX2TWCG30M2G2kwaKeTYgEUp5Snjuj8qm729SObL6nbtUldAi37qbxkD5gg3xnBio+f9nqpSepGZMvxA==} - engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.24.6': resolution: {integrity: sha512-4yA7s865JHaqUdRbnaxarZREuPTHrjpDT+pXoAZ1yhyo6uFnIEpS8VMu16siFOHDpZNKYv5BObhsB//ycbICyw==} engines: {node: '>=6.9.0'} @@ -885,14 +872,6 @@ packages: resolution: {integrity: sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==} engines: {node: '>=6.9.0'} - '@babel/highlight@7.23.4': - resolution: {integrity: sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==} - engines: {node: '>=6.9.0'} - - '@babel/highlight@7.24.2': - resolution: {integrity: sha512-Yac1ao4flkTxTteCDZLEvdxg2fZfz1v8M4QpaGypq/WPDqg3ijHYbDfs+LG5hvzSoqaSZ9/Z9lKSP3CjZjv+pA==} - engines: {node: '>=6.9.0'} - '@babel/parser@7.24.1': resolution: {integrity: sha512-Zo9c7N3xdOIQrNip7Lc9wvRPzlRtovHVE4lkz8WEDr7uYh/GMQhSiIgFxGIArRHYdJE5kxtZjAf8rT0xhdLCzg==} engines: {node: '>=6.0.0'} @@ -3528,8 +3507,8 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 - '@testing-library/dom@10.4.0': - resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} '@testing-library/jest-dom@6.8.0': @@ -4093,10 +4072,6 @@ packages: resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} engines: {node: '>=12'} - ansi-styles@3.2.1: - resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} - engines: {node: '>=4'} - ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -4385,10 +4360,6 @@ packages: resolution: {integrity: sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A==} engines: {node: '>=18'} - chalk@2.4.2: - resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} - engines: {node: '>=4'} - chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -4489,16 +4460,10 @@ packages: collect-v8-coverage@1.0.2: resolution: {integrity: sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==} - color-convert@1.9.3: - resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} - color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} - color-name@1.1.3: - resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} - color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} @@ -5378,10 +5343,6 @@ packages: harmony-reflect@1.6.2: resolution: {integrity: sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==} - has-flag@3.0.0: - resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} - engines: {node: '>=4'} - has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -7255,10 +7216,6 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true - supports-color@5.5.0: - resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} - engines: {node: '>=4'} - supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -8166,16 +8123,6 @@ snapshots: jsonwebtoken: 9.0.2 uuid: 8.3.2 - '@babel/code-frame@7.23.5': - dependencies: - '@babel/highlight': 7.23.4 - chalk: 2.4.2 - - '@babel/code-frame@7.24.2': - dependencies: - '@babel/highlight': 7.24.2 - picocolors: 1.1.1 - '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.27.1 @@ -8189,7 +8136,7 @@ snapshots: '@babel/core@7.24.3': dependencies: '@ampproject/remapping': 2.2.1 - '@babel/code-frame': 7.24.2 + '@babel/code-frame': 7.27.1 '@babel/generator': 7.24.1 '@babel/helper-compilation-targets': 7.23.6 '@babel/helper-module-transforms': 7.24.5(@babel/core@7.24.3) @@ -8375,7 +8322,7 @@ snapshots: '@babel/helper-module-imports': 7.24.3 '@babel/helper-simple-access': 7.24.5 '@babel/helper-split-export-declaration': 7.24.5 - '@babel/helper-validator-identifier': 7.24.5 + '@babel/helper-validator-identifier': 7.27.1 '@babel/helper-module-transforms@7.27.3(@babel/core@7.28.0)': dependencies: @@ -8449,10 +8396,6 @@ snapshots: '@babel/helper-string-parser@7.27.1': {} - '@babel/helper-validator-identifier@7.22.20': {} - - '@babel/helper-validator-identifier@7.24.5': {} - '@babel/helper-validator-identifier@7.24.6': {} '@babel/helper-validator-identifier@7.27.1': {} @@ -8487,19 +8430,6 @@ snapshots: '@babel/template': 7.27.2 '@babel/types': 7.28.2 - '@babel/highlight@7.23.4': - dependencies: - '@babel/helper-validator-identifier': 7.22.20 - chalk: 2.4.2 - js-tokens: 4.0.0 - - '@babel/highlight@7.24.2': - dependencies: - '@babel/helper-validator-identifier': 7.22.20 - chalk: 2.4.2 - js-tokens: 4.0.0 - picocolors: 1.1.1 - '@babel/parser@7.24.1': dependencies: '@babel/types': 7.24.5 @@ -9238,13 +9168,13 @@ snapshots: '@babel/template@7.22.15': dependencies: - '@babel/code-frame': 7.23.5 + '@babel/code-frame': 7.27.1 '@babel/parser': 7.24.5 '@babel/types': 7.24.5 '@babel/template@7.24.0': dependencies: - '@babel/code-frame': 7.24.2 + '@babel/code-frame': 7.27.1 '@babel/parser': 7.24.1 '@babel/types': 7.24.0 @@ -9256,7 +9186,7 @@ snapshots: '@babel/traverse@7.24.1': dependencies: - '@babel/code-frame': 7.24.2 + '@babel/code-frame': 7.27.1 '@babel/generator': 7.24.1 '@babel/helper-environment-visitor': 7.22.20 '@babel/helper-function-name': 7.23.0 @@ -9308,13 +9238,13 @@ snapshots: '@babel/types@7.24.0': dependencies: '@babel/helper-string-parser': 7.23.4 - '@babel/helper-validator-identifier': 7.22.20 + '@babel/helper-validator-identifier': 7.27.1 to-fast-properties: 2.0.0 '@babel/types@7.24.5': dependencies: '@babel/helper-string-parser': 7.24.1 - '@babel/helper-validator-identifier': 7.24.5 + '@babel/helper-validator-identifier': 7.27.1 to-fast-properties: 2.0.0 '@babel/types@7.27.6': @@ -11242,15 +11172,15 @@ snapshots: tailwindcss: 4.1.12 vite: 7.1.3(@types/node@24.3.0)(jiti@2.5.1)(less@4.4.1)(lightningcss@1.30.1)(sass@1.91.0)(stylus@0.64.0)(yaml@2.4.2) - '@testing-library/dom@10.4.0': + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.27.1 '@babel/runtime': 7.28.3 '@types/aria-query': 5.0.4 aria-query: 5.3.0 - chalk: 4.1.2 dom-accessibility-api: 0.5.16 lz-string: 1.5.0 + picocolors: 1.1.1 pretty-format: 27.5.1 '@testing-library/jest-dom@6.8.0': @@ -11262,19 +11192,19 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/react@16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@18.3.0)(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + '@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.3.0)(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: '@babel/runtime': 7.28.3 - '@testing-library/dom': 10.4.0 + '@testing-library/dom': 10.4.1 react: 19.1.1 react-dom: 19.1.1(react@19.1.1) optionalDependencies: '@types/react': 19.1.12 '@types/react-dom': 18.3.0 - '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.0)': + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': dependencies: - '@testing-library/dom': 10.4.0 + '@testing-library/dom': 10.4.1 '@textlint/ast-node-types@15.2.0': {} @@ -11913,10 +11843,6 @@ snapshots: ansi-regex@6.1.0: {} - ansi-styles@3.2.1: - dependencies: - color-convert: 1.9.3 - ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 @@ -12273,12 +12199,6 @@ snapshots: loupe: 3.2.0 pathval: 2.0.1 - chalk@2.4.2: - dependencies: - ansi-styles: 3.2.1 - escape-string-regexp: 1.0.5 - supports-color: 5.5.0 - chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -12378,16 +12298,10 @@ snapshots: collect-v8-coverage@1.0.2: {} - color-convert@1.9.3: - dependencies: - color-name: 1.1.3 - color-convert@2.0.1: dependencies: color-name: 1.1.4 - color-name@1.1.3: {} - color-name@1.1.4: {} color-string@1.9.1: @@ -13401,8 +13315,6 @@ snapshots: harmony-reflect@1.6.2: {} - has-flag@3.0.0: {} - has-flag@4.0.0: {} has-property-descriptors@1.0.2: @@ -13950,7 +13862,7 @@ snapshots: jest-message-util@29.7.0: dependencies: - '@babel/code-frame': 7.23.5 + '@babel/code-frame': 7.27.1 '@jest/types': 29.6.3 '@types/stack-utils': 2.0.3 chalk: 4.1.2 @@ -14913,7 +14825,7 @@ snapshots: parse-json@5.2.0: dependencies: - '@babel/code-frame': 7.24.2 + '@babel/code-frame': 7.27.1 error-ex: 1.3.2 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 @@ -15729,10 +15641,6 @@ snapshots: pirates: 4.0.6 ts-interface-checker: 0.1.13 - supports-color@5.5.0: - dependencies: - has-flag: 3.0.0 - supports-color@7.2.0: dependencies: has-flag: 4.0.0 From d8221ae5ef32110d87456bb6dc3f22d11375f770 Mon Sep 17 00:00:00 2001 From: PoffM Date: Sat, 6 Sep 2025 17:26:57 -0400 Subject: [PATCH 02/98] recorder WIP - generated code is inserted before the breakpoint (needs rolling offset) --- .../src/panel-controller/panel-router.ts | 52 +++++++++++++++++-- .../src/util/debug-session-tracker.ts | 6 +-- .../web-view-vite/src/recorder/recorder.ts | 13 ++--- 3 files changed, 57 insertions(+), 14 deletions(-) diff --git a/packages/extension/src/panel-controller/panel-router.ts b/packages/extension/src/panel-controller/panel-router.ts index 29b0f60..b12b486 100644 --- a/packages/extension/src/panel-controller/panel-router.ts +++ b/packages/extension/src/panel-controller/panel-router.ts @@ -178,17 +178,59 @@ export const panelRouter = t.router({ .input( z.object({ event: z.string(), - query: z.tuple([z.string(), z.array(z.unknown())]), + query: z.tuple([ + z.string(), + z.tuple([z.string(), z.optional( + z.record(z.string(), z.union([z.string(), z.boolean()])), + )]), + ]), }), ) .mutation(async ({ ctx, input }) => { - const { event, query: [method, args] } = input - const code = `fireEvent.${method}(${args.map(arg => JSON.stringify(arg)).join(', ')})` + const { event, query: [method, [queryArg0, queryOptions]] } = input + + const parsedQueryOptions = queryOptions && Object.entries(queryOptions).reduce( + (result, entry) => { + const [key, val] = entry + result[key] = typeof val === 'string' ? new RegExp(val) : val + return result + }, + {} as Record, + ) + + const queryArgsStr = (() => { + let result = `'${queryArg0}'` + if (!parsedQueryOptions) { + return result + } + const entries = Object.entries(parsedQueryOptions).map(([key, val]) => { + return `${key}: ${String(val)}` + }) + let optionsStr = entries.join(', ') + if (entries.length > 0) { + optionsStr = `{ ${optionsStr} }` + result += `, ${optionsStr}` + } + return result + })() + + const code = `fireEvent.${event}(${method}(${queryArgsStr}))` const pausedLocation = await ctx.sessionTracker.getPausedLocation() - console.log('code is ', code) - console.log('todo generate code at ', pausedLocation) + if (!pausedLocation) { + return + } + + const editor = vscode.window.visibleTextEditors.find( + editor => editor.document.uri.path === pausedLocation.filePath.toString(), + ) + if (editor && pausedLocation) { + const position = new vscode.Position(pausedLocation.lineNumber - 1, pausedLocation.indent) + await editor.edit((editBuilder) => { + editBuilder.insert(position, `${code}\n`) + }) + } // const resultStr = await ctx.sessionTracker.runDebugExpression( // `globalThis.__recordInputAsCode(${JSON.stringify(method)}, ${JSON.stringify(args)})`, diff --git a/packages/extension/src/util/debug-session-tracker.ts b/packages/extension/src/util/debug-session-tracker.ts index 0c8a0ad..03dba6e 100644 --- a/packages/extension/src/util/debug-session-tracker.ts +++ b/packages/extension/src/util/debug-session-tracker.ts @@ -59,7 +59,7 @@ export function startDebugSessionTracker( } interface DebugPauseLocation { - fileUri: string + filePath: string lineNumber: number indent: number } @@ -80,8 +80,8 @@ export function startDebugSessionTracker( const fileUri = frame.source?.path const lineNumber = frame.line const indent = frame.column - 1 - vscode.window.showInformationMessage(`Stopped at ${fileUri}:${lineNumber}`) - return { fileUri, lineNumber, indent } + const filePath = vscode.Uri.parse(fileUri).fsPath + return { filePath, lineNumber, indent } } else { vscode.window.showInformationMessage('No stack frames') diff --git a/packages/web-view-vite/src/recorder/recorder.ts b/packages/web-view-vite/src/recorder/recorder.ts index 31469c5..2c60fda 100644 --- a/packages/web-view-vite/src/recorder/recorder.ts +++ b/packages/web-view-vite/src/recorder/recorder.ts @@ -35,14 +35,11 @@ export function createRecorder(shadowHost: HTMLDivElement) { // Generate the selector const suggestedQuery = getSuggestedQuery(target) if (suggestedQuery) { - const queryArgs = serializeQueryArgs(suggestedQuery.queryArgs) + const query = serializeQueryArgs(suggestedQuery.queryArgs) // Send the selector to the extension process to record as code await client.recordInputAsCode.mutate({ event: type, - query: [ - suggestedQuery.queryMethod, - queryArgs, - ], + query: [suggestedQuery.queryMethod, query], }) } } @@ -79,13 +76,17 @@ export function createRecorder(shadowHost: HTMLDivElement) { // }, // }) +/** + * Convert the queryArgs from testing-library to JSON to be sent to the extension process. + * Mainly to convert the RegExp to a string before sending it. + */ export function serializeQueryArgs(queryArgs: QueryArgs): [string, { [key: string]: string | boolean }?] { const [query, options] = queryArgs if (!options) { return [query] } const serializedOptions = Object.entries(options).reduce((prev, curr) => { const val = curr[1] if (val !== undefined) { - prev[curr[0]] = String(curr[1]) + prev[curr[0]] = val instanceof RegExp ? String(val) : val } return prev }, {} as Record) From ccbb6a0b4473067810754c59913df6fcdce66592 Mon Sep 17 00:00:00 2001 From: PoffM Date: Sat, 6 Sep 2025 18:45:09 -0400 Subject: [PATCH 03/98] recorder WIP - make test framework/library info available to codegen - refactor codegen into new file --- packages/extension/src/debug-config.ts | 10 +--- packages/extension/src/extension.ts | 29 ++++++--- .../{detect.ts => detect-test-framework.ts} | 6 +- .../framework-support/detect-test-library.ts | 35 +++++++++++ .../src/framework-support/jest-support.ts | 2 +- .../src/panel-controller/panel-controller.ts | 9 ++- .../src/panel-controller/panel-router.ts | 57 +++--------------- .../src/recorder/record-input-as-code.ts | 60 +++++++++++++++++++ .../test/detect-test-framework.test.ts | 2 +- 9 files changed, 138 insertions(+), 72 deletions(-) rename packages/extension/src/framework-support/{detect.ts => detect-test-framework.ts} (96%) create mode 100644 packages/extension/src/framework-support/detect-test-library.ts create mode 100644 packages/extension/src/recorder/record-input-as-code.ts diff --git a/packages/extension/src/debug-config.ts b/packages/extension/src/debug-config.ts index f18e882..5b0adba 100644 --- a/packages/extension/src/debug-config.ts +++ b/packages/extension/src/debug-config.ts @@ -1,23 +1,19 @@ +import { findUp } from 'find-up' import path from 'pathe' import type vscode from 'vscode' -import type { z } from 'zod/mini' -import { findUp } from 'find-up' -import type { zFrameworkSetting } from './extension' -import { detectTestFramework } from './framework-support/detect' +import type { TestFrameworkInfo } from './framework-support/detect-test-framework' import { jestDebugConfig } from './framework-support/jest-support' import { vitestDebugConfig } from './framework-support/vitest-support' const DEBUG_NAME = 'Visually Debug UI' export async function makeDebugConfig( + fwInfo: TestFrameworkInfo, testFile: string, testName: string, - frameworkSetting: z.infer, htmlUpdaterPort: number, testCssFiles?: string[], ) { - const fwInfo = await detectTestFramework(testFile, frameworkSetting) - const pkgPath = await findUp('package.json', { cwd: testFile }) if (!pkgPath) { throw new Error(`Could not find related package.json for test file ${testFile}`) diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 4f92961..baf7af2 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -4,6 +4,7 @@ import { TelemetryReporter } from '@vscode/extension-telemetry' import path from 'pathe' import * as vscode from 'vscode' import { z } from 'zod/mini' +import once from 'lodash/once' import { autoSetFirstBreakpoint } from './auto-set-first-breakpoint' import { codeLensProvider } from './code-lens-provider' import { makeDebugConfig } from './debug-config' @@ -12,6 +13,8 @@ import { startPanelController } from './panel-controller/panel-controller' import { startDebugSessionTracker } from './util/debug-session-tracker' import { extensionSetting } from './util/extension-setting' import { hotReload } from './util/hot-reload' +import { detectTestFramework } from './framework-support/detect-test-framework' +import { detectTestLibrary } from './framework-support/detect-test-library' const reporter = (() => { try { @@ -104,10 +107,26 @@ export let visuallyDebugUI = async ( throw new TypeError(`Expected string argument \"testName\", received ${testName}`) } + const frameworkSetting = (() => { + const parsed = zFrameworkSetting + .safeParse(extensionSetting('ui-test-visualizer.testFramework')) + return parsed.success ? parsed.data : 'autodetect' + })() + + const fwInfo = await detectTestFramework(testFile, frameworkSetting) + // Save the test file before starting the debug session await vscode.window.activeTextEditor?.document.save() - const panelController = await startPanelController(extensionContext, storage) + const testLibraryInfo = once(async () => { + const testingLibrary = await detectTestLibrary(testFile) + return { + framework: fwInfo.framework, + testingLibrary, + } + }) + + const panelController = await startPanelController(extensionContext, storage, testLibraryInfo) const onStartDebug = vscode.debug.onDidStartDebugSession(async (currentSession) => { onStartDebug.dispose() @@ -144,16 +163,10 @@ export let visuallyDebugUI = async ( ) }) - const frameworkSetting = (() => { - const parsed = zFrameworkSetting - .safeParse(extensionSetting('ui-test-visualizer.testFramework')) - return parsed.success ? parsed.data : 'autodetect' - })() - const debugConfig = await makeDebugConfig( + fwInfo, testFile, testName, - frameworkSetting, panelController.htmlUpdaterPort, await storage.get('enabledCssFiles'), ) diff --git a/packages/extension/src/framework-support/detect.ts b/packages/extension/src/framework-support/detect-test-framework.ts similarity index 96% rename from packages/extension/src/framework-support/detect.ts rename to packages/extension/src/framework-support/detect-test-framework.ts index 73f6727..9dbab6e 100644 --- a/packages/extension/src/framework-support/detect.ts +++ b/packages/extension/src/framework-support/detect-test-framework.ts @@ -2,14 +2,16 @@ import path from 'pathe' import { findUp, findUpMultiple } from 'find-up' import { readInitialOptions } from 'jest-config' +export type SupportedFramework = 'vitest' | 'jest' + export interface TestFrameworkInfo { - framework: 'jest' | 'vitest' + framework: SupportedFramework configPath: string } export async function detectTestFramework( testFilePath: string, - frameworkSetting: 'autodetect' | 'vitest' | 'jest', + frameworkSetting: 'autodetect' | SupportedFramework, ): Promise { // auto detect test config files diff --git a/packages/extension/src/framework-support/detect-test-library.ts b/packages/extension/src/framework-support/detect-test-library.ts new file mode 100644 index 0000000..2996c8e --- /dev/null +++ b/packages/extension/src/framework-support/detect-test-library.ts @@ -0,0 +1,35 @@ +import type { SupportedFramework } from './detect-test-framework' + +export const SUPPORTED_TESTING_LIBRARIES = [ + '@solidjs/testing-library', + '@testing-library/react', + '@testing-library/dom', +] as const + +export type TestingLibrary = typeof SUPPORTED_TESTING_LIBRARIES[number] + +export interface TestLibraryInfo { + framework: SupportedFramework + testingLibrary: TestingLibrary | null +} + +export async function detectTestLibrary(testFilePath: string): Promise { + // Lookup the test framework in node_modules + const detectedLibrary = (() => { + for (const testingLibrary of SUPPORTED_TESTING_LIBRARIES) { + const resolved = (() => { + try { + return require.resolve(testingLibrary, { paths: [testFilePath] }) + } + catch { + return undefined + } + })() + if (resolved) { + return testingLibrary + } + } + })() ?? null + + return detectedLibrary +} diff --git a/packages/extension/src/framework-support/jest-support.ts b/packages/extension/src/framework-support/jest-support.ts index e78a72d..062a72c 100644 --- a/packages/extension/src/framework-support/jest-support.ts +++ b/packages/extension/src/framework-support/jest-support.ts @@ -2,7 +2,7 @@ import { findUp, findUpSync } from 'find-up' import { readInitialOptions } from 'jest-config' import path from 'pathe' import type * as vscode from 'vscode' -import type { TestFrameworkInfo } from './detect' +import type { TestFrameworkInfo } from './detect-test-framework' import { cleanTestNameForTerminal } from './util' function buildPath() { diff --git a/packages/extension/src/panel-controller/panel-controller.ts b/packages/extension/src/panel-controller/panel-controller.ts index 908d02e..5949226 100644 --- a/packages/extension/src/panel-controller/panel-controller.ts +++ b/packages/extension/src/panel-controller/panel-controller.ts @@ -1,9 +1,10 @@ -import path from 'pathe' +import { TRPCError, callTRPCProcedure } from '@trpc/server' import getPort from 'get-port' +import path from 'pathe' +import type { HTMLPatch } from 'replicate-dom' import * as vscode from 'vscode' import type { Server as WsServer } from 'ws' -import type { HTMLPatch } from 'replicate-dom' -import { TRPCError, callTRPCProcedure } from '@trpc/server' +import type { TestLibraryInfo } from '../framework-support/detect-test-library' import type { MyStorageType } from '../my-extension-storage' import type { DebugSessionTracker } from '../util/debug-session-tracker' import { type PanelRouterCtx, panelRouter } from './panel-router' @@ -15,6 +16,7 @@ const Server = require('../../node_modules/ws/lib/websocket-server') as typeof W export async function startPanelController( extensionContext: vscode.ExtensionContext, storage: MyStorageType, + testLibraryInfo: () => Promise, ) { const htmlUpdaterPort = await getPort() const viteDevServerPort = 5173 @@ -117,6 +119,7 @@ export async function startPanelController( sessionTracker, storage, flushPatches, + testLibraryInfo, } const result = await callTRPCProcedure({ router: panelRouter, diff --git a/packages/extension/src/panel-controller/panel-router.ts b/packages/extension/src/panel-controller/panel-router.ts index b12b486..0820da1 100644 --- a/packages/extension/src/panel-controller/panel-router.ts +++ b/packages/extension/src/panel-controller/panel-router.ts @@ -1,15 +1,18 @@ +import { initTRPC } from '@trpc/server' import path from 'pathe' import * as vscode from 'vscode' -import { initTRPC } from '@trpc/server' import { z } from 'zod/mini' -import { workspaceCssFiles } from '../util/workspace-css-files' +import type { TestLibraryInfo } from '../framework-support/detect-test-library' import type { MyStorageType } from '../my-extension-storage' import type { DebugSessionTracker } from '../util/debug-session-tracker' +import { workspaceCssFiles } from '../util/workspace-css-files' +import { recordInputAsCode } from '../recorder/record-input-as-code' export interface PanelRouterCtx { sessionTracker: DebugSessionTracker storage: MyStorageType flushPatches: () => void + testLibraryInfo: () => Promise } const t = initTRPC.context().create() @@ -188,54 +191,8 @@ export const panelRouter = t.router({ ) .mutation(async ({ ctx, input }) => { const { event, query: [method, [queryArg0, queryOptions]] } = input - - const parsedQueryOptions = queryOptions && Object.entries(queryOptions).reduce( - (result, entry) => { - const [key, val] = entry - result[key] = typeof val === 'string' ? new RegExp(val) : val - return result - }, - {} as Record, - ) - - const queryArgsStr = (() => { - let result = `'${queryArg0}'` - if (!parsedQueryOptions) { - return result - } - const entries = Object.entries(parsedQueryOptions).map(([key, val]) => { - return `${key}: ${String(val)}` - }) - let optionsStr = entries.join(', ') - if (entries.length > 0) { - optionsStr = `{ ${optionsStr} }` - result += `, ${optionsStr}` - } - return result - })() - - const code = `fireEvent.${event}(${method}(${queryArgsStr}))` - - const pausedLocation = await ctx.sessionTracker.getPausedLocation() - - if (!pausedLocation) { - return - } - - const editor = vscode.window.visibleTextEditors.find( - editor => editor.document.uri.path === pausedLocation.filePath.toString(), - ) - if (editor && pausedLocation) { - const position = new vscode.Position(pausedLocation.lineNumber - 1, pausedLocation.indent) - await editor.edit((editBuilder) => { - editBuilder.insert(position, `${code}\n`) - }) - } - - // const resultStr = await ctx.sessionTracker.runDebugExpression( - // `globalThis.__recordInputAsCode(${JSON.stringify(method)}, ${JSON.stringify(args)})`, - // ) - // ctx.flushPatches() + const testLibraryInfo = await ctx.testLibraryInfo() + await recordInputAsCode(ctx.sessionTracker, testLibraryInfo, event, method, queryArg0, queryOptions) }), }) diff --git a/packages/extension/src/recorder/record-input-as-code.ts b/packages/extension/src/recorder/record-input-as-code.ts new file mode 100644 index 0000000..042a043 --- /dev/null +++ b/packages/extension/src/recorder/record-input-as-code.ts @@ -0,0 +1,60 @@ +import * as vscode from 'vscode' +import type { DebugSessionTracker } from '../util/debug-session-tracker' +import type { TestLibraryInfo } from '../framework-support/detect-test-library' + +export async function recordInputAsCode( + sessionTracker: DebugSessionTracker, + testLibraryInfo: TestLibraryInfo, + event: string, + method: string, + queryArg0: string, + queryOptions: Record | undefined, +) { + const parsedQueryOptions = queryOptions && Object.entries(queryOptions).reduce( + (result, entry) => { + const [key, val] = entry + result[key] = typeof val === 'string' ? new RegExp(val) : val + return result + }, + {} as Record, + ) + + const queryArgsStr = (() => { + let result = `'${queryArg0}'` + if (!parsedQueryOptions) { + return result + } + const entries = Object.entries(parsedQueryOptions).map(([key, val]) => { + return `${key}: ${String(val)}` + }) + let optionsStr = entries.join(', ') + if (entries.length > 0) { + optionsStr = `{ ${optionsStr} }` + result += `, ${optionsStr}` + } + return result + })() + + const code = `fireEvent.${event}(${method}(${queryArgsStr}))` + + const pausedLocation = await sessionTracker.getPausedLocation() + + if (!pausedLocation) { + return + } + + const editor = vscode.window.visibleTextEditors.find( + editor => editor.document.uri.path === pausedLocation.filePath.toString(), + ) + if (editor && pausedLocation) { + const position = new vscode.Position(pausedLocation.lineNumber - 1, pausedLocation.indent) + await editor.edit((editBuilder) => { + editBuilder.insert(position, `${code}\n`) + }) + } + + // const resultStr = await ctx.sessionTracker.runDebugExpression( + // `globalThis.__recordInputAsCode(${JSON.stringify(method)}, ${JSON.stringify(args)})`, + // ) + // ctx.flushPatches() +} diff --git a/packages/extension/test/detect-test-framework.test.ts b/packages/extension/test/detect-test-framework.test.ts index 7e87d0d..41534b8 100644 --- a/packages/extension/test/detect-test-framework.test.ts +++ b/packages/extension/test/detect-test-framework.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import path from 'pathe' -import { detectTestFramework } from '../src/framework-support/detect' +import { detectTestFramework } from '../src/framework-support/detect-test-framework' const examplesPath = path.join(__dirname, '../../../examples') From 6be81a81fdfa697ad71e2008d5ef085019f6f925 Mon Sep 17 00:00:00 2001 From: PoffM Date: Sat, 6 Sep 2025 20:10:29 -0400 Subject: [PATCH 04/98] recorder WIP - setup recorder state retained during debug run --- packages/extension/src/extension.ts | 12 ++--- .../src/panel-controller/panel-controller.ts | 5 +- .../src/panel-controller/panel-router.ts | 8 ++-- .../src/recorder/record-input-as-code.ts | 47 ++++++++++++++----- .../src/util/debug-session-tracker.ts | 4 +- 5 files changed, 46 insertions(+), 30 deletions(-) diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index baf7af2..6251749 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -15,6 +15,7 @@ import { extensionSetting } from './util/extension-setting' import { hotReload } from './util/hot-reload' import { detectTestFramework } from './framework-support/detect-test-framework' import { detectTestLibrary } from './framework-support/detect-test-library' +import { initRecorderState } from './recorder/record-input-as-code' const reporter = (() => { try { @@ -118,15 +119,10 @@ export let visuallyDebugUI = async ( // Save the test file before starting the debug session await vscode.window.activeTextEditor?.document.save() - const testLibraryInfo = once(async () => { - const testingLibrary = await detectTestLibrary(testFile) - return { - framework: fwInfo.framework, - testingLibrary, - } - }) + // Only initialize the recorder state once per debug session + const recorderState = once(() => initRecorderState(testFile, fwInfo.framework)) - const panelController = await startPanelController(extensionContext, storage, testLibraryInfo) + const panelController = await startPanelController(extensionContext, storage, recorderState) const onStartDebug = vscode.debug.onDidStartDebugSession(async (currentSession) => { onStartDebug.dispose() diff --git a/packages/extension/src/panel-controller/panel-controller.ts b/packages/extension/src/panel-controller/panel-controller.ts index 5949226..2b14cce 100644 --- a/packages/extension/src/panel-controller/panel-controller.ts +++ b/packages/extension/src/panel-controller/panel-controller.ts @@ -7,6 +7,7 @@ import type { Server as WsServer } from 'ws' import type { TestLibraryInfo } from '../framework-support/detect-test-library' import type { MyStorageType } from '../my-extension-storage' import type { DebugSessionTracker } from '../util/debug-session-tracker' +import type { RecorderState } from '../recorder/record-input-as-code' import { type PanelRouterCtx, panelRouter } from './panel-router' // Avoids import errors when importing in Vitest @@ -16,7 +17,7 @@ const Server = require('../../node_modules/ws/lib/websocket-server') as typeof W export async function startPanelController( extensionContext: vscode.ExtensionContext, storage: MyStorageType, - testLibraryInfo: () => Promise, + recorderState: () => Promise, ) { const htmlUpdaterPort = await getPort() const viteDevServerPort = 5173 @@ -119,7 +120,7 @@ export async function startPanelController( sessionTracker, storage, flushPatches, - testLibraryInfo, + recorderState, } const result = await callTRPCProcedure({ router: panelRouter, diff --git a/packages/extension/src/panel-controller/panel-router.ts b/packages/extension/src/panel-controller/panel-router.ts index 0820da1..aff2c64 100644 --- a/packages/extension/src/panel-controller/panel-router.ts +++ b/packages/extension/src/panel-controller/panel-router.ts @@ -2,17 +2,17 @@ import { initTRPC } from '@trpc/server' import path from 'pathe' import * as vscode from 'vscode' import { z } from 'zod/mini' -import type { TestLibraryInfo } from '../framework-support/detect-test-library' import type { MyStorageType } from '../my-extension-storage' import type { DebugSessionTracker } from '../util/debug-session-tracker' import { workspaceCssFiles } from '../util/workspace-css-files' +import type { RecorderState } from '../recorder/record-input-as-code' import { recordInputAsCode } from '../recorder/record-input-as-code' export interface PanelRouterCtx { sessionTracker: DebugSessionTracker storage: MyStorageType flushPatches: () => void - testLibraryInfo: () => Promise + recorderState: () => Promise } const t = initTRPC.context().create() @@ -191,8 +191,8 @@ export const panelRouter = t.router({ ) .mutation(async ({ ctx, input }) => { const { event, query: [method, [queryArg0, queryOptions]] } = input - const testLibraryInfo = await ctx.testLibraryInfo() - await recordInputAsCode(ctx.sessionTracker, testLibraryInfo, event, method, queryArg0, queryOptions) + const recorderState = await ctx.recorderState() + await recordInputAsCode(ctx.sessionTracker, recorderState, event, method, queryArg0, queryOptions) }), }) diff --git a/packages/extension/src/recorder/record-input-as-code.ts b/packages/extension/src/recorder/record-input-as-code.ts index 042a043..da92725 100644 --- a/packages/extension/src/recorder/record-input-as-code.ts +++ b/packages/extension/src/recorder/record-input-as-code.ts @@ -1,15 +1,40 @@ import * as vscode from 'vscode' +import type { SupportedFramework } from '../framework-support/detect-test-framework' +import { detectTestLibrary } from '../framework-support/detect-test-library' import type { DebugSessionTracker } from '../util/debug-session-tracker' -import type { TestLibraryInfo } from '../framework-support/detect-test-library' + +export type RecorderState = Awaited> + +export async function initRecorderState(testFile: string, framework: SupportedFramework) { + return { + testLibraryInfo: { + framework, + testingLibrary: await detectTestLibrary(testFile), + }, + offset: 0, + } +} export async function recordInputAsCode( sessionTracker: DebugSessionTracker, - testLibraryInfo: TestLibraryInfo, + state: RecorderState, event: string, - method: string, + findMethod: string, queryArg0: string, queryOptions: Record | undefined, ) { + const pausedLocation = await sessionTracker.getPausedLocation() + if (!pausedLocation) { + return + } + + const editor = vscode.window.visibleTextEditors.find( + editor => editor.document.uri.path === pausedLocation.filePath.toString(), + ) + if (!editor) { + return + } + const parsedQueryOptions = queryOptions && Object.entries(queryOptions).reduce( (result, entry) => { const [key, val] = entry @@ -35,21 +60,17 @@ export async function recordInputAsCode( return result })() - const code = `fireEvent.${event}(${method}(${queryArgsStr}))` - - const pausedLocation = await sessionTracker.getPausedLocation() + let code = `fireEvent.${event}(${findMethod}(${queryArgsStr}))` - if (!pausedLocation) { - return - } + const line = editor.document.lineAt(pausedLocation.lineNumber - 1) + const indent = line.text.match(/^\s*/)?.[0] || '' + code = `${indent}${code}` - const editor = vscode.window.visibleTextEditors.find( - editor => editor.document.uri.path === pausedLocation.filePath.toString(), - ) if (editor && pausedLocation) { - const position = new vscode.Position(pausedLocation.lineNumber - 1, pausedLocation.indent) + const position = new vscode.Position(pausedLocation.lineNumber - 1 + state.offset, 0) await editor.edit((editBuilder) => { editBuilder.insert(position, `${code}\n`) + state.offset += 1 }) } diff --git a/packages/extension/src/util/debug-session-tracker.ts b/packages/extension/src/util/debug-session-tracker.ts index 03dba6e..960bdd0 100644 --- a/packages/extension/src/util/debug-session-tracker.ts +++ b/packages/extension/src/util/debug-session-tracker.ts @@ -61,7 +61,6 @@ export function startDebugSessionTracker( interface DebugPauseLocation { filePath: string lineNumber: number - indent: number } async function getPausedLocation(): Promise { @@ -79,9 +78,8 @@ export function startDebugSessionTracker( const frame = response.stackFrames[0] const fileUri = frame.source?.path const lineNumber = frame.line - const indent = frame.column - 1 const filePath = vscode.Uri.parse(fileUri).fsPath - return { filePath, lineNumber, indent } + return { filePath, lineNumber } } else { vscode.window.showInformationMessage('No stack frames') From 9e75480690391b8d24e7dca6cf29f213d728b403 Mon Sep 17 00:00:00 2001 From: PoffM Date: Sat, 6 Sep 2025 20:34:48 -0400 Subject: [PATCH 05/98] fix tests --- packages/extension/test/compat.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/extension/test/compat.test.ts b/packages/extension/test/compat.test.ts index 1170a14..4f8daa7 100644 --- a/packages/extension/test/compat.test.ts +++ b/packages/extension/test/compat.test.ts @@ -5,6 +5,7 @@ import path from 'pathe' import { beforeAll, describe, expect, it } from 'vitest' import type { Server as WsServer } from 'ws' import { makeDebugConfig } from '../src/debug-config' +import { detectTestFramework } from '../src/framework-support/detect-test-framework' describe('tool compatibility', async () => { it('works with Jest + SWC + Nextjs', async () => { @@ -105,10 +106,11 @@ describe('tool compatibility', async () => { testFile: string, testName: string, ) { + const fwInfo = await detectTestFramework(path.join(examplesPath, testFile), 'autodetect') const cfg = await makeDebugConfig( + fwInfo, path.join(examplesPath, testFile), testName, - 'autodetect', htmlUpdaterPort, [], ) From 4d761eed887d311e8cd79207f46860e949c78e27 Mon Sep 17 00:00:00 2001 From: PoffM Date: Mon, 8 Sep 2025 01:38:55 -0400 Subject: [PATCH 06/98] fix type error --- packages/extension/src/recorder/record-input-as-code.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/extension/src/recorder/record-input-as-code.ts b/packages/extension/src/recorder/record-input-as-code.ts index da92725..2f4829b 100644 --- a/packages/extension/src/recorder/record-input-as-code.ts +++ b/packages/extension/src/recorder/record-input-as-code.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode' import type { SupportedFramework } from '../framework-support/detect-test-framework' import { detectTestLibrary } from '../framework-support/detect-test-library' -import type { DebugSessionTracker } from '../util/debug-session-tracker' +import type { DebuggerTracker } from '../util/debugger-tracker' export type RecorderState = Awaited> @@ -16,7 +16,7 @@ export async function initRecorderState(testFile: string, framework: SupportedFr } export async function recordInputAsCode( - sessionTracker: DebugSessionTracker, + sessionTracker: DebuggerTracker, state: RecorderState, event: string, findMethod: string, From 1b8c4332ae300908d6ed299497e5c2595903949d Mon Sep 17 00:00:00 2001 From: PoffM Date: Thu, 11 Sep 2025 00:48:35 -0400 Subject: [PATCH 07/98] recorder WIP - Clean up back-end recorder setup code - Improve selector generation in front end code --- packages/extension/src/extension.ts | 13 +- .../src/panel-controller/panel-controller.ts | 8 +- .../src/panel-controller/panel-router.ts | 16 +- .../src/recorder/record-input-as-code.ts | 152 ++++++++++-------- .../web-view-vite/src/recorder/recorder.ts | 82 +++++----- 5 files changed, 145 insertions(+), 126 deletions(-) diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 64b0dd5..0b91214 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -1,21 +1,20 @@ import '@total-typescript/ts-reset' import { TelemetryReporter } from '@vscode/extension-telemetry' +import once from 'lodash/once' import path from 'pathe' import * as vscode from 'vscode' import { z } from 'zod/mini' -import once from 'lodash/once' import { autoSetFirstBreakpoint } from './auto-set-first-breakpoint' import { codeLensProvider } from './code-lens-provider' import { makeDebugConfig } from './debug-config' +import { detectTestFramework } from './framework-support/detect-test-framework' import { myExtensionStorage } from './my-extension-storage' import { startPanelController } from './panel-controller/panel-controller' +import { startRecorderCodeGenSession } from './recorder/record-input-as-code' import { startDebuggerTracker } from './util/debugger-tracker' import { extensionSetting } from './util/extension-setting' import { hotReload } from './util/hot-reload' -import { detectTestFramework } from './framework-support/detect-test-framework' -import { detectTestLibrary } from './framework-support/detect-test-library' -import { initRecorderState } from './recorder/record-input-as-code' const reporter = (() => { try { @@ -120,14 +119,14 @@ export let visuallyDebugUI = async ( await vscode.window.activeTextEditor?.document.save() // Only initialize the recorder state once per debug session - const recorderState = once(() => initRecorderState(testFile, fwInfo.framework)) + const recorderCodeGenSession = once(() => startRecorderCodeGenSession(testFile, fwInfo.framework)) - const panelController = await startPanelController(extensionContext, storage, recorderState) + const panelController = await startPanelController(extensionContext, storage, recorderCodeGenSession) const onStartDebug = vscode.debug.onDidStartDebugSession(async (currentSession) => { onStartDebug.dispose() - const sessionTracker = await startDebuggerTracker( + const sessionTracker = startDebuggerTracker( currentSession, { onFrameChange: () => panelController.flushPatches(), diff --git a/packages/extension/src/panel-controller/panel-controller.ts b/packages/extension/src/panel-controller/panel-controller.ts index d420f03..ddba5c7 100644 --- a/packages/extension/src/panel-controller/panel-controller.ts +++ b/packages/extension/src/panel-controller/panel-controller.ts @@ -5,7 +5,7 @@ import type { HTMLPatch } from 'replicate-dom' import * as vscode from 'vscode' import type { Server as WsServer } from 'ws' import type { MyStorageType } from '../my-extension-storage' -import type { RecorderState } from '../recorder/record-input-as-code' +import type { RecorderCodeGenSession } from '../recorder/record-input-as-code' import type { DebuggerTracker } from '../util/debugger-tracker' import { type PanelRouterCtx, panelRouter } from './panel-router' @@ -16,7 +16,7 @@ const Server = require('../../node_modules/ws/lib/websocket-server') as typeof W export async function startPanelController( extensionContext: vscode.ExtensionContext, storage: MyStorageType, - recorderState: () => Promise, + recorderCodeGenSession: () => Promise, ) { const htmlUpdaterPort = await getPort() const viteDevServerPort = 5173 @@ -115,7 +115,7 @@ export async function startPanelController( return prodHtml } - throw new Error('Unknown NODE_ENV') + throw new Error(`Unknown NODE_ENV ${process.env.NODE_ENV}`) })() panel.webview.html = html @@ -128,7 +128,7 @@ export async function startPanelController( sessionTracker, storage, flushPatches, - recorderState, + recorderCodeGenSession, } const result = await callTRPCProcedure({ router: panelRouter, diff --git a/packages/extension/src/panel-controller/panel-router.ts b/packages/extension/src/panel-controller/panel-router.ts index cb6e13a..68df437 100644 --- a/packages/extension/src/panel-controller/panel-router.ts +++ b/packages/extension/src/panel-controller/panel-router.ts @@ -3,8 +3,7 @@ import path from 'pathe' import * as vscode from 'vscode' import { z } from 'zod/mini' import type { MyStorageType } from '../my-extension-storage' -import type { RecorderState } from '../recorder/record-input-as-code' -import { recordInputAsCode } from '../recorder/record-input-as-code' +import { type RecorderCodeGenSession, zSerializedRegexp } from '../recorder/record-input-as-code' import type { DebuggerTracker } from '../util/debugger-tracker' import { workspaceCssFiles } from '../util/workspace-css-files' @@ -12,7 +11,7 @@ export interface PanelRouterCtx { sessionTracker: DebuggerTracker storage: MyStorageType flushPatches: () => void - recorderState: () => Promise + recorderCodeGenSession: () => Promise } const t = initTRPC.context().create() @@ -183,16 +182,17 @@ export const panelRouter = t.router({ event: z.string(), query: z.tuple([ z.string(), - z.tuple([z.string(), z.optional( - z.record(z.string(), z.union([z.string(), z.boolean()])), - )]), + z.tuple([ + z.union([z.string(), zSerializedRegexp]), + z.optional(z.record(z.string(), z.union([z.string(), z.boolean(), zSerializedRegexp]))), + ]), ]), }), ) .mutation(async ({ ctx, input }) => { const { event, query: [method, [queryArg0, queryOptions]] } = input - const recorderState = await ctx.recorderState() - await recordInputAsCode(ctx.sessionTracker, recorderState, event, method, queryArg0, queryOptions) + const recorderCodeGenSession = await ctx.recorderCodeGenSession() + await recorderCodeGenSession.recordInputAsCode(ctx.sessionTracker, event, method, queryArg0, queryOptions) }), }) diff --git a/packages/extension/src/recorder/record-input-as-code.ts b/packages/extension/src/recorder/record-input-as-code.ts index 2f4829b..8bc6036 100644 --- a/packages/extension/src/recorder/record-input-as-code.ts +++ b/packages/extension/src/recorder/record-input-as-code.ts @@ -1,81 +1,103 @@ import * as vscode from 'vscode' +import { z } from 'zod/mini' import type { SupportedFramework } from '../framework-support/detect-test-framework' import { detectTestLibrary } from '../framework-support/detect-test-library' import type { DebuggerTracker } from '../util/debugger-tracker' -export type RecorderState = Awaited> +export type RecorderCodeGenSession = Awaited> -export async function initRecorderState(testFile: string, framework: SupportedFramework) { - return { - testLibraryInfo: { - framework, - testingLibrary: await detectTestLibrary(testFile), - }, - offset: 0, - } -} +export type SerializedRegexp = z.infer +export const zSerializedRegexp = z.object({ + type: z.literal('regexp'), + value: z.string(), +}) -export async function recordInputAsCode( - sessionTracker: DebuggerTracker, - state: RecorderState, - event: string, - findMethod: string, - queryArg0: string, - queryOptions: Record | undefined, +export async function startRecorderCodeGenSession( + testFile: string, + testFramework: SupportedFramework, ) { - const pausedLocation = await sessionTracker.getPausedLocation() - if (!pausedLocation) { - return - } + const testLibrary = await detectTestLibrary(testFile) - const editor = vscode.window.visibleTextEditors.find( - editor => editor.document.uri.path === pausedLocation.filePath.toString(), - ) - if (!editor) { - return - } + let offset = 0 - const parsedQueryOptions = queryOptions && Object.entries(queryOptions).reduce( - (result, entry) => { - const [key, val] = entry - result[key] = typeof val === 'string' ? new RegExp(val) : val - return result - }, - {} as Record, - ) + const codeGenSession = { + recordInputAsCode: async ( + sessionTracker: DebuggerTracker, + event: string, + findMethod: string, + queryArg0: string | SerializedRegexp, + queryOptions: Record | undefined, + ) => { + const pausedLocation = await sessionTracker.getPausedLocation() + if (!pausedLocation) { + return + } - const queryArgsStr = (() => { - let result = `'${queryArg0}'` - if (!parsedQueryOptions) { - return result - } - const entries = Object.entries(parsedQueryOptions).map(([key, val]) => { - return `${key}: ${String(val)}` - }) - let optionsStr = entries.join(', ') - if (entries.length > 0) { - optionsStr = `{ ${optionsStr} }` - result += `, ${optionsStr}` - } - return result - })() + const editor = vscode.window.visibleTextEditors.find( + editor => editor.document.uri.path === pausedLocation.filePath.toString(), + ) + if (!editor) { + return + } - let code = `fireEvent.${event}(${findMethod}(${queryArgsStr}))` + const parsedQueryOptions = queryOptions && Object.entries(queryOptions).reduce( + (result, entry) => { + const [key, val] = entry + result[key] = (() => { + if (typeof val === 'string') { + return new RegExp(val) + } + else if (typeof val === 'boolean') { + return val + } + else if (val.type === 'regexp') { + return new RegExp(val.value) + } + else { + return '' + } + })() - const line = editor.document.lineAt(pausedLocation.lineNumber - 1) - const indent = line.text.match(/^\s*/)?.[0] || '' - code = `${indent}${code}` + return result + }, + {} as Record, + ) - if (editor && pausedLocation) { - const position = new vscode.Position(pausedLocation.lineNumber - 1 + state.offset, 0) - await editor.edit((editBuilder) => { - editBuilder.insert(position, `${code}\n`) - state.offset += 1 - }) - } + const queryArgsStr = (() => { + let result = typeof queryArg0 === 'string' ? `'${queryArg0}'` : queryArg0.value + if (!parsedQueryOptions) { + return result + } + const entries = Object.entries(parsedQueryOptions).map(([key, val]) => { + return `${key}: ${String(val)}` + }) + let optionsStr = entries.join(', ') + if (entries.length > 0) { + optionsStr = `{ ${optionsStr} }` + result += `, ${optionsStr}` + } + return result + })() + + let code = `fireEvent.${event}(${findMethod}(${queryArgsStr}))` + + const line = editor.document.lineAt(pausedLocation.lineNumber - 1) + const indent = line.text.match(/^\s*/)?.[0] || '' + code = `${indent}${code}` - // const resultStr = await ctx.sessionTracker.runDebugExpression( - // `globalThis.__recordInputAsCode(${JSON.stringify(method)}, ${JSON.stringify(args)})`, - // ) - // ctx.flushPatches() + if (editor && pausedLocation) { + const position = new vscode.Position(pausedLocation.lineNumber - 1 + offset, 0) + await editor.edit((editBuilder) => { + editBuilder.insert(position, `${code}\n`) + offset += 1 + }) + } + + // const resultStr = await ctx.sessionTracker.runDebugExpression( + // `globalThis.__recordInputAsCode(${JSON.stringify(method)}, ${JSON.stringify(args)})`, + // ) + // ctx.flushPatches() + }, + } + return codeGenSession } diff --git a/packages/web-view-vite/src/recorder/recorder.ts b/packages/web-view-vite/src/recorder/recorder.ts index 2c60fda..28cc876 100644 --- a/packages/web-view-vite/src/recorder/recorder.ts +++ b/packages/web-view-vite/src/recorder/recorder.ts @@ -1,10 +1,9 @@ import { createEffect, createSignal } from 'solid-js' import { createEventListener } from '@solid-primitives/event-listener' -import type { QueryArgs } from '@testing-library/dom' +import type { QueryArgs, Suggestion } from '@testing-library/dom' import { getSuggestedQuery } from '@testing-library/dom' import { deepElementFromPoint } from '../inspector/util' import { client } from '../lib/panel-client' -// import { SelectorComputer } from './selector-gen/SelectorComputer' export function createRecorder(shadowHost: HTMLDivElement) { const [isRecording, setIsRecording] = createSignal(false) @@ -25,27 +24,35 @@ export function createRecorder(shadowHost: HTMLDivElement) { }) async function emitEvent(type: 'click', target: Element) { - console.log('target', target) + let suggestedQuery: Suggestion | undefined - // TODO Do I need this? - // const selectors = selectorComputer.getSelectors(target) - // console.log('selector', selectors) - - if (target instanceof HTMLElement) { - // Generate the selector - const suggestedQuery = getSuggestedQuery(target) - if (suggestedQuery) { - const query = serializeQueryArgs(suggestedQuery.queryArgs) - // Send the selector to the extension process to record as code - await client.recordInputAsCode.mutate({ - event: type, - query: [suggestedQuery.queryMethod, query], - }) + // Generate the selector using the closest HTMLElement. + // e.g. if you click on an SVG, that doesn't count as an HTMLElement, + // so step up to the parent. + while (!suggestedQuery && target) { + if (target instanceof HTMLElement) { + suggestedQuery = getSuggestedQuery(target) + } + if (!suggestedQuery) { + if (target.parentElement) { + target = target.parentElement + } + else { + return + } } } - if (type === 'click') { - console.log('click', target) + + if (!suggestedQuery) { + return } + + const query = serializeQueryArgs(suggestedQuery.queryArgs) + // Send the selector to the extension process to record as code + await client.recordInputAsCode.mutate({ + event: type, + query: [suggestedQuery.queryMethod, query], + }) } return { @@ -57,39 +64,30 @@ export function createRecorder(shadowHost: HTMLDivElement) { } } -// const selectorComputer = new SelectorComputer({ -// getAccessibleName: (node: Node) => { -// if (node instanceof Element) { -// const label = node.getAttribute('aria-label') -// const labelledby = node.getAttribute('aria-labelledby') - -// if (label) { return label } -// if (labelledby) { return document.getElementById(labelledby)?.textContent ?? '' } -// } -// return node.textContent ?? '' -// }, -// getAccessibleRole: (node: Node) => { -// if (node instanceof Element) { -// return node.getAttribute('role') ?? '' -// } -// return '' -// }, -// }) - /** * Convert the queryArgs from testing-library to JSON to be sent to the extension process. * Mainly to convert the RegExp to a string before sending it. */ -export function serializeQueryArgs(queryArgs: QueryArgs): [string, { [key: string]: string | boolean }?] { +export function serializeQueryArgs(queryArgs: QueryArgs): [string | SerializedRegexp, { [key: string]: string | boolean | SerializedRegexp }?] { const [query, options] = queryArgs - if (!options) { return [query] } + if (!options) { + // @ts-expect-error Not declared the testing library, but the query could be a RegExp: + const result = query instanceof RegExp ? serializeRegexp(query) : query + return [result] + } const serializedOptions = Object.entries(options).reduce((prev, curr) => { const val = curr[1] if (val !== undefined) { - prev[curr[0]] = val instanceof RegExp ? String(val) : val + prev[curr[0]] = val instanceof RegExp ? serializeRegexp(val) : val } return prev - }, {} as Record) + }, {} as Record) return [query, serializedOptions] } + +export interface SerializedRegexp { type: 'regexp', value: string } + +function serializeRegexp(regexp: RegExp): SerializedRegexp { + return { type: 'regexp', value: regexp.toString() } +} From b06fb15c83b0473596b16fef20c89da887016ac8 Mon Sep 17 00:00:00 2001 From: PoffM Date: Thu, 11 Sep 2025 03:00:47 -0400 Subject: [PATCH 08/98] recorder WIP handle more input event types (front end) --- .../components/FormExample.tsx | 60 +++++++++++++++++++ .../vitest-react-tailwind4/test/form.test.tsx | 13 ++++ .../web-view-vite/src/recorder/recorder.ts | 35 +++++++---- 3 files changed, 96 insertions(+), 12 deletions(-) create mode 100644 examples/vitest-react-tailwind4/components/FormExample.tsx create mode 100644 examples/vitest-react-tailwind4/test/form.test.tsx diff --git a/examples/vitest-react-tailwind4/components/FormExample.tsx b/examples/vitest-react-tailwind4/components/FormExample.tsx new file mode 100644 index 0000000..930c1e9 --- /dev/null +++ b/examples/vitest-react-tailwind4/components/FormExample.tsx @@ -0,0 +1,60 @@ +import React from 'react' + +const FormExample: React.FC = () => { + return ( +
+

Form Section

+
+
e.preventDefault()}> + + + +
+
+

Non-Form Section

+
+ + + +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. +

+
+
+ ) +} + +export default FormExample diff --git a/examples/vitest-react-tailwind4/test/form.test.tsx b/examples/vitest-react-tailwind4/test/form.test.tsx new file mode 100644 index 0000000..85ed3e1 --- /dev/null +++ b/examples/vitest-react-tailwind4/test/form.test.tsx @@ -0,0 +1,13 @@ +import React from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import FormExample from '../components/FormExample' + +it('renders FormExample with form and non-form sections', async () => { + render() + + // Fill form inputs and submit + fireEvent.change(screen.getByLabelText('First input'), { target: { value: 'Test Value 1' } }) + fireEvent.change(screen.getByPlaceholderText('Second input'), { target: { value: 'Test Value 2' } }) + fireEvent.click(screen.getByText('Submit')) + fireEvent.change(screen.getByPlaceholderText('Second input'), { target: { value: 'Test Value 2' } }) +}) diff --git a/packages/web-view-vite/src/recorder/recorder.ts b/packages/web-view-vite/src/recorder/recorder.ts index 28cc876..31b6fc7 100644 --- a/packages/web-view-vite/src/recorder/recorder.ts +++ b/packages/web-view-vite/src/recorder/recorder.ts @@ -1,29 +1,40 @@ import { createEffect, createSignal } from 'solid-js' -import { createEventListener } from '@solid-primitives/event-listener' +import { makeEventListener } from '@solid-primitives/event-listener' import type { QueryArgs, Suggestion } from '@testing-library/dom' import { getSuggestedQuery } from '@testing-library/dom' import { deepElementFromPoint } from '../inspector/util' import { client } from '../lib/panel-client' +export type InputEventType = 'click' | 'input' | 'submit' | 'focus' | 'blur' + export function createRecorder(shadowHost: HTMLDivElement) { const [isRecording, setIsRecording] = createSignal(false) createEffect(() => { if (isRecording()) { - createEventListener(shadowHost.shadowRoot!, 'click', (e: Event) => { - if (!(e instanceof MouseEvent)) { - return - } - const clickedEl = (shadowHost.shadowRoot && deepElementFromPoint(shadowHost.shadowRoot, e.clientX, e.clientY)) ?? e.target - if (!(clickedEl instanceof Element)) { - return - } - emitEvent('click', clickedEl) - }) + for (const eventType of ['click', 'input', 'submit', 'focus', 'blur'] as const) { + makeEventListener(shadowHost.shadowRoot!, eventType, (e: Event) => { + let target = e.target + + // When clicking, use deepElementFromPoint to get the right element if it's inside a shadow root. + if (e instanceof MouseEvent) { + const clickedEl = (shadowHost.shadowRoot && deepElementFromPoint(shadowHost.shadowRoot, e.clientX, e.clientY)) ?? e.target + if (!(clickedEl instanceof Element)) { + return + } + target = clickedEl + } + + if (!(target instanceof Element)) { + return + } + emitEvent(eventType, target) + }) + } } }) - async function emitEvent(type: 'click', target: Element) { + async function emitEvent(type: InputEventType, target: Element) { let suggestedQuery: Suggestion | undefined // Generate the selector using the closest HTMLElement. From 426e182d33f07e6a1a7534ec99d5faaaa66f2b10 Mon Sep 17 00:00:00 2001 From: PoffM Date: Thu, 11 Sep 2025 04:28:06 -0400 Subject: [PATCH 09/98] recorder WIP improve codegen add the codegen for missing imports needed for the new test code --- .../components/FormExample.tsx | 88 +++++++++++-------- .../src/recorder/record-input-as-code.ts | 65 +++++++++++++- 2 files changed, 112 insertions(+), 41 deletions(-) diff --git a/examples/vitest-react-tailwind4/components/FormExample.tsx b/examples/vitest-react-tailwind4/components/FormExample.tsx index 930c1e9..94538d7 100644 --- a/examples/vitest-react-tailwind4/components/FormExample.tsx +++ b/examples/vitest-react-tailwind4/components/FormExample.tsx @@ -1,57 +1,69 @@ -import React from 'react' +import React, { useState } from 'react' const FormExample: React.FC = () => { + const [submitCount, setSubmitCount] = useState(0) return ( -
-

Form Section

-
-
e.preventDefault()}> +
+
+ Submit Count: {submitCount} +
+
+

Form Section

+
+ { + e.preventDefault() + setSubmitCount(c => c + 1) + }} + > + + + + +
+

Non-Form Section

+
- - -
-

Non-Form Section

-
- - - -

- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. -

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. +

+
) diff --git a/packages/extension/src/recorder/record-input-as-code.ts b/packages/extension/src/recorder/record-input-as-code.ts index 8bc6036..2313f07 100644 --- a/packages/extension/src/recorder/record-input-as-code.ts +++ b/packages/extension/src/recorder/record-input-as-code.ts @@ -1,5 +1,6 @@ import * as vscode from 'vscode' -import { z } from 'zod/mini' +import { required, z } from 'zod/mini' +import { walk } from 'estree-walker' import type { SupportedFramework } from '../framework-support/detect-test-framework' import { detectTestLibrary } from '../framework-support/detect-test-library' import type { DebuggerTracker } from '../util/debugger-tracker' @@ -16,7 +17,10 @@ export async function startRecorderCodeGenSession( testFile: string, testFramework: SupportedFramework, ) { - const testLibrary = await detectTestLibrary(testFile) + const testLibrary = await detectTestLibrary(testFile) ?? '@testing-library/dom' + + // @ts-expect-error import the wasm file directly + const { parseSync } = await import('@oxc-parser/binding-wasm32-wasi') let offset = 0 @@ -79,12 +83,49 @@ export async function startRecorderCodeGenSession( return result })() - let code = `fireEvent.${event}(${findMethod}(${queryArgsStr}))` + const fireEvent = 'fireEvent' + const screen = 'screen' + + let code = `${fireEvent}.${event}(${screen}.${findMethod}(${queryArgsStr}))` const line = editor.document.lineAt(pausedLocation.lineNumber - 1) const indent = line.text.match(/^\s*/)?.[0] || '' code = `${indent}${code}` + // Figure out which imports need to be added + const requiredImports = new Map([ + [fireEvent, { from: testLibrary }], + [findMethod, { from: testLibrary }], + ]) + const importInsertionPoints = new Map() + { + // TODO avoid re-parsing for every generated line of code + const parsed = parseSync(editor.document.fileName, editor.document.getText(), {}) + const programJson = parsed.program + const program = JSON.parse(programJson) + + walk(program, { + enter(node) { + if (node.type === 'ImportDeclaration') { + if (node.source.type === 'Literal') { + const endOfLastSpecifier = node.specifiers.at(-1)?.end + if (endOfLastSpecifier) { + importInsertionPoints.set(node.source.value, endOfLastSpecifier) + } + } + for (const specifier of node.specifiers) { + if (['ImportSpecifier', 'ImportDefaultSpecifier', 'ImportNamespaceSpecifier'].includes(specifier.type)) { + requiredImports.delete(specifier.local.name) + } + } + } + }, + }) + } + + // Do the code edit + + // Add the fireEvent code if (editor && pausedLocation) { const position = new vscode.Position(pausedLocation.lineNumber - 1 + offset, 0) await editor.edit((editBuilder) => { @@ -93,6 +134,24 @@ export async function startRecorderCodeGenSession( }) } + // Add missing imports + for (const [importName, { from }] of requiredImports) { + const insertionPoint = importInsertionPoints.get(from) + if (insertionPoint) { + const position = editor.document.positionAt(insertionPoint) + await editor.edit((editBuilder) => { + editBuilder.insert(position, `, ${importName}`) + offset += 1 + }) + } + else { + await editor.edit((editBuilder) => { + editBuilder.insert(new vscode.Position(0, 0), `import { ${importName} } from '${from}'\n`) + offset += 1 + }) + } + } + // const resultStr = await ctx.sessionTracker.runDebugExpression( // `globalThis.__recordInputAsCode(${JSON.stringify(method)}, ${JSON.stringify(args)})`, // ) From f1629c4d1b89f53fe7e6585d394368355c52b6e3 Mon Sep 17 00:00:00 2001 From: PoffM Date: Thu, 11 Sep 2025 20:18:32 -0400 Subject: [PATCH 10/98] recorder WIP - disable from submission's default navigation behavior in the webview - run code as debug expression before writing it to the file --- packages/extension/src/extension.ts | 10 ++-- .../src/panel-controller/panel-controller.ts | 7 ++- .../src/panel-controller/panel-router.ts | 8 +-- .../src/recorder/record-input-as-code.ts | 49 ++++++++++++++----- packages/web-view-vite/index.html | 1 + .../web-view-vite/src/recorder/recorder.ts | 21 ++++++-- 6 files changed, 70 insertions(+), 26 deletions(-) diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 0b91214..4705f86 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -119,14 +119,16 @@ export let visuallyDebugUI = async ( await vscode.window.activeTextEditor?.document.save() // Only initialize the recorder state once per debug session - const recorderCodeGenSession = once(() => startRecorderCodeGenSession(testFile, fwInfo.framework)) + const recorderCodeGenSession = once( + () => startRecorderCodeGenSession(testFile, fwInfo.framework, panelController), + ) const panelController = await startPanelController(extensionContext, storage, recorderCodeGenSession) const onStartDebug = vscode.debug.onDidStartDebugSession(async (currentSession) => { onStartDebug.dispose() - const sessionTracker = startDebuggerTracker( + const debuggerTracker = startDebuggerTracker( currentSession, { onFrameChange: () => panelController.flushPatches(), @@ -145,7 +147,7 @@ export let visuallyDebugUI = async ( catch {} })() - await panelController.openPanel(sessionTracker) + await panelController.openPanel(debuggerTracker) const onTerminate = vscode.debug.onDidTerminateDebugSession( (endedSession) => { @@ -154,7 +156,7 @@ export let visuallyDebugUI = async ( } autoBreakpoint?.dispose() - sessionTracker.dispose() + debuggerTracker.dispose() panelController.dispose() onTerminate.dispose() }, diff --git a/packages/extension/src/panel-controller/panel-controller.ts b/packages/extension/src/panel-controller/panel-controller.ts index ddba5c7..7cab85b 100644 --- a/packages/extension/src/panel-controller/panel-controller.ts +++ b/packages/extension/src/panel-controller/panel-controller.ts @@ -13,6 +13,8 @@ import { type PanelRouterCtx, panelRouter } from './panel-router' // eslint-disable-next-line ts/no-var-requires, ts/no-require-imports const Server = require('../../node_modules/ws/lib/websocket-server') as typeof WsServer +export type PanelController = Awaited> + export async function startPanelController( extensionContext: vscode.ExtensionContext, storage: MyStorageType, @@ -55,7 +57,7 @@ export async function startPanelController( flushPatches, notifyDebuggerRestarted, async openPanel( - sessionTracker: DebuggerTracker, + debuggerTracker: DebuggerTracker, ) { // Create the webview panel panel = vscode.window.createWebviewPanel( @@ -104,6 +106,7 @@ export async function startPanelController( + @@ -125,7 +128,7 @@ export async function startPanelController( try { const ctx: PanelRouterCtx = { - sessionTracker, + debuggerTracker, storage, flushPatches, recorderCodeGenSession, diff --git a/packages/extension/src/panel-controller/panel-router.ts b/packages/extension/src/panel-controller/panel-router.ts index 68df437..c060c04 100644 --- a/packages/extension/src/panel-controller/panel-router.ts +++ b/packages/extension/src/panel-controller/panel-router.ts @@ -8,7 +8,7 @@ import type { DebuggerTracker } from '../util/debugger-tracker' import { workspaceCssFiles } from '../util/workspace-css-files' export interface PanelRouterCtx { - sessionTracker: DebuggerTracker + debuggerTracker: DebuggerTracker storage: MyStorageType flushPatches: () => void recorderCodeGenSession: () => Promise @@ -20,7 +20,7 @@ const t = initTRPC.context().create() export const panelRouter = t.router({ serializeHtml: t.procedure .query(async ({ ctx }) => { - const html = await ctx.sessionTracker.runDebugExpression('globalThis.__serializeHtml()') + const html = await ctx.debuggerTracker.runDebugExpression('globalThis.__serializeHtml()') return html }), @@ -107,7 +107,7 @@ export const panelRouter = t.router({ .mutation(async ({ ctx }) => { const files = await ctx.storage.get('enabledCssFiles') ?? [] const filesAsString = JSON.stringify(files) - const resultStr = await ctx.sessionTracker.runDebugExpression( + const resultStr = await ctx.debuggerTracker.runDebugExpression( `globalThis.__replaceStyles(${filesAsString})`, ) ctx.flushPatches() @@ -192,7 +192,7 @@ export const panelRouter = t.router({ .mutation(async ({ ctx, input }) => { const { event, query: [method, [queryArg0, queryOptions]] } = input const recorderCodeGenSession = await ctx.recorderCodeGenSession() - await recorderCodeGenSession.recordInputAsCode(ctx.sessionTracker, event, method, queryArg0, queryOptions) + await recorderCodeGenSession.recordInputAsCode(ctx.debuggerTracker, event, method, queryArg0, queryOptions) }), }) diff --git a/packages/extension/src/recorder/record-input-as-code.ts b/packages/extension/src/recorder/record-input-as-code.ts index 2313f07..7b26e03 100644 --- a/packages/extension/src/recorder/record-input-as-code.ts +++ b/packages/extension/src/recorder/record-input-as-code.ts @@ -1,9 +1,10 @@ -import * as vscode from 'vscode' -import { required, z } from 'zod/mini' import { walk } from 'estree-walker' +import * as vscode from 'vscode' +import { z } from 'zod/mini' import type { SupportedFramework } from '../framework-support/detect-test-framework' import { detectTestLibrary } from '../framework-support/detect-test-library' import type { DebuggerTracker } from '../util/debugger-tracker' +import type { PanelController } from '../panel-controller/panel-controller' export type RecorderCodeGenSession = Awaited> @@ -16,6 +17,7 @@ export const zSerializedRegexp = z.object({ export async function startRecorderCodeGenSession( testFile: string, testFramework: SupportedFramework, + panelController: PanelController, ) { const testLibrary = await detectTestLibrary(testFile) ?? '@testing-library/dom' @@ -26,13 +28,13 @@ export async function startRecorderCodeGenSession( const codeGenSession = { recordInputAsCode: async ( - sessionTracker: DebuggerTracker, + debuggerTracker: DebuggerTracker, event: string, findMethod: string, queryArg0: string | SerializedRegexp, queryOptions: Record | undefined, ) => { - const pausedLocation = await sessionTracker.getPausedLocation() + const pausedLocation = await debuggerTracker.getPausedLocation() if (!pausedLocation) { return } @@ -49,13 +51,13 @@ export async function startRecorderCodeGenSession( const [key, val] = entry result[key] = (() => { if (typeof val === 'string') { - return new RegExp(val) + return `'${val.replace(/'/g, '\\\'')}'` } else if (typeof val === 'boolean') { return val } else if (val.type === 'regexp') { - return new RegExp(val.value) + return val.value } else { return '' @@ -86,7 +88,18 @@ export async function startRecorderCodeGenSession( const fireEvent = 'fireEvent' const screen = 'screen' - let code = `${fireEvent}.${event}(${screen}.${findMethod}(${queryArgsStr}))` + /** + * e.g. `screen.getByRole('button', { name: 'Submit' })` + * or just `document` + */ + const selector = (() => { + if (queryArg0 === 'document') { + return 'document' + } + return `${screen}.${findMethod}(${queryArgsStr})` + })() + + let code = `${fireEvent}.${event}(${selector})` const line = editor.document.lineAt(pausedLocation.lineNumber - 1) const indent = line.text.match(/^\s*/)?.[0] || '' @@ -95,8 +108,23 @@ export async function startRecorderCodeGenSession( // Figure out which imports need to be added const requiredImports = new Map([ [fireEvent, { from: testLibrary }], - [findMethod, { from: testLibrary }], + [screen, { from: testLibrary }], ]) + + // Replicate the input event from the webview to the test runtime. + // We do this through a debug expression, which is the same as running code through vscode's 'Debug Terminal'. + const debugExpression = ` +(() => { +${[...requiredImports.entries()].map(([importName, { from }]) => `const { ${importName} } = require('${from}');`).join('\n')} +${code}; +})() +` + + const resultStr = await debuggerTracker.runDebugExpression( + debugExpression, + ) + panelController.flushPatches() + const importInsertionPoints = new Map() { // TODO avoid re-parsing for every generated line of code @@ -151,11 +179,6 @@ export async function startRecorderCodeGenSession( }) } } - - // const resultStr = await ctx.sessionTracker.runDebugExpression( - // `globalThis.__recordInputAsCode(${JSON.stringify(method)}, ${JSON.stringify(args)})`, - // ) - // ctx.flushPatches() }, } return codeGenSession diff --git a/packages/web-view-vite/index.html b/packages/web-view-vite/index.html index 110d016..d792321 100644 --- a/packages/web-view-vite/index.html +++ b/packages/web-view-vite/index.html @@ -2,6 +2,7 @@ + diff --git a/packages/web-view-vite/src/recorder/recorder.ts b/packages/web-view-vite/src/recorder/recorder.ts index 31b6fc7..3084948 100644 --- a/packages/web-view-vite/src/recorder/recorder.ts +++ b/packages/web-view-vite/src/recorder/recorder.ts @@ -28,6 +28,7 @@ export function createRecorder(shadowHost: HTMLDivElement) { if (!(target instanceof Element)) { return } + emitEvent(eventType, target) }) } @@ -37,14 +38,28 @@ export function createRecorder(shadowHost: HTMLDivElement) { async function emitEvent(type: InputEventType, target: Element) { let suggestedQuery: Suggestion | undefined + /** + * Sometimes testing-library can't find a good query to use. + * This fn checks if testing-library generated a useful query. + */ + function hasQuery() { + if (!suggestedQuery) { + return false + } + if (suggestedQuery.queryArgs[0] === 'document') { + return false + } + return true + } + // Generate the selector using the closest HTMLElement. // e.g. if you click on an SVG, that doesn't count as an HTMLElement, // so step up to the parent. - while (!suggestedQuery && target) { + while (!hasQuery() && target) { if (target instanceof HTMLElement) { suggestedQuery = getSuggestedQuery(target) } - if (!suggestedQuery) { + if (!hasQuery()) { if (target.parentElement) { target = target.parentElement } @@ -54,7 +69,7 @@ export function createRecorder(shadowHost: HTMLDivElement) { } } - if (!suggestedQuery) { + if (!hasQuery() || !suggestedQuery) { return } From 590c04b0b2224ef9fec5fbf78f237d9f4cb4f9de Mon Sep 17 00:00:00 2001 From: PoffM Date: Sat, 13 Sep 2025 00:45:37 -0400 Subject: [PATCH 11/98] recorder WIP - include the text in the generated 'change' event - add initial right-click command to create UI test --- package.json | 16 ++- packages/extension/src/extension.ts | 34 ++++-- .../framework-support/detect-test-library.ts | 5 - .../src/panel-controller/panel-controller.ts | 2 +- .../src/panel-controller/panel-router.ts | 21 +++- .../src/recorder/create-ui-test-file.ts | 112 ++++++++++++++++++ .../src/recorder/record-input-as-code.ts | 22 +++- .../web-view-vite/src/recorder/recorder.ts | 103 ++++++++-------- 8 files changed, 235 insertions(+), 80 deletions(-) create mode 100644 packages/extension/src/recorder/create-ui-test-file.ts diff --git a/package.json b/package.json index a2c3560..68845fe 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,21 @@ "description": "Test Framework: Vitest or Jest. Auto-detects by default by walking up directories from your test file." } } - } + }, + "menus": { + "editor/context": [ + { + "command": "ui-test-visualizer.createUiTest", + "when": "editorTextFocus && editorLangId =~ /typescript|javascript/" + } + ] + }, + "commands": [ + { + "command": "ui-test-visualizer.createUiTest", + "title": "Create UI test (UI Test Visualizer)" + } + ] }, "scripts": { "dev": "rm -rf build-dev && pnpm run -r dev", diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 4705f86..22bc5fd 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -15,6 +15,8 @@ import { startRecorderCodeGenSession } from './recorder/record-input-as-code' import { startDebuggerTracker } from './util/debugger-tracker' import { extensionSetting } from './util/extension-setting' import { hotReload } from './util/hot-reload' +import { detectTestLibrary } from './framework-support/detect-test-library' +import { createUiTestFile } from './recorder/create-ui-test-file' const reporter = (() => { try { @@ -41,14 +43,16 @@ export async function activate(extensionContext: vscode.ExtensionContext) { }) } - const debugTest = vscode.commands.registerCommand( - 'ui-test-visualizer.visuallyDebugUI', - (testFile: unknown, testName: unknown, startAndEndLines: unknown, firstStatementStartLine: unknown) => visuallyDebugUI( - testFile, - testName, - startAndEndLines, - firstStatementStartLine, - extensionContext, + extensionContext.subscriptions.push( + vscode.commands.registerCommand( + 'ui-test-visualizer.visuallyDebugUI', + (testFile: unknown, testName: unknown, startAndEndLines: unknown, firstStatementStartLine: unknown) => visuallyDebugUI( + testFile, + testName, + startAndEndLines, + firstStatementStartLine, + extensionContext, + ), ), ) @@ -85,7 +89,10 @@ export async function activate(extensionContext: vscode.ExtensionContext) { ) } - extensionContext.subscriptions.push(debugTest) + // Right-click a component name and click "Create UI test" -> create a test file with the name of the component + extensionContext.subscriptions.push( + vscode.commands.registerCommand('ui-test-visualizer.createUiTest', () => createUiTestFile()), + ) } export function deactivate() { } @@ -113,14 +120,17 @@ export let visuallyDebugUI = async ( return parsed.success ? parsed.data : 'autodetect' })() - const fwInfo = await detectTestFramework(testFile, frameworkSetting) + const frameworkInfo = await detectTestFramework(testFile, frameworkSetting) + const testLibrary = await detectTestLibrary(testFile) // Save the test file before starting the debug session await vscode.window.activeTextEditor?.document.save() // Only initialize the recorder state once per debug session const recorderCodeGenSession = once( - () => startRecorderCodeGenSession(testFile, fwInfo.framework, panelController), + async () => testLibrary + ? await startRecorderCodeGenSession(testFile, frameworkInfo.framework, testLibrary, panelController) + : null, ) const panelController = await startPanelController(extensionContext, storage, recorderCodeGenSession) @@ -164,7 +174,7 @@ export let visuallyDebugUI = async ( }) const debugConfig = await makeDebugConfig( - fwInfo, + frameworkInfo, testFile, testName, panelController.htmlUpdaterPort, diff --git a/packages/extension/src/framework-support/detect-test-library.ts b/packages/extension/src/framework-support/detect-test-library.ts index 2996c8e..19fcaf5 100644 --- a/packages/extension/src/framework-support/detect-test-library.ts +++ b/packages/extension/src/framework-support/detect-test-library.ts @@ -8,11 +8,6 @@ export const SUPPORTED_TESTING_LIBRARIES = [ export type TestingLibrary = typeof SUPPORTED_TESTING_LIBRARIES[number] -export interface TestLibraryInfo { - framework: SupportedFramework - testingLibrary: TestingLibrary | null -} - export async function detectTestLibrary(testFilePath: string): Promise { // Lookup the test framework in node_modules const detectedLibrary = (() => { diff --git a/packages/extension/src/panel-controller/panel-controller.ts b/packages/extension/src/panel-controller/panel-controller.ts index 7cab85b..7f8e07b 100644 --- a/packages/extension/src/panel-controller/panel-controller.ts +++ b/packages/extension/src/panel-controller/panel-controller.ts @@ -18,7 +18,7 @@ export type PanelController = Awaited> export async function startPanelController( extensionContext: vscode.ExtensionContext, storage: MyStorageType, - recorderCodeGenSession: () => Promise, + recorderCodeGenSession: () => Promise, ) { const htmlUpdaterPort = await getPort() const viteDevServerPort = 5173 diff --git a/packages/extension/src/panel-controller/panel-router.ts b/packages/extension/src/panel-controller/panel-router.ts index c060c04..b73d9f9 100644 --- a/packages/extension/src/panel-controller/panel-router.ts +++ b/packages/extension/src/panel-controller/panel-router.ts @@ -11,7 +11,7 @@ export interface PanelRouterCtx { debuggerTracker: DebuggerTracker storage: MyStorageType flushPatches: () => void - recorderCodeGenSession: () => Promise + recorderCodeGenSession: () => Promise } const t = initTRPC.context().create() @@ -180,19 +180,32 @@ export const panelRouter = t.router({ .input( z.object({ event: z.string(), + eventData: z.object({ + text: z.optional(z.string()), // Used for change events + }), query: z.tuple([ z.string(), z.tuple([ z.union([z.string(), zSerializedRegexp]), - z.optional(z.record(z.string(), z.union([z.string(), z.boolean(), zSerializedRegexp]))), + z.optional(z.record( + z.string(), + z.union([z.string(), z.boolean(), zSerializedRegexp]), + )), ]), ]), }), ) .mutation(async ({ ctx, input }) => { - const { event, query: [method, [queryArg0, queryOptions]] } = input + const { event, eventData, query: [method, [queryArg0, queryOptions]] } = input const recorderCodeGenSession = await ctx.recorderCodeGenSession() - await recorderCodeGenSession.recordInputAsCode(ctx.debuggerTracker, event, method, queryArg0, queryOptions) + await recorderCodeGenSession?.recordInputAsCode( + ctx.debuggerTracker, + event, + eventData, + method, + queryArg0, + queryOptions, + ) }), }) diff --git a/packages/extension/src/recorder/create-ui-test-file.ts b/packages/extension/src/recorder/create-ui-test-file.ts new file mode 100644 index 0000000..54274d0 --- /dev/null +++ b/packages/extension/src/recorder/create-ui-test-file.ts @@ -0,0 +1,112 @@ +import { Buffer } from 'node:buffer' +import * as path from 'pathe' +import * as vscode from 'vscode' +import { walk } from 'estree-walker' +import { zFrameworkSetting } from '../extension' +import { extensionSetting } from '../util/extension-setting' +import { detectTestFramework } from '../framework-support/detect-test-framework' +import { SUPPORTED_TESTING_LIBRARIES, detectTestLibrary } from '../framework-support/detect-test-library' + +export async function createUiTestFile() { + // @ts-expect-error import the wasm file directly + const { parseSync } = await import('@oxc-parser/binding-wasm32-wasi') + + const editor = vscode.window.activeTextEditor + if (!editor) { + return + } + + const doc = editor.document + + const selection = editor.selection + const wordRange = doc.getWordRangeAtPosition(selection.active, /\w+/) + if (!wordRange) { + return + } + + const exportName = (() => { + const parsed = parseSync(doc.fileName, doc.getText(), {}) + const programJson = parsed.program + const program = JSON.parse(programJson) + + const wordStart = doc.offsetAt(wordRange.start) + const wordEnd = doc.offsetAt(wordRange.end) + + let result: string | null = null + walk(program, { + enter(node) { + if (node.type === 'ExportNamedDeclaration' && node.start <= wordStart && node.end >= wordEnd) { + result = node.declaration?.id.name + } + if (node.type === 'ExportDefaultDeclaration' && node.declaration.type === 'FunctionDeclaration' && node.start <= wordStart && node.end >= wordEnd) { + result = node.declaration.id.name + } + }, + }) + + return result as string | null + })() + + const word = doc.getText(wordRange) + + if (!exportName) { + vscode.window.showInformationMessage( + `No valid selection found. Must be an exported capitalized function name. Got ${word}`, + ) + return + } + + // React/Solid component convention: starts with capital letter + if (!/^[A-Z]/.test(exportName)) { + vscode.window.showInformationMessage( + `Selection must be a capitalized identifier, e.g. a React component name. Got "${word}".`, + ) + return + } + + const frameworkSetting = (() => { + const parsed = zFrameworkSetting + .safeParse(extensionSetting('ui-test-visualizer.testFramework')) + return parsed.success ? parsed.data : 'autodetect' + })() + + const frameworkInfo = await detectTestFramework(editor.document.uri.fsPath, frameworkSetting) + const testingLibrary = await detectTestLibrary(editor.document.uri.fsPath) + + if (!testingLibrary) { + vscode.window.showInformationMessage(`Could not detect a testing library for ${editor.document.uri.fsPath}. Supported testing libraries are ${SUPPORTED_TESTING_LIBRARIES.join(', ')}.`) + return + } + + const currentDir = path.dirname(editor.document.uri.fsPath) + const testFileName = `${exportName}.test.tsx` + const testFileUri = vscode.Uri.file(path.join(currentDir, testFileName)) + + // Check if the test file already exists + try { + await vscode.workspace.fs.stat(testFileUri) + vscode.window.showInformationMessage(`Test file ${testFileName} already exists.`) + return + } + catch { + // File does not exist, proceed to create + } + + // Create basic test content + const testContent = `import { describe, test } from '${frameworkInfo.framework}' +import { render } from '${testingLibrary}' + +describe('${exportName}', () => { + test('basic usage', () => { + render(<${exportName} />) + }) +}) +` + + // Write the file + await vscode.workspace.fs.writeFile(testFileUri, Buffer.from(testContent, 'utf8')) + + // Open the new test file + const newDocument = await vscode.workspace.openTextDocument(testFileUri) + await vscode.window.showTextDocument(newDocument) +} diff --git a/packages/extension/src/recorder/record-input-as-code.ts b/packages/extension/src/recorder/record-input-as-code.ts index 7b26e03..c8c8436 100644 --- a/packages/extension/src/recorder/record-input-as-code.ts +++ b/packages/extension/src/recorder/record-input-as-code.ts @@ -1,10 +1,13 @@ +import type { inferProcedureInput } from '@trpc/server' import { walk } from 'estree-walker' import * as vscode from 'vscode' import { z } from 'zod/mini' import type { SupportedFramework } from '../framework-support/detect-test-framework' +import type { TestingLibrary } from '../framework-support/detect-test-library' import { detectTestLibrary } from '../framework-support/detect-test-library' -import type { DebuggerTracker } from '../util/debugger-tracker' import type { PanelController } from '../panel-controller/panel-controller' +import type { panelRouter } from '../panel-controller/panel-router' +import type { DebuggerTracker } from '../util/debugger-tracker' export type RecorderCodeGenSession = Awaited> @@ -17,19 +20,19 @@ export const zSerializedRegexp = z.object({ export async function startRecorderCodeGenSession( testFile: string, testFramework: SupportedFramework, + testLibrary: TestingLibrary, panelController: PanelController, ) { - const testLibrary = await detectTestLibrary(testFile) ?? '@testing-library/dom' - // @ts-expect-error import the wasm file directly const { parseSync } = await import('@oxc-parser/binding-wasm32-wasi') let offset = 0 - const codeGenSession = { + return { recordInputAsCode: async ( debuggerTracker: DebuggerTracker, event: string, + eventData: inferProcedureInput['eventData'], findMethod: string, queryArg0: string | SerializedRegexp, queryOptions: Record | undefined, @@ -99,7 +102,15 @@ export async function startRecorderCodeGenSession( return `${screen}.${findMethod}(${queryArgsStr})` })() - let code = `${fireEvent}.${event}(${selector})` + const fireEventArgs = (() => { + if (event === 'change' && eventData.text) { + const value = eventData.text.replace(/'/g, '\\\'') + return `, { target: { value: '${value}' } }` + } + return '' + })() + + let code = `${fireEvent}.${event}(${selector}${fireEventArgs})` const line = editor.document.lineAt(pausedLocation.lineNumber - 1) const indent = line.text.match(/^\s*/)?.[0] || '' @@ -181,5 +192,4 @@ ${code}; } }, } - return codeGenSession } diff --git a/packages/web-view-vite/src/recorder/recorder.ts b/packages/web-view-vite/src/recorder/recorder.ts index 3084948..142fbba 100644 --- a/packages/web-view-vite/src/recorder/recorder.ts +++ b/packages/web-view-vite/src/recorder/recorder.ts @@ -1,18 +1,16 @@ -import { createEffect, createSignal } from 'solid-js' import { makeEventListener } from '@solid-primitives/event-listener' import type { QueryArgs, Suggestion } from '@testing-library/dom' import { getSuggestedQuery } from '@testing-library/dom' +import { createEffect, createSignal } from 'solid-js' import { deepElementFromPoint } from '../inspector/util' import { client } from '../lib/panel-client' -export type InputEventType = 'click' | 'input' | 'submit' | 'focus' | 'blur' - export function createRecorder(shadowHost: HTMLDivElement) { const [isRecording, setIsRecording] = createSignal(false) createEffect(() => { if (isRecording()) { - for (const eventType of ['click', 'input', 'submit', 'focus', 'blur'] as const) { + for (const eventType of ['click', 'submit', 'focus', 'blur', 'change'] as const) { makeEventListener(shadowHost.shadowRoot!, eventType, (e: Event) => { let target = e.target @@ -29,63 +27,66 @@ export function createRecorder(shadowHost: HTMLDivElement) { return } - emitEvent(eventType, target) - }) - } - } - }) + let suggestedQuery: Suggestion | undefined - async function emitEvent(type: InputEventType, target: Element) { - let suggestedQuery: Suggestion | undefined + /** + * Sometimes testing-library can't find a good query to use. + * This fn checks if testing-library generated a useful query. + */ + function hasQuery() { + if (!suggestedQuery) { + return false + } + if (suggestedQuery.queryArgs[0] === 'document') { + return false + } + return true + } - /** - * Sometimes testing-library can't find a good query to use. - * This fn checks if testing-library generated a useful query. - */ - function hasQuery() { - if (!suggestedQuery) { - return false - } - if (suggestedQuery.queryArgs[0] === 'document') { - return false - } - return true - } + // Generate the selector using the closest HTMLElement. + // e.g. if you click on an SVG, that doesn't count as an HTMLElement, + // so step up to the parent. + while (!hasQuery() && target) { + if (target instanceof HTMLElement) { + suggestedQuery = getSuggestedQuery(target) + } + if (!hasQuery()) { + if (target instanceof Element && target.parentElement) { + target = target.parentElement + } + else { + return + } + } + } - // Generate the selector using the closest HTMLElement. - // e.g. if you click on an SVG, that doesn't count as an HTMLElement, - // so step up to the parent. - while (!hasQuery() && target) { - if (target instanceof HTMLElement) { - suggestedQuery = getSuggestedQuery(target) - } - if (!hasQuery()) { - if (target.parentElement) { - target = target.parentElement - } - else { - return - } - } - } + if (!hasQuery() || !suggestedQuery) { + return + } - if (!hasQuery() || !suggestedQuery) { - return - } + const eventData: Parameters[0]['eventData'] = {} - const query = serializeQueryArgs(suggestedQuery.queryArgs) - // Send the selector to the extension process to record as code - await client.recordInputAsCode.mutate({ - event: type, - query: [suggestedQuery.queryMethod, query], - }) - } + if (eventType === 'change' && target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { + const text = target.value + eventData.text = text + } + + const query = serializeQueryArgs(suggestedQuery.queryArgs) + // Send the selector to the extension process to record as code + void client.recordInputAsCode.mutate({ + event: eventType, + query: [suggestedQuery.queryMethod, query], + eventData, + }) + }) + } + } + }) return { isRecording, toggle: (recording: boolean) => { setIsRecording(recording) - console.log('recording:', recording) }, } } From 5240724a68249c30587852919e214f517ad8aa3a Mon Sep 17 00:00:00 2001 From: PoffM Date: Sat, 13 Sep 2025 01:24:41 -0400 Subject: [PATCH 12/98] recorder WIP Fix initial file create behavior for exported arrow functions --- examples/vitest-react-tailwind4/vite.config.ts | 3 ++- .../src/recorder/create-ui-test-file.ts | 16 ++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/examples/vitest-react-tailwind4/vite.config.ts b/examples/vitest-react-tailwind4/vite.config.ts index 5066940..fa6a3f5 100644 --- a/examples/vitest-react-tailwind4/vite.config.ts +++ b/examples/vitest-react-tailwind4/vite.config.ts @@ -2,9 +2,10 @@ import { defineConfig } from 'vite' import tailwindcss from '@tailwindcss/vite' +import react from '@vitejs/plugin-react' export default defineConfig({ - plugins: [tailwindcss()], + plugins: [react(), tailwindcss()], test: { globals: true, environment: 'happy-dom', diff --git a/packages/extension/src/recorder/create-ui-test-file.ts b/packages/extension/src/recorder/create-ui-test-file.ts index 54274d0..0418774 100644 --- a/packages/extension/src/recorder/create-ui-test-file.ts +++ b/packages/extension/src/recorder/create-ui-test-file.ts @@ -35,11 +35,11 @@ export async function createUiTestFile() { let result: string | null = null walk(program, { enter(node) { - if (node.type === 'ExportNamedDeclaration' && node.start <= wordStart && node.end >= wordEnd) { - result = node.declaration?.id.name - } - if (node.type === 'ExportDefaultDeclaration' && node.declaration.type === 'FunctionDeclaration' && node.start <= wordStart && node.end >= wordEnd) { - result = node.declaration.id.name + if ( + (node.type === 'ExportNamedDeclaration' || node.type === 'ExportDefaultDeclaration') + && node.start <= wordStart && node.end >= wordEnd + ) { + result = node?.declaration?.id?.name ?? node?.declaration?.declarations?.[0]?.id?.name ?? null } }, }) @@ -92,13 +92,17 @@ export async function createUiTestFile() { // File does not exist, proceed to create } + const relativePathToSrc = path.relative(currentDir, doc.fileName).replace(/\.[jt]sx?$/, '') + // Create basic test content + const isArrowRender = testingLibrary === '@solidjs/testing-library' const testContent = `import { describe, test } from '${frameworkInfo.framework}' import { render } from '${testingLibrary}' +import { ${exportName} } from './${relativePathToSrc}' describe('${exportName}', () => { test('basic usage', () => { - render(<${exportName} />) + render(${isArrowRender ? `() => <${exportName} />` : `<${exportName} />`}) }) }) ` From 18653b0206004d78a67ab34de3aa3609ff6e9249 Mon Sep 17 00:00:00 2001 From: PoffM Date: Sat, 13 Sep 2025 02:19:00 -0400 Subject: [PATCH 13/98] recorder WIP - avoid multiple matches by using exact regex --- .../web-view-vite/src/recorder/recorder.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/web-view-vite/src/recorder/recorder.ts b/packages/web-view-vite/src/recorder/recorder.ts index 142fbba..6e17678 100644 --- a/packages/web-view-vite/src/recorder/recorder.ts +++ b/packages/web-view-vite/src/recorder/recorder.ts @@ -66,7 +66,7 @@ export function createRecorder(shadowHost: HTMLDivElement) { const eventData: Parameters[0]['eventData'] = {} - if (eventType === 'change' && target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { + if (eventType === 'change' && (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement)) { const text = target.value eventData.text = text } @@ -99,13 +99,13 @@ export function serializeQueryArgs(queryArgs: QueryArgs): [string | SerializedRe const [query, options] = queryArgs if (!options) { // @ts-expect-error Not declared the testing library, but the query could be a RegExp: - const result = query instanceof RegExp ? serializeRegexp(query) : query + const result = query instanceof RegExp ? processRegexp(query) : query return [result] } const serializedOptions = Object.entries(options).reduce((prev, curr) => { const val = curr[1] if (val !== undefined) { - prev[curr[0]] = val instanceof RegExp ? serializeRegexp(val) : val + prev[curr[0]] = val instanceof RegExp ? processRegexp(val) : val } return prev }, {} as Record) @@ -115,6 +115,17 @@ export function serializeQueryArgs(queryArgs: QueryArgs): [string | SerializedRe export interface SerializedRegexp { type: 'regexp', value: string } -function serializeRegexp(regexp: RegExp): SerializedRegexp { +function processRegexp(regexp: RegExp): SerializedRegexp { + // Make the regexp exact, to avoid multiple matches. + // e.g. when you have buttons aria-labeled "right" and "top right", then the regex /right/ would match both. + regexp = makeRegexpExact(regexp) + return { type: 'regexp', value: regexp.toString() } } + +function makeRegexpExact(regexp: RegExp) { + const { source, flags } = regexp + + // Wrap with ^ and $ + return new RegExp(`^${source}$`, flags) +} From 1172292b895d872c181549455780742f7423d76b Mon Sep 17 00:00:00 2001 From: PoffM Date: Wed, 17 Sep 2025 00:49:08 -0400 Subject: [PATCH 14/98] recorder WIP - Add recorder panel UI - code view on left half (using fake data atm) - mouse event selection on right half --- .../src/recorder/record-input-as-code.ts | 2 +- packages/web-view-vite/package.json | 3 + packages/web-view-vite/src/App.tsx | 39 ++- .../src/recorder/recorder-panel.tsx | 103 ++++++ .../web-view-vite/src/recorder/recorder.ts | 26 +- pnpm-lock.yaml | 314 ++++++++++++++++++ 6 files changed, 471 insertions(+), 16 deletions(-) create mode 100644 packages/web-view-vite/src/recorder/recorder-panel.tsx diff --git a/packages/extension/src/recorder/record-input-as-code.ts b/packages/extension/src/recorder/record-input-as-code.ts index c8c8436..b65f7f6 100644 --- a/packages/extension/src/recorder/record-input-as-code.ts +++ b/packages/extension/src/recorder/record-input-as-code.ts @@ -131,7 +131,7 @@ ${code}; })() ` - const resultStr = await debuggerTracker.runDebugExpression( + await debuggerTracker.runDebugExpression( debugExpression, ) panelController.flushPatches() diff --git a/packages/web-view-vite/package.json b/packages/web-view-vite/package.json index 88860a1..0a1de1b 100644 --- a/packages/web-view-vite/package.json +++ b/packages/web-view-vite/package.json @@ -9,6 +9,8 @@ "dependencies": { "@kobalte/core": "0.13.11", "@kobalte/utils": "^0.9.1", + "@shikijs/langs": "^3.12.2", + "@shikijs/themes": "^3.12.2", "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/map": "^0.7.2", "@solid-primitives/mutation-observer": "^1.2.2", @@ -23,6 +25,7 @@ "lodash": "^4.17.21", "lucide-solid": "^0.542.0", "replicate-dom": "workspace:*", + "shiki": "^3.12.2", "solid-js": "^1.9.9", "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7", diff --git a/packages/web-view-vite/src/App.tsx b/packages/web-view-vite/src/App.tsx index 1f48018..af5a844 100644 --- a/packages/web-view-vite/src/App.tsx +++ b/packages/web-view-vite/src/App.tsx @@ -1,5 +1,5 @@ import * as webviewToolkit from '@vscode/webview-ui-toolkit' -import { ErrorBoundary, Show, createSignal } from 'solid-js' +import { ErrorBoundary, Match, Show, Switch, createSignal } from 'solid-js' import { createColorTheme } from './lib/color-theme' import { createDomReplica } from './lib/create-dom-replica' import { createInspectorHeight } from './inspector/inspector-height' @@ -7,8 +7,7 @@ import { Toolbar } from './components/Toolbar' import { Inspector } from './inspector/Inspector' import { Resizer } from './inspector/Resizer' import { createRecorder } from './recorder/recorder' - -// Importing the router type from the server file +import { RecorderPanel } from './recorder/recorder-panel' // In order to use the Webview UI Toolkit web components they // must be registered with the browser (i.e. webview) using the @@ -21,6 +20,7 @@ import { createRecorder } from './recorder/recorder' .register(webviewToolkit.vsCodeCheckbox({ prefix })) .register(webviewToolkit.vsCodeProgressRing({ prefix })) .register(webviewToolkit.vsCodeTextField({ prefix })) + .register(webviewToolkit.vsCodeRadio({ prefix })) } // TODO put these into a context provider @@ -65,17 +65,32 @@ export function App() { > {shadowHost}
- +
- ( -
- Error showing the inspector{error instanceof Error ? `: ${error.message}` : ''} -
- )} - > - -
+ + + ( +
+ Error showing the recorder UI{error instanceof Error ? `: ${error.message}` : ''} +
+ )} + > + +
+ +
+ + ( +
+ Error showing the inspector{error instanceof Error ? `: ${error.message}` : ''} +
+ )} + > + +
+
+
diff --git a/packages/web-view-vite/src/recorder/recorder-panel.tsx b/packages/web-view-vite/src/recorder/recorder-panel.tsx new file mode 100644 index 0000000..44ea571 --- /dev/null +++ b/packages/web-view-vite/src/recorder/recorder-panel.tsx @@ -0,0 +1,103 @@ +import { For, Suspense, createResource, createSignal } from 'solid-js' +import { createHighlighterCore, createJavaScriptRegexEngine } from 'shiki' +import shikiDarkPlus from '@shikijs/themes/dark-plus' +import shikiLightPlus from '@shikijs/themes/light-plus' +import shikiTypescript from '@shikijs/langs/typescript' +import { recorder } from '../App' +import { MOUSE_EVENT_TYPES } from './recorder' + +export function RecorderPanel() { + const [codeHighlighter] = createResource(async () => await createHighlighterCore({ + themes: [shikiDarkPlus, shikiLightPlus], + langs: [shikiTypescript], + engine: createJavaScriptRegexEngine(), + })) + + // TODO show the actual generated code + const [mockGeneratedCode] = createSignal([ + 'console.log(\'click on button\')', + 'console.log(\'type in input\')', + 'console.log(\'hover over element\')', + 'console.log(\'double click\')', + 'console.log(\'right click\')', + 'console.log(\'mouse down\')', + 'console.log(\'mouse up\')', + 'console.log(\'mouse enter\')', + 'console.log(\'mouse leave\')', + 'console.log(\'mouse move\')', + ]) + + function highlightedCode(code: string) { + const shikiTheme = document.body.classList.contains('vscode-light') ? 'light-plus' : 'dark-plus' + + const html = codeHighlighter.error + ? null + // codeHighlighter() triggers Suspense + : codeHighlighter()?.codeToHtml( + code, + { lang: 'typescript', theme: shikiTheme }, + ) + + if (!html) { + return code + } + + return ( +
{ + div.innerHTML = html + // Remove shiki's default background colors from the generated elements. + for (const el of div.querySelectorAll('pre') ?? []) { + el.style.backgroundColor = 'transparent' + } + for (const el of div.querySelectorAll('code') ?? []) { + el.style.backgroundColor = 'transparent' + } + }} + /> + ) + } + + return ( +
+
+
+

Generated Code

+
+            {code => (
+              {code}
}> + <>{highlightedCode(code)} + + )} + + +
+
+
+
+

Choose Mouse Event

+
+ {event => ( + + )} + +
+
+
+
+ ) +} diff --git a/packages/web-view-vite/src/recorder/recorder.ts b/packages/web-view-vite/src/recorder/recorder.ts index 6e17678..28cc0cd 100644 --- a/packages/web-view-vite/src/recorder/recorder.ts +++ b/packages/web-view-vite/src/recorder/recorder.ts @@ -1,16 +1,30 @@ import { makeEventListener } from '@solid-primitives/event-listener' -import type { QueryArgs, Suggestion } from '@testing-library/dom' +import type { EventType, QueryArgs, Suggestion } from '@testing-library/dom' import { getSuggestedQuery } from '@testing-library/dom' import { createEffect, createSignal } from 'solid-js' import { deepElementFromPoint } from '../inspector/util' import { client } from '../lib/panel-client' +export const MOUSE_EVENT_TYPES: EventType[] = [ + 'click', + 'dblClick', + 'mouseDown', + 'mouseUp', + 'mouseEnter', + 'mouseLeave', + 'mouseMove', + 'mouseOver', + 'mouseOut', + 'contextMenu', +] + export function createRecorder(shadowHost: HTMLDivElement) { const [isRecording, setIsRecording] = createSignal(false) + const [mouseEvent, setMouseEvent] = createSignal('click') createEffect(() => { if (isRecording()) { - for (const eventType of ['click', 'submit', 'focus', 'blur', 'change'] as const) { + for (const eventType of ['click', 'submit', 'change'] as const) { makeEventListener(shadowHost.shadowRoot!, eventType, (e: Event) => { let target = e.target @@ -71,10 +85,14 @@ export function createRecorder(shadowHost: HTMLDivElement) { eventData.text = text } + const recordedEventType = eventType === 'click' + ? mouseEvent() + : eventType + const query = serializeQueryArgs(suggestedQuery.queryArgs) // Send the selector to the extension process to record as code void client.recordInputAsCode.mutate({ - event: eventType, + event: recordedEventType, query: [suggestedQuery.queryMethod, query], eventData, }) @@ -88,6 +106,8 @@ export function createRecorder(shadowHost: HTMLDivElement) { toggle: (recording: boolean) => { setIsRecording(recording) }, + mouseEvent, + setMouseEvent, } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 258783a..c9068fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -473,6 +473,12 @@ importers: '@kobalte/utils': specifier: ^0.9.1 version: 0.9.1(solid-js@1.9.9) + '@shikijs/langs': + specifier: ^3.12.2 + version: 3.12.2 + '@shikijs/themes': + specifier: ^3.12.2 + version: 3.12.2 '@solid-primitives/event-listener': specifier: ^2.4.3 version: 2.4.3(solid-js@1.9.9) @@ -515,6 +521,9 @@ importers: replicate-dom: specifier: workspace:* version: link:../replicate-dom + shiki: + specifier: ^3.12.2 + version: 3.12.2 solid-js: specifier: ^1.9.9 version: 1.9.9 @@ -3263,6 +3272,27 @@ packages: resolution: {integrity: sha512-8fHvsBMQtibVDxHKCyjaxDdWStE6E063xwBqrBz1zl/VArzEVUzXF+NLNc/LdIuyVrgQ41BG7Bmvo5bbZQ+XEg==} engines: {node: '>=20.0.0'} + '@shikijs/core@3.12.2': + resolution: {integrity: sha512-L1Safnhra3tX/oJK5kYHaWmLEBJi1irASwewzY3taX5ibyXyMkkSDZlq01qigjryOBwrXSdFgTiZ3ryzSNeu7Q==} + + '@shikijs/engine-javascript@3.12.2': + resolution: {integrity: sha512-Nm3/azSsaVS7hk6EwtHEnTythjQfwvrO5tKqMlaH9TwG1P+PNaR8M0EAKZ+GaH2DFwvcr4iSfTveyxMIvXEHMw==} + + '@shikijs/engine-oniguruma@3.12.2': + resolution: {integrity: sha512-hozwnFHsLvujK4/CPVHNo3Bcg2EsnG8krI/ZQ2FlBlCRpPZW4XAEQmEwqegJsypsTAN9ehu2tEYe30lYKSZW/w==} + + '@shikijs/langs@3.12.2': + resolution: {integrity: sha512-bVx5PfuZHDSHoBal+KzJZGheFuyH4qwwcwG/n+MsWno5cTlKmaNtTsGzJpHYQ8YPbB5BdEdKU1rga5/6JGY8ww==} + + '@shikijs/themes@3.12.2': + resolution: {integrity: sha512-fTR3QAgnwYpfGczpIbzPjlRnxyONJOerguQv1iwpyQZ9QXX4qy/XFQqXlf17XTsorxnHoJGbH/LXBvwtqDsF5A==} + + '@shikijs/types@3.12.2': + resolution: {integrity: sha512-K5UIBzxCyv0YoxN3LMrKB9zuhp1bV+LgewxuVwHdl4Gz5oePoUFrr9EfgJlGlDeXCU1b/yhdnXeuRvAnz8HN8Q==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} @@ -3616,6 +3646,9 @@ packages: '@types/graceful-fs@4.1.9': resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/istanbul-lib-coverage@2.0.6': resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} @@ -3643,6 +3676,9 @@ packages: '@types/mdast@3.0.15': resolution: {integrity: sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==} + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} @@ -3673,6 +3709,9 @@ packages: '@types/unist@2.0.10': resolution: {integrity: sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==} + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/vscode-webview@1.57.5': resolution: {integrity: sha512-iBAUYNYkz+uk1kdsq05fEcoh8gJmwT3lqqFPN7MGyjQ3HVloViMdo7ZJ8DFIP8WOK74PjOEilosqAyxV2iUFUw==} @@ -4352,6 +4391,9 @@ packages: caniuse-lite@1.0.30001737: resolution: {integrity: sha512-BiloLiXtQNrY5UyF0+1nSJLXUENuhka2pzy2Fx5pGxqavdrxSCW4U6Pn/PoG3Efspi2frRbHpBV2XsrPE6EDlw==} + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@5.2.0: resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==} engines: {node: '>=12'} @@ -4372,9 +4414,15 @@ packages: resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} engines: {node: '>=10'} + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + character-entities-legacy@1.1.4: resolution: {integrity: sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==} + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + character-entities@1.2.4: resolution: {integrity: sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==} @@ -4478,6 +4526,9 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@12.1.0: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} @@ -4668,6 +4719,9 @@ packages: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} @@ -5366,6 +5420,12 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} @@ -5387,6 +5447,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + htmlparser2@10.0.0: resolution: {integrity: sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==} @@ -6195,6 +6258,9 @@ packages: mdast-util-from-markdown@0.8.5: resolution: {integrity: sha512-2hkTXtYYnr+NubD/g6KGBS/0mFmBcifAsI0yIWRiRo0PjVs6SSOSOdtzbp6kSGnShDN6G5aWZpKQ2lWRy27mWQ==} + mdast-util-to-hast@13.2.0: + resolution: {integrity: sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==} + mdast-util-to-string@2.0.0: resolution: {integrity: sha512-AW4DRS3QbBayY/jJmD8437V1Gombjf8RSOUCMFBuo5iHi58AGEgVCKQ+ezHkZZDpAQS75hcBMpLqjpJTjtUL7w==} @@ -6212,6 +6278,21 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + micromark@2.11.4: resolution: {integrity: sha512-+WoovN/ppKolQOFIAajxi7Lu9kInbPxFuTBVEavFcL8eAfVstoc5MocPmqBeAdBOJV00uaVjegzH4+MA0DN/uA==} @@ -6424,6 +6505,12 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + oniguruma-parser@0.12.1: + resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==} + + oniguruma-to-es@4.3.3: + resolution: {integrity: sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg==} + open@10.1.2: resolution: {integrity: sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw==} engines: {node: '>=18'} @@ -6739,6 +6826,9 @@ packages: resolution: {integrity: sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg==} engines: {node: '>=18'} + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + prr@1.0.1: resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==} @@ -6848,6 +6938,15 @@ packages: regenerate@1.4.2: resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.0.1: + resolution: {integrity: sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA==} + regexp-ast-analysis@0.7.1: resolution: {integrity: sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} @@ -7009,6 +7108,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shiki@3.12.2: + resolution: {integrity: sha512-uIrKI+f9IPz1zDT+GMz+0RjzKJiijVr6WDWm9Pe3NNY6QigKCfifCEv9v9R2mDASKKjzjQ2QpFLcxaR3iHSnMA==} + side-channel-list@1.0.0: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} @@ -7101,6 +7203,9 @@ packages: engines: {node: '>= 8'} deprecated: The work that was done in this beta branch won't be included in future versions + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + spawndamnit@3.0.1: resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} @@ -7151,6 +7256,9 @@ packages: string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -7378,6 +7486,9 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + ts-api-utils@1.3.0: resolution: {integrity: sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==} engines: {node: '>=16'} @@ -7512,9 +7623,24 @@ packages: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} + unist-util-is@6.0.0: + resolution: {integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + unist-util-stringify-position@2.0.3: resolution: {integrity: sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==} + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.1: + resolution: {integrity: sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==} + + unist-util-visit@5.0.0: + resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -7574,6 +7700,12 @@ packages: resolution: {integrity: sha512-gjb0ARm9qlcBAonU4zPwkl9ecKkas+tC2CGwFfptTCWWIVTWY1YUbT2zZKsOAF1jR/tNxxyLwwG0cb42XlYcTg==} engines: {node: '>=4'} + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-node@2.1.8: resolution: {integrity: sha512-uPAwSr57kYjAUux+8E2j0q0Fxpn8M9VoyfGiRI8Kfktz9NcYMCenwY5RnZxnF1WTu3TGiYipirIzacLL3VVGFg==} engines: {node: ^18.0.0 || >=20.0.0} @@ -7957,6 +8089,9 @@ packages: zod@4.1.5: resolution: {integrity: sha512-rcUUZqlLJgBC33IT3PNMgsCq6TzLQEG/Ei/KTCU0PedSWRMAXoOUN+4t/0H+Q8bdnLPdqUYnvboJT0bn/229qg==} + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + snapshots: '@adobe/css-tools@4.3.3': {} @@ -10921,6 +11056,39 @@ snapshots: '@secretlint/types@10.2.0': {} + '@shikijs/core@3.12.2': + dependencies: + '@shikijs/types': 3.12.2 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@3.12.2': + dependencies: + '@shikijs/types': 3.12.2 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.3 + + '@shikijs/engine-oniguruma@3.12.2': + dependencies: + '@shikijs/types': 3.12.2 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@3.12.2': + dependencies: + '@shikijs/types': 3.12.2 + + '@shikijs/themes@3.12.2': + dependencies: + '@shikijs/types': 3.12.2 + + '@shikijs/types@3.12.2': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + '@sinclair/typebox@0.27.8': {} '@sinclair/typebox@0.34.37': {} @@ -11302,6 +11470,10 @@ snapshots: dependencies: '@types/node': 24.3.0 + '@types/hast@3.0.4': + dependencies: + '@types/unist': 2.0.10 + '@types/istanbul-lib-coverage@2.0.6': {} '@types/istanbul-lib-report@3.0.3': @@ -11333,6 +11505,10 @@ snapshots: dependencies: '@types/unist': 2.0.10 + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + '@types/node@12.20.55': {} '@types/node@24.3.0': @@ -11362,6 +11538,8 @@ snapshots: '@types/unist@2.0.10': {} + '@types/unist@3.0.3': {} + '@types/vscode-webview@1.57.5': {} '@types/vscode@1.103.0': {} @@ -12183,6 +12361,8 @@ snapshots: caniuse-lite@1.0.30001737: {} + ccount@2.0.1: {} + chai@5.2.0: dependencies: assertion-error: 2.0.1 @@ -12208,8 +12388,12 @@ snapshots: char-regex@1.0.2: {} + character-entities-html4@2.1.0: {} + character-entities-legacy@1.1.4: {} + character-entities-legacy@3.0.0: {} + character-entities@1.2.4: {} character-reference-invalid@1.1.4: {} @@ -12320,6 +12504,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + comma-separated-tokens@2.0.3: {} + commander@12.1.0: {} commander@4.1.1: {} @@ -12458,6 +12644,10 @@ snapshots: detect-newline@3.1.0: {} + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + didyoumean@1.2.2: {} diff-sequences@29.6.3: {} @@ -13335,6 +13525,24 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + hosted-git-info@2.8.9: {} hosted-git-info@4.1.0: @@ -13353,6 +13561,8 @@ snapshots: html-escaper@2.0.2: {} + html-void-elements@3.0.0: {} + htmlparser2@10.0.0: dependencies: domelementtype: 2.3.0 @@ -14520,6 +14730,18 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-to-hast@13.2.0: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + mdast-util-to-string@2.0.0: {} mdurl@2.0.0: {} @@ -14532,6 +14754,23 @@ snapshots: merge2@1.4.1: {} + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-encode@2.0.1: {} + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + micromark@2.11.4: dependencies: debug: 4.4.1 @@ -14736,6 +14975,14 @@ snapshots: dependencies: mimic-fn: 2.1.0 + oniguruma-parser@0.12.1: {} + + oniguruma-to-es@4.3.3: + dependencies: + oniguruma-parser: 0.12.1 + regex: 6.0.1 + regex-recursion: 6.0.2 + open@10.1.2: dependencies: default-browser: 5.2.1 @@ -15044,6 +15291,8 @@ snapshots: dependencies: parse-ms: 4.0.0 + property-information@7.1.0: {} + prr@1.0.1: optional: true @@ -15165,6 +15414,16 @@ snapshots: regenerate@1.4.2: {} + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.0.1: + dependencies: + regex-utilities: 2.3.0 + regexp-ast-analysis@0.7.1: dependencies: '@eslint-community/regexpp': 4.10.0 @@ -15420,6 +15679,17 @@ snapshots: shebang-regex@3.0.0: {} + shiki@3.12.2: + dependencies: + '@shikijs/core': 3.12.2 + '@shikijs/engine-javascript': 3.12.2 + '@shikijs/engine-oniguruma': 3.12.2 + '@shikijs/langs': 3.12.2 + '@shikijs/themes': 3.12.2 + '@shikijs/types': 3.12.2 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 @@ -15523,6 +15793,8 @@ snapshots: dependencies: whatwg-url: 7.1.0 + space-separated-tokens@2.0.2: {} + spawndamnit@3.0.1: dependencies: cross-spawn: 7.0.6 @@ -15581,6 +15853,11 @@ snapshots: safe-buffer: 5.2.1 optional: true + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -15819,6 +16096,8 @@ snapshots: tree-kill@1.2.2: {} + trim-lines@3.0.1: {} + ts-api-utils@1.3.0(typescript@5.9.2): dependencies: typescript: 5.9.2 @@ -15933,10 +16212,33 @@ snapshots: unicorn-magic@0.3.0: {} + unist-util-is@6.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position@2.0.3: dependencies: '@types/unist': 2.0.10 + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.1: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.0 + + unist-util-visit@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.0 + unist-util-visit-parents: 6.0.1 + universalify@0.1.2: {} universalify@2.0.1: {} @@ -16016,6 +16318,16 @@ snapshots: version-range@4.14.0: {} + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + vite-node@2.1.8(@types/node@24.3.0)(less@4.4.1)(lightningcss@1.30.1)(sass@1.91.0)(stylus@0.64.0): dependencies: cac: 6.7.14 @@ -16435,3 +16747,5 @@ snapshots: yoctocolors@2.1.1: {} zod@4.1.5: {} + + zwitch@2.0.4: {} From ddbdc70dbf387ba73d614804fb1f3b947aced6bd Mon Sep 17 00:00:00 2001 From: PoffM Date: Wed, 17 Sep 2025 01:24:15 -0400 Subject: [PATCH 15/98] recorder WIP - fix panel sizing getting stuck - fix radio button colors --- packages/web-view-vite/src/App.tsx | 7 +------ packages/web-view-vite/src/recorder/recorder-panel.tsx | 9 ++++----- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/packages/web-view-vite/src/App.tsx b/packages/web-view-vite/src/App.tsx index af5a844..6ee1df8 100644 --- a/packages/web-view-vite/src/App.tsx +++ b/packages/web-view-vite/src/App.tsx @@ -58,11 +58,7 @@ export function App() { style={{ visibility: firstPatchReceived() ? 'visible' : 'hidden' }} class="absolute h-full w-full flex flex-col" > -
+
{shadowHost}
@@ -78,7 +74,6 @@ export function App() { > - ( diff --git a/packages/web-view-vite/src/recorder/recorder-panel.tsx b/packages/web-view-vite/src/recorder/recorder-panel.tsx index 44ea571..561bd97 100644 --- a/packages/web-view-vite/src/recorder/recorder-panel.tsx +++ b/packages/web-view-vite/src/recorder/recorder-panel.tsx @@ -58,8 +58,8 @@ export function RecorderPanel() { } return ( -
-
+
+

Generated Code

@@ -72,19 +72,18 @@ export function RecorderPanel() {
           
-
+

Choose Mouse Event

{event => ( -
-

Choose Mouse Event

+

Choose Mouse Event

{event => (