mcpField
C
component-fixtures

component-fixtures

github.com/microsoft测试与质量194.4k273 次安装今天更新
代码仓库

关于 component-fixtures

component-fixtures 是用于 VS Code 组件截图测试的开发技能,面向创建和验证隔离渲染组件的开发者。它提供主题化夹具、服务辅助工具和异步渲染稳定性指导。

主要功能

  1. 01创建独立组件渲染夹具
  2. 02自动生成深色和浅色主题变体
  3. 03提供编辑器服务与测试辅助工具
  4. 04指导异步截图就绪检查
  5. 05支持通过组件浏览器截图验证

适用场景

  1. 01为 UI 组件创建截图测试
  2. 02检查浅色与深色主题下的组件外观
  3. 03排查夹具中的布局或异步渲染问题

MCP 相关

文档提及使用 `mcp_component-exp_` 工具列出夹具并捕获截图;未说明该技能本身实现 MCP 服务。

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/microsoft/vscode --skill component-fixtures

安装后在 Claude Code 中输入/component-fixtures确认已加载。

详细介绍

组件夹具

组件夹具通过组件浏览器渲染独立的 UI 组件,用于视觉截图测试。夹具位于 src/vs/workbench/test/browser/componentFixtures/ 中,Vite 开发服务器会通过 glob src/**/*.fixture.ts 自动发现它们。

使用 mcp_component-exp_* 工具列出夹具并截取截图。如果你看不到这些工具,请告知用户启用它们。

在本地运行夹具

  1. 启动组件浏览器服务器:运行 Component Explorer Server 任务
  2. 使用 mcp_component-exp_list_fixtures 工具查看所有可用夹具及其 URL
  3. 使用 mcp_component-exp_screenshot 工具以编程方式截取截图

文件结构

每个夹具文件都会导出一个默认的 defineThemedFixtureGroup(...)。文件必须以 .fixture.ts 结尾。

src/vs/workbench/test/browser/componentFixtures/
  fixtureUtils.ts              # Shared helpers (DO NOT import @vscode/component-explorer elsewhere)
  myComponent.fixture.ts       # Your fixture file

基本模式

import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from './fixtureUtils.js';

export default defineThemedFixtureGroup({ path: 'myFeature/' }, {
    Default: defineComponentFixture({ render: renderMyComponent }),
    AnotherVariant: defineComponentFixture({ render: renderMyComponent }),
});

function renderMyComponent({ container, disposableStore, theme }: ComponentFixtureContext): void {
    container.style.width = '400px';

    const instantiationService = createEditorServices(disposableStore, {
        colorTheme: theme,
        additionalServices: (reg) => {
            // Register additional services the component needs
            reg.define(IMyService, MyServiceImpl);
            reg.defineInstance(IMockService, mockInstance);
        },
    });

    const widget = disposableStore.add(
        instantiationService.createInstance(MyWidget, /* constructor args */)
    );
    container.appendChild(widget.domNode);
}

要点:

  • defineThemedFixtureGroup 会自动为每个夹具创建深色和浅色变体
  • defineComponentFixture 会为渲染函数封装主题设置、异步就绪处理和可释放对象管理
  • createEditorServices 会提供一个预先注册基础编辑器服务的 TestInstantiationService
  • 始终使用 disposableStore.add(...) 注册创建的控件,以防止资源泄漏
  • 将 colorTheme: theme 传递给 createEditorServices,以确保正确渲染主题颜色
文件图标主题

夹具默认使用 Seti 文件图标。可以在单个夹具上选择其他内置主题,或禁用文件图标:

defineComponentFixture({ fileIconTheme: 'vs-minimal', render: renderMyComponent });
defineComponentFixture({ fileIconTheme: 'none', render: renderMyComponent });

当渲染的组件读取 IThemeService 时,将选定的主题从 ComponentFixtureContext 传递给 createEditorServices:

function renderMyComponent({ disposableStore, theme, fileIconTheme }: ComponentFixtureContext): void {
	const instantiationService = createEditorServices(disposableStore, {
		colorTheme: theme,
		fileIconTheme,
	});
}

