diff --git a/.github/workflows/screenshot-test.yml b/.github/workflows/screenshot-test.yml index d6f7edb1cbe..73e5a3864ba 100644 --- a/.github/workflows/screenshot-test.yml +++ b/.github/workflows/screenshot-test.yml @@ -24,6 +24,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + lfs: true - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/.vscode/launch.json b/.vscode/launch.json index 74dfd6a3da6..d116d2c0033 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -604,12 +604,13 @@ }, { "name": "Component Explorer", - "type": "chrome", + "type": "msedge", + "port": 9230, "request": "launch", - "url": "http://localhost:5199/___explorer", - "preLaunchTask": "Launch Monaco Editor Vite", + "url": "http://localhost:5337/___explorer", + "preLaunchTask": "Launch Component Explorer", "presentation": { - "group": "monaco", + "group": "1_component_explorer", "order": 4 } }, diff --git a/.vscode/mcp.json b/.vscode/mcp.json index 08be33e5196..713ca79a4af 100644 --- a/.vscode/mcp.json +++ b/.vscode/mcp.json @@ -3,9 +3,25 @@ "vscode-automation-mcp": { "type": "stdio", "command": "npm", - // Look at the [README](../test/mcp/README.md) to see what arguments are supported - "args": ["run", "start-stdio"], + "args": [ + "run", + "start-stdio" + ], "cwd": "${workspaceFolder}/test/mcp" + }, + "component-explorer": { + "type": "stdio", + "command": "npx", + "cwd": "${workspaceFolder}", + "args": [ + "component-explorer", + "mcp", + "-c", + "./test/componentFixtures/component-explorer.json", + "--no-daemon-autostart", + "--no-daemon-hint", + "Start the daemon by running the 'Launch Component Explorer' VS Code task (use the run_task tool). When you start the task, try up to 4 times to give the daemon enough time to start." + ] } }, "inputs": [] diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 03fe5fb263f..40a4b1784f4 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -357,6 +357,13 @@ "problemMatcher": [ "$tsc" ] + }, + { + "label": "Launch Component Explorer", + "type": "shell", + "command": "npx component-explorer serve -c ./test/componentFixtures/component-explorer.json", + "isBackground": true, + "problemMatcher": [] } ] } diff --git a/src/vs/workbench/test/browser/componentFixtures/aiStats.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/aiStats.fixture.ts index 7df2b70d7d5..001d6010501 100644 --- a/src/vs/workbench/test/browser/componentFixtures/aiStats.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/aiStats.fixture.ts @@ -72,7 +72,7 @@ interface RenderAiStatsOptions extends ComponentFixtureContext { data: IAiStatsHoverData; } -function renderAiStatsHover({ container, disposableStore, data }: RenderAiStatsOptions): HTMLElement { +function renderAiStatsHover({ container, disposableStore, data }: RenderAiStatsOptions): void { container.style.width = '320px'; container.style.padding = '8px'; container.style.backgroundColor = 'var(--vscode-editorHoverWidget-background)'; @@ -87,6 +87,4 @@ function renderAiStatsHover({ container, disposableStore, data }: RenderAiStatsO const elem = hover.keepUpdated(disposableStore).element; container.appendChild(elem); - - return container; } diff --git a/src/vs/workbench/test/browser/componentFixtures/baseUI.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/baseUI.fixture.ts index 19579a21ab1..0c8ab56d71f 100644 --- a/src/vs/workbench/test/browser/componentFixtures/baseUI.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/baseUI.fixture.ts @@ -113,7 +113,7 @@ const themedProgressBarOptions = { // Buttons // ============================================================================ -function renderButtons({ container, disposableStore }: ComponentFixtureContext): HTMLElement { +function renderButtons({ container, disposableStore }: ComponentFixtureContext): void { container.style.padding = '16px'; container.style.display = 'flex'; container.style.flexDirection = 'column'; @@ -162,11 +162,9 @@ function renderButtons({ container, disposableStore }: ComponentFixtureContext): const disabledSecondary = disposableStore.add(new Button(disabledSection, { ...themedButtonStyles, secondary: true, title: 'Disabled Secondary', disabled: true })); disabledSecondary.label = 'Disabled Secondary'; disabledSecondary.enabled = false; - - return container; } -function renderButtonBar({ container, disposableStore }: ComponentFixtureContext): HTMLElement { +function renderButtonBar({ container, disposableStore }: ComponentFixtureContext): void { container.style.padding = '16px'; container.style.display = 'flex'; container.style.flexDirection = 'column'; @@ -193,8 +191,6 @@ function renderButtonBar({ container, disposableStore }: ComponentFixtureContext const buttonWithDesc = disposableStore.add(new ButtonWithDescription(descContainer, { ...themedButtonStyles, title: 'Install Extension', supportIcons: true })); buttonWithDesc.label = '$(extensions) Install Extension'; buttonWithDesc.description = 'This will install the extension and enable it globally'; - - return container; } @@ -202,7 +198,7 @@ function renderButtonBar({ container, disposableStore }: ComponentFixtureContext // Toggles and Checkboxes // ============================================================================ -function renderToggles({ container, disposableStore }: ComponentFixtureContext): HTMLElement { +function renderToggles({ container, disposableStore }: ComponentFixtureContext): void { container.style.padding = '16px'; container.style.display = 'flex'; container.style.flexDirection = 'column'; @@ -266,8 +262,6 @@ function renderToggles({ container, disposableStore }: ComponentFixtureContext): checkboxSection.appendChild(createCheckboxRow('Enable auto-save', true)); checkboxSection.appendChild(createCheckboxRow('Show line numbers', true)); checkboxSection.appendChild(createCheckboxRow('Word wrap', false)); - - return container; } @@ -275,7 +269,7 @@ function renderToggles({ container, disposableStore }: ComponentFixtureContext): // Input Boxes // ============================================================================ -function renderInputBoxes({ container, disposableStore }: ComponentFixtureContext): HTMLElement { +function renderInputBoxes({ container, disposableStore }: ComponentFixtureContext): void { container.style.padding = '16px'; container.style.display = 'flex'; container.style.flexDirection = 'column'; @@ -321,8 +315,6 @@ function renderInputBoxes({ container, disposableStore }: ComponentFixtureContex })); errorInput.value = 'invalid-email'; errorInput.validate(); - - return container; } @@ -330,7 +322,7 @@ function renderInputBoxes({ container, disposableStore }: ComponentFixtureContex // Count Badges // ============================================================================ -function renderCountBadges({ container }: ComponentFixtureContext): HTMLElement { +function renderCountBadges({ container }: ComponentFixtureContext): void { container.style.padding = '16px'; container.style.display = 'flex'; container.style.gap = '12px'; @@ -353,8 +345,6 @@ function renderCountBadges({ container }: ComponentFixtureContext): HTMLElement new CountBadge(badgeContainer, { count }, themedBadgeStyles); container.appendChild(badgeContainer); } - - return container; } @@ -362,7 +352,7 @@ function renderCountBadges({ container }: ComponentFixtureContext): HTMLElement // Action Bar // ============================================================================ -function renderActionBar({ container, disposableStore }: ComponentFixtureContext): HTMLElement { +function renderActionBar({ container, disposableStore }: ComponentFixtureContext): void { container.style.padding = '16px'; container.style.display = 'flex'; container.style.flexDirection = 'column'; @@ -410,8 +400,6 @@ function renderActionBar({ container, disposableStore }: ComponentFixtureContext new Action('action.disabled', 'Disabled', ThemeIcon.asClassName(Codicon.debugPause), false, async () => { }), new Action('action.enabled2', 'Enabled', ThemeIcon.asClassName(Codicon.debugStop), true, async () => { }), ]); - - return container; } @@ -419,7 +407,7 @@ function renderActionBar({ container, disposableStore }: ComponentFixtureContext // Progress Bar // ============================================================================ -function renderProgressBars({ container, disposableStore }: ComponentFixtureContext): HTMLElement { +function renderProgressBars({ container, disposableStore }: ComponentFixtureContext): void { container.style.padding = '16px'; container.style.display = 'flex'; container.style.flexDirection = 'column'; @@ -470,8 +458,6 @@ function renderProgressBars({ container, disposableStore }: ComponentFixtureCont const doneBar = disposableStore.add(new ProgressBar(doneSection, themedProgressBarOptions)); doneBar.total(100); doneBar.worked(100); - - return container; } @@ -479,7 +465,7 @@ function renderProgressBars({ container, disposableStore }: ComponentFixtureCont // Highlighted Label // ============================================================================ -function renderHighlightedLabels({ container }: ComponentFixtureContext): HTMLElement { +function renderHighlightedLabels({ container }: ComponentFixtureContext): void { container.style.padding = '16px'; container.style.display = 'flex'; container.style.flexDirection = 'column'; @@ -511,6 +497,4 @@ function renderHighlightedLabels({ container }: ComponentFixtureContext): HTMLEl container.appendChild(createHighlightedLabel('inlineCompletionsController.ts', [{ start: 6, end: 10 }])); // "Comp" container.appendChild(createHighlightedLabel('diffEditorViewModel.ts', [{ start: 0, end: 4 }, { start: 10, end: 14 }])); // "diff" and "View" container.appendChild(createHighlightedLabel('workbenchTestServices.ts', [{ start: 9, end: 13 }])); // "Test" - - return container; } diff --git a/src/vs/workbench/test/browser/componentFixtures/codeEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/codeEditor.fixture.ts index af7ff834637..c752df7da14 100644 --- a/src/vs/workbench/test/browser/componentFixtures/codeEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/codeEditor.fixture.ts @@ -31,7 +31,7 @@ console.log(greet('World')); console.log(\`Count: \${counter.count}\`); `; -function renderCodeEditor({ container, disposableStore, theme }: ComponentFixtureContext): HTMLElement { +function renderCodeEditor({ container, disposableStore, theme }: ComponentFixtureContext): void { container.style.width = '600px'; container.style.height = '400px'; container.style.border = '1px solid var(--vscode-editorWidget-border)'; @@ -66,8 +66,6 @@ function renderCodeEditor({ container, disposableStore, theme }: ComponentFixtur )); editor.setModel(model); - - return container; } export default defineThemedFixtureGroup({ diff --git a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts index 0aa45800518..62bbbcb65d7 100644 --- a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts @@ -95,6 +95,7 @@ import '../../../../platform/theme/common/colors/editorColors.js'; import '../../../../platform/theme/common/colors/listColors.js'; import '../../../../platform/theme/common/colors/miscColors.js'; import '../../../common/theme.js'; +import { isThenable } from '../../../../base/common/async.js'; /** * A storage service that never stores anything and always returns the default/fallback value. @@ -478,7 +479,7 @@ export interface ComponentFixtureContext { } export interface ComponentFixtureOptions { - render: (context: ComponentFixtureContext) => HTMLElement | Promise; + render: (context: ComponentFixtureContext) => void | Promise; } type ThemedFixtures = ReturnType; @@ -486,6 +487,10 @@ type ThemedFixtures = ReturnType; /** * Creates Dark and Light fixture variants from a single render function. * The render function receives a context with container and disposableStore. + * + * Note: If render returns a Promise, the async work will run in background. + * Component-explorer waits 2 animation frames after sync render returns, + * which should be sufficient for most async setup, but timing is not guaranteed. */ export function defineComponentFixture(options: ComponentFixtureOptions): ThemedFixtures { const createFixture = (theme: typeof darkTheme | typeof lightTheme) => defineFixture({ @@ -496,8 +501,9 @@ export function defineComponentFixture(options: ComponentFixtureOptions): Themed render: (container: HTMLElement) => { const disposableStore = new DisposableStore(); setupTheme(container, theme); - options.render({ container, disposableStore, theme }); - return disposableStore; + // Start render (may be async) - component-explorer will wait 2 rAF after this returns + const result = options.render({ container, disposableStore, theme }); + return isThenable(result) ? result.then(() => disposableStore) : disposableStore; }, }); diff --git a/src/vs/workbench/test/browser/componentFixtures/inlineCompletions.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/inlineCompletions.fixture.ts index 3f30440013a..8d4fe0ae306 100644 --- a/src/vs/workbench/test/browser/componentFixtures/inlineCompletions.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/inlineCompletions.fixture.ts @@ -33,7 +33,7 @@ interface InlineEditOptions extends ComponentFixtureContext { editorOptions?: IEditorOptions; } -function renderInlineEdit(options: InlineEditOptions): HTMLElement { +function renderInlineEdit(options: InlineEditOptions): void { const { container, disposableStore, theme } = options; container.style.width = options.width ?? '500px'; container.style.height = options.height ?? '170px'; @@ -100,8 +100,6 @@ function renderInlineEdit(options: InlineEditOptions): HTMLElement { // Trigger inline completions const controller = InlineCompletionsController.get(editor); controller?.model?.get(); - - return container; } diff --git a/src/vs/workbench/test/browser/componentFixtures/promptFilePickers.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/promptFilePickers.fixture.ts index 6c3b453b0a6..6692adf6bcd 100644 --- a/src/vs/workbench/test/browser/componentFixtures/promptFilePickers.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/promptFilePickers.fixture.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { mainWindow } from '../../../../base/browser/window.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Event } from '../../../../base/common/event.js'; import { ResourceSet } from '../../../../base/common/map.js'; @@ -85,15 +86,7 @@ export default defineThemedFixtureGroup({ }), }); -async function renderPromptFilePickerFixture({ container, disposableStore, theme, type, placeholder, seedData }: RenderPromptPickerOptions): Promise { - container.style.width = 'fit-content'; - container.style.minHeight = '0'; - container.style.padding = '8px'; - container.style.boxSizing = 'border-box'; - container.style.background = 'var(--vscode-editor-background)'; - container.style.border = '1px solid var(--vscode-editorWidget-border)'; - container.style.position = 'relative'; - +async function renderPromptFilePickerFixture({ container, disposableStore, theme, type, placeholder, seedData }: RenderPromptPickerOptions): Promise { const quickInputHost = document.createElement('div'); quickInputHost.style.position = 'relative'; const hostWidth = 800; @@ -207,5 +200,45 @@ async function renderPromptFilePickerFixture({ container, disposableStore, theme type, }); - return container; + // Wait for the quickpick widget to render and have dimensions + const quickInputWidget = await waitForElement( + quickInputHost, + '.quick-input-widget', + el => el.offsetWidth > 0 && el.offsetHeight > 0 + ); + + if (quickInputWidget) { + // Reset positioning + quickInputWidget.style.position = 'relative'; + quickInputWidget.style.top = '0'; + quickInputWidget.style.left = '0'; + + // Move widget to container and remove host + container.appendChild(quickInputWidget); + quickInputHost.remove(); + + // Set explicit dimensions on container to match widget + const rect = quickInputWidget.getBoundingClientRect(); + container.style.width = `${rect.width}px`; + container.style.height = `${rect.height}px`; + } +} + +async function waitForElement( + root: HTMLElement, + selector: string, + condition: (el: T) => boolean, + timeout = 2000 +): Promise { + const start = Date.now(); + while (Date.now() - start < timeout) { + const el = root.querySelector(selector); + if (el && condition(el)) { + // Wait one more frame to ensure layout is complete + await new Promise(resolve => mainWindow.requestAnimationFrame(resolve)); + return el; + } + await new Promise(resolve => setTimeout(resolve, 10)); + } + return root.querySelector(selector); } diff --git a/test/componentFixtures/component-explorer-config.schema.json b/test/componentFixtures/component-explorer-config.schema.json new file mode 100644 index 00000000000..3d129dc3b7e --- /dev/null +++ b/test/componentFixtures/component-explorer-config.schema.json @@ -0,0 +1,183 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "screenshotDir": { + "description": "Directory for storing screenshots (default: .screenshots)", + "type": "string" + }, + "sessions": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Unique session name" + }, + "source": { + "anyOf": [ + { + "type": "string", + "const": "current" + }, + { + "type": "object", + "properties": { + "worktree": { + "type": "object", + "properties": { + "ref": { + "type": "string", + "description": "Git ref (branch, tag, or commit) to check out" + }, + "name": { + "description": "Directory name for the worktree (default: component-explorer-baseline)", + "type": "string" + }, + "install": { + "anyOf": [ + { + "type": "string", + "const": "auto" + }, + { + "type": "string", + "const": "npm" + }, + { + "type": "string", + "const": "pnpm" + }, + { + "type": "string", + "const": "yarn" + }, + { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "Custom install command to run in the worktree" + } + }, + "required": [ + "command" + ], + "additionalProperties": false + }, + { + "type": "boolean", + "const": false + } + ], + "description": "Dependency install strategy for the worktree" + } + }, + "required": [ + "ref" + ], + "additionalProperties": false, + "description": "Git worktree configuration for a baseline session" + } + }, + "required": [ + "worktree" + ], + "additionalProperties": false + } + ], + "description": "Session source: \"current\" for the working tree, or a worktree config for a baseline" + }, + "viteConfig": { + "description": "Path to vite config file, relative to this config (overrides top-level viteConfig)", + "type": "string" + } + }, + "required": [ + "name" + ], + "additionalProperties": false, + "description": "A component explorer session" + }, + "description": "List of explorer sessions" + }, + "compare": { + "type": "object", + "properties": { + "baseline": { + "type": "string", + "description": "Session name to use as the baseline for comparisons" + }, + "current": { + "type": "string", + "description": "Session name to use as the current version for comparisons" + } + }, + "required": [ + "baseline", + "current" + ], + "additionalProperties": false, + "description": "Screenshot comparison configuration" + }, + "viteConfig": { + "description": "Default vite config file path, relative to this config (default: vite.config.ts)", + "type": "string" + }, + "vite": { + "type": "object", + "properties": { + "hmr": { + "type": "object", + "properties": { + "allowedPaths": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Glob patterns for files that keep HMR; everything else triggers a full reload" + } + }, + "required": [ + "allowedPaths" + ], + "additionalProperties": false, + "description": "Vite HMR configuration" + } + }, + "additionalProperties": false, + "description": "Vite configuration overrides" + }, + "redirection": { + "type": "object", + "properties": { + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535, + "description": "Port for the redirection HTTP server" + }, + "host": { + "description": "Host to bind the redirection server to (default: localhost)", + "type": "string" + } + }, + "required": [ + "port" + ], + "additionalProperties": false, + "description": "HTTP redirection server that redirects to the session URL" + }, + "$schema": { + "type": "string", + "description": "URL of the JSON Schema for this config file" + } + }, + "required": [ + "sessions" + ], + "additionalProperties": false, + "description": "Component Explorer configuration" +} diff --git a/test/componentFixtures/component-explorer.json b/test/componentFixtures/component-explorer.json index 12a3fd30d42..2f24a100b02 100644 --- a/test/componentFixtures/component-explorer.json +++ b/test/componentFixtures/component-explorer.json @@ -1,10 +1,14 @@ { + "$schema": "./component-explorer-config.schema.json", "screenshotDir": ".screenshots", "sessions": [ { "name": "current" } ], + "redirection": { + "port": 5337 + }, "viteConfig": "../../build/vite/vite.config.ts", "vite": { "hmr": {