fixtureUtils.ts 中的实用工具

导出用途
defineComponentFixture根据渲染函数创建深色/浅色主题的夹具变体
defineThemedFixtureGroup将多个主题化夹具归入一个命名夹具组
createEditorServices创建包含所有基础编辑器服务的 TestInstantiationService
registerWorkbenchServices注册其他工作台服务(上下文菜单、标签等)
createTextModel通过 ModelService 为编辑器夹具创建文本模型
setupTheme将主题 CSS 应用到容器(由 defineComponentFixture 自动调用)
darkTheme / lightTheme预加载的 ColorThemeData 实例

重要: 只有 fixtureUtils.ts 可以从 @vscode/component-explorer 导入内容。所有夹具文件都必须通过 fixtureUtils.ts 中的辅助函数进行操作。

CSS 作用域

夹具共享文档和工作台样式表(isolation: 'none')。辅助函数会限定主题和文件图标样式的作用域,但 DOM、CSS 和进程级注册不会自动隔离。

共享主题初始化

fixtureUtilsCss.ts 会在生成并缓存主题 CSS 之前,加载共享的工作台颜色和尺寸注册,包括 vs/workbench/common/agentsColors.ts 和 agentsSizes.ts 中的 Agent 令牌。请将必需的全产品注册放在此共享引导代码中,而不要放在单个夹具内:发现另一个夹具不应改变已有夹具的字体或颜色。请复用所属共享层中的轻量级注册模块,而不要导入产品入口点或更高层的扩展。

匹配生产环境 CSS 选择器

许多 VS Code 组件的 CSS 规则限定在深层祖先选择器中(例如 .interactive-session .interactive-input-part > .widget-container .my-element)。在夹具中,必须重建这些选择器所需的祖先 DOM 结构,才能使其匹配:

function render({ container }: ComponentFixtureContext): void {
    container.classList.add('interactive-session');

    // Recreate ancestor structure that CSS selectors expect
    const inputPart = dom.$('.interactive-input-part');
    const widgetContainer = dom.$('.widget-container');
    inputPart.appendChild(widgetContainer);
    container.appendChild(inputPart);

    widgetContainer.appendChild(myWidget.domNode);
}

新组件设计建议: 避免使用依赖特定祖先元素的深层嵌套 CSS 选择器。使用自包含的类名(例如,使用 .my-widget .my-element,而不是 .parent-view .parent-part > .wrapper .my-element)。这样组件更便于创建夹具和复用。

服务

使用 createEditorServices

createEditorServices 会预先注册以下服务:IAccessibilityService、IKeybindingService、IClipboardService、IOpenerService、INotificationService、IDialogService、IUndoRedoService、ILanguageService、IConfigurationService、IStorageService、IThemeService、IModelService、ICodeEditorService、IContextKeyService、ICommandService、ITelemetryService、IHoverService、IUserInteractionService 等。

其他服务

通过 additionalServices 注册额外服务:

createEditorServices(disposableStore, {
    additionalServices: (reg) => {
        // Class-based (instantiated by DI):
        reg.define(IMyService, MyServiceImpl);
        // Instance-based (pre-constructed):
        reg.defineInstance(IMyService, myMockInstance);
    },
});
模拟服务

使用 base/test/common/mock.js 中的 mock<T>() 辅助函数创建模拟服务实例:

import { mock } from '../../../../base/test/common/mock.js';

const myService = new class extends mock<IMyService>() {
    override someMethod(): string { return 'test'; }
    override onSomeEvent = Event.None;
};
reg.defineInstance(IMyService, myService);

对于模拟视图模型或数据对象:

const element = new class extends mock<IChatRequestViewModel>() { }();

异步渲染

组件浏览器会等待渲染 Promise 完成,然后再等待两个动画帧。这些动画帧并不能保证图像解码、原生尺寸观察器或延迟布局已就绪。

渲染 Promise 必须仅在夹具达到其命名状态时才完成:

  • 等待所有会影响截图的资源加载完成。编辑器的 setInput() 可能会在主图像或缩略图加载完成之前返回;请等待预期的图像数量,并等待 decode() 完成;若图像缺失或损坏,则应失败。
  • 在定位视口之前,先完成会改变布局的操作。等待内容高度、滚动高度、滚动位置和有限动画稳定,然后滚动并再次等待稳定。断言“屏幕外”的页眉确实位于屏幕外,而“可见”的页眉没有被固定内容遮挡。
  • 考虑瞬态视觉效果。高度稳定并不意味着截图稳定。无头渲染在滚动或调整大小时不会闪现自动隐藏的滚动条,因此不要等待或隐藏它们;如果滚动条是预期状态的一部分,请使用 ScrollbarVisibility.Visible。
  • 通过现有服务或选项提供确定性的显示数据,例如单个自定义思考短语。仅使用带种子的随机生成器,并不能确保标签不受渲染顺序或异步回调顺序影响。
  • 使用有界、基于条件的等待,并在失败时抛出错误;不要使用固定时长的休眠或额外动画帧来默默接受未完成的状态。

将夹具设计为状态 + 局部输入 + 明确的就绪条件。请提供本地账户图标,而不要让模拟对象回退到在线头像 URL,并等待图标解码;waitForFixtureCondition 会在保留虚拟时钟的同时轮询原生浏览器帧。要等待 DOM 状态(例如元素、类、属性或文本出现),请使用 waitForFixtureDomCondition,它会在每次 DOM 变更后重新检查条件;如果条件始终不成立,则会在浏览器的夹具超时后失败。优先使用共享的 DOM 加载指示器,而不是动画 SVG 背景:祖先元素的 CSS 无法停止图像内部的动画。

依赖原生浏览器图像解码或尺寸/滚动回调的夹具,可以使用 virtualTime: { enabled: false } 并显式等待这些操作完成。JavaScript 虚拟时间不会推进浏览器的图像解码器或布局流水线。

对于边缘绘制顺序敏感的异步夹具,deferPaint: true 会让无头夹具在渲染完成前保持透明,而不会改变其布局或交互式预览。它可以防止中间绘制影响最终光栅化;但不能替代等待就绪。

验证 fixture

不要添加以 fixture 或 fixture 基础设施为测试对象的自动化测试(包括仅针对 fixture 的单元测试、Playwright 测试和截图稳定性测试)。使用现有的 Component Explorer 渲染、截图、稳定性和错误工具验证 fixture 变更,并辅以直接检查或临时浏览器探测。生产行为的常规测试应放在所属的生产 API 中。

在 CI 使用的同一就绪边界进行验证:在无头模式下 renderFixture() 解析后、空闲回调执行后,以及另一个 fixture 挂载后重新挂载时,立即捕获截图。在同一平台上比较精确哈希,并单独检查预期状态;不要将临时探测提交为 fixture 测试。

陷阱:重新挂载 DOM 会导致闪烁

避免在初次渲染后将已渲染的组件移到不同的 DOM 父节点下。这会导致:

  • 重新计算布局(position: absolute 坐标失效,组件随之跳动)
  • 焦点丢失(blur 事件可能触发 QuickInput 等组件中的隐藏逻辑)
  • 截图不稳定(组件浏览器可能会捕获到中间布局状态)

错误模式——异步等待后重新挂载组件:

async function render({ container }: ComponentFixtureContext): Promise<void> {
    const host = document.createElement('div');
    container.appendChild(host);
    // ... create widget inside host ...
    await waitForWidget();
    container.appendChild(widget);  // BAD: reparenting causes flicker
    host.remove();
}

更好的模式——从一开始就在正确的 DOM 结构中原位渲染:

function render({ container }: ComponentFixtureContext): void {
    // Set up the correct DOM structure first, then create the widget inside it
    const widget = createWidget(container);
    container.appendChild(widget.domNode);
}

如果组件确实需要异步设置(例如在内部进行渲染的 QuickInput),则应从一开始就将宿主容器构造成最终布局的样子,以尽量减少组件出现后的 DOM 操作。

为 fixture 调整现有组件

现有组件通常只需稍作修改即可用于 fixture。当编写 fixture 遇到阻碍时,应修复组件,而不是在 fixture 中绕过问题。常见的调整包括:

将 CSS 与祖先上下文解耦

如果组件的 CSS 只有在 .workbench .sidebar .my-view .my-widget 这类深层嵌套选择器下才能生效,请重构 CSS,使其自成一体。将样式移到组件自身的根类作用域下:

/* Before: requires specific ancestors */
.workbench .sidebar .my-view .my-widget .header { font-weight: bold; }

/* After: self-contained */
.my-widget .header { font-weight: bold; }

如果组件与其父级共享样式(例如继承背景色),请使用 CSS 自定义属性,而不要依赖祖先选择器。

提取硬编码的服务依赖

如果组件直接访问单例或全局状态,而不是使用 DI,请重构组件,通过构造函数接收服务:

// Before: hard to mock in fixtures
class MyWidget {
    private readonly config = getSomeGlobalConfig();
}

// After: injectable and testable
class MyWidget {
    constructor(@IConfigurationService private readonly configService: IConfigurationService) { }
}
添加用于控制自动聚焦和动画的选项

创建时自动聚焦或运行动画的组件会导致截图不稳定。请添加一个选项参数:

interface IMyWidgetOptions {
    shouldAutoFocus?: boolean;
}

fixture 会传入 shouldAutoFocus: false。生产调用点则保留默认行为。

公开内部状态,以支持直接渲染“已完成”状态

许多组件都具有生命周期状态(loading → active → completed)。如果组件只能通过用户交互进入“已完成”状态,请添加支持,使其能够通过构造函数数据直接初始化为该状态:

// The fixture can pass pre-filled data to render the summary/completed state
// without simulating the full user interaction flow.
const carousel: IChatQuestionCarousel = {
    questions,
    allowSkip: true,
    kind: 'questionCarousel',
    isUsed: true,           // Already completed
    data: { 'q1': 'answer' }, // Pre-filled answers
};
让 DOM 节点可访问

如果组件在内部构建 DOM,却未公开根元素,请添加一个公开的 readonly domNode: HTMLElement 属性,以便 fixture 将其附加到容器中。

不要将仅用于测试的操作加入生产 API

当 fixture 或测试必须驱动生产环境中只能通过用户输入或服务触发的状态(例如聚焦、选择或折叠行,或定位已渲染的行)时,不要为此添加公开方法:其他代码会开始调用这些方法,从而绕过真实流程。将最少量的方法设为 protected,并在 test/ 下的仅供测试使用的子类中实现这些操作(例如 TestSessionsList extends SessionsList),这样产品代码就无法导入它。

编写适用于 fixture 的组件

设计新的 UI 组件时,请遵循以下做法,使其易于用于 fixture:

1. 在构造函数中接收容器元素
// Good: container is passed in
class MyWidget {
    constructor(container: HTMLElement, @IFoo foo: IFoo) {
        this.domNode = dom.append(container, dom.$('.my-widget'));
    }
}

// Also good: widget creates its own domNode for the caller to place
class MyWidget {
    readonly domNode: HTMLElement;
    constructor(@IFoo foo: IFoo) {
        this.domNode = dom.$('.my-widget');
    }
}
2. 对所有服务使用依赖注入

所有外部依赖都应通过 DI 提供,以便测试固件可以提供测试实现:

// Good: services injected
constructor(@IThemeService private readonly themeService: IThemeService) { }

// Bad: reaching into globals
constructor() { this.theme = getGlobalTheme(); }
3. 保持 CSS 选择器简洁
/* Good: self-contained, easy to fixture */
.my-widget .my-header { ... }
.my-widget .my-list-item { ... }

/* Bad: requires deep ancestor chain */
.workbench .sidebar .my-view .my-widget .my-header { ... }
4. 避免在构造期间读取布局或窗口服务

在构造期间测量窗口或读取布局尺寸的组件很难用于测试固件,因为阴影 DOM 容器的尺寸与工作台不同:

// Prefer: use CSS for sizing, or accept dimensions as parameters
container.style.width = '400px';
container.style.height = '300px';

// Avoid: reading from layoutService during construction
const width = this.layoutService.mainContainerDimension.width;
5. 支持在测试固件中禁用自动聚焦

自动聚焦可能影响截图的稳定性。提供禁用自动聚焦的选项:

interface IMyWidgetOptions {
    shouldAutoFocus?: boolean;  // Fixtures pass false
}
6. 暴露 DOM 节点

测试固件需要将组件的 DOM 添加到容器中。将其公开为 readonly domNode: HTMLElement。

7. 让悬停和聚焦状态可渲染

测试固件无法触发 CSS :hover,而 DOM 焦点只能通过 ctx.focus() 确定性地设置。让依赖交互的视觉效果可以通过状态触发:

  • 为每个视觉效果依赖的 :hover 选择器配对一个 .hovered 类,例如 .monaco-list-row:is(:hover, .hovered);:is() 可保持优先级不变。列表组件已将 .monaco-list-row.hovered 设置为与悬停行相同的样式。
  • 通过组件设置焦点和选中状态(列表行会根据列表特征添加 .focused 和 .selected),如果生产环境没有相应 API,则通过仅用于测试的子类设置(参见将仅用于测试的操作排除在生产 API 之外)。当状态需要 DOM 焦点时,再调用 ctx.focus()。
  • 如果 JavaScript 也会响应悬停(例如 mouseover 监听器),除了添加 .hovered,还要在元素上分发 mouseover 事件。

src/vs/sessions/contrib/sessions/test/browser/sessionsListFixtureUtils.ts 以这种方式渲染 interaction: { hovered, focused, selected }。

多个测试固件变体

创建不同变体,以展示同一组件的不同状态:

export default defineThemedFixtureGroup({
    // Different data states
    Empty: defineComponentFixture({ render: (ctx) => renderWidget(ctx, { items: [] }) }),
    WithItems: defineComponentFixture({ render: (ctx) => renderWidget(ctx, { items: sampleItems }) }),

    // Different configurations
    ReadOnly: defineComponentFixture({ render: (ctx) => renderWidget(ctx, { readonly: true }) }),
    Editable: defineComponentFixture({ render: (ctx) => renderWidget(ctx, { readonly: false }) }),

    // Lifecycle states
    Loading: defineComponentFixture({ render: (ctx) => renderWidget(ctx, { state: 'loading' }) }),
    Completed: defineComponentFixture({ render: (ctx) => renderWidget(ctx, { state: 'done' }) }),
});

经验总结

请根据开发测试固件的经验更新本节!

  • 不要将组件复制到测试固件中再进行修改。始终让原始组件适配测试固件,然后在测试固件中渲染它。这样可以确保测试固件测试的是实际组件代码和生命周期,而不是可能掩盖错误的修改版本。

  • 不要在测试固件中重新组合子组件。 不要手动实例化并添加应由父组件创建的子组件(例如工具栏内容组件)。应正确配置父组件(例如设置正确的编辑器选项、注册正确的提供程序),让子组件通过正常代码路径出现。手动重新组合会掩盖集成错误,也无法测试真实的组件生命周期。

  • 对于依赖大量服务的组件,用数据描述状态。 如果组件会读取许多服务,为其提供一个测试框架,让它接收普通状态,并通过实际负责状态的服务和生产菜单进行渲染,而不是为每个测试固件创建模拟对象并使用 DOM 变通方法。这样,测试固件只需列出数据;添加新功能时,只需向测试框架添加状态字段。会话列表测试框架(sessionsListFixtureUtils.ts)就采用了这种模式;其中真实的工具栏暴露出了存根菜单掩盖的标头错误。

  • 切勿针对每个测试固件改变进程级注册。 资源管理器 UI 会同时挂载一个文件夹中的所有测试固件,它们共用命令、菜单和其他全局注册表。共享操作只注册一次,并在所有测试固件中保持一致;通过测试固件自身的服务(例如其菜单服务)改变每个测试固件的呈现方式。截图运行会逐个渲染测试固件,无法发现挂载顺序竞争问题,因此请预览整个文件夹(___explorer?fixture=<folder>)进行检查。

评论

支持 Markdown