diff --git a/.github/workflows/webview-screenshots.yml b/.github/workflows/webview-screenshots.yml
new file mode 100644
index 0000000000..307b1f05c4
--- /dev/null
+++ b/.github/workflows/webview-screenshots.yml
@@ -0,0 +1,87 @@
+name: Webview screenshots
+
+on:
+ pull_request:
+ push:
+ branches: [main]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ render:
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 2
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ cache: npm
+ - run: npm install --no-save
+ - run: npx --no-install playwright install --with-deps chromium
+ - run: npm run explorer:check
+ - run: npm run explorer:render -- --workers 1
+ - name: Require screenshots without fixture diagnostics
+ run: |
+ node --input-type=module <<'JS'
+ import assert from 'node:assert/strict';
+ import { readFileSync } from 'node:fs';
+ import { createHash } from 'node:crypto';
+ import { join } from 'node:path';
+ const directory = '.screenshots/current';
+ const manifest = JSON.parse(readFileSync(join(directory, 'manifest.json')));
+ assert(manifest.fixtures.length > 0, 'No fixtures were captured');
+ for (const fixture of manifest.fixtures) {
+ assert(!fixture.hasError, `${fixture.fixtureId}: ${JSON.stringify(fixture.events)}`);
+ assert(fixture.imagePath, `${fixture.fixtureId}: missing screenshot`);
+ const image = readFileSync(join(directory, fixture.imagePath));
+ assert.equal(createHash('sha256').update(image).digest('hex'), fixture.imageHash, fixture.fixtureId);
+ }
+ JS
+ - name: Preserve screenshots and manifest
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: webview-screenshots
+ path: .screenshots/current/
+ if-no-files-found: error
+
+ publish:
+ needs: render
+ if: >-
+ github.repository == 'microsoft/vscode-pull-request-github' &&
+ (github.event_name == 'push' ||
+ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
+ (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main'))
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: read
+ id-token: write
+ env:
+ SCREENSHOT_SERVICE_URL: https://hediet-screenshots.azurewebsites.net
+ steps:
+ - uses: actions/download-artifact@v4
+ with:
+ name: webview-screenshots
+ path: screenshots
+ - name: Publish screenshots with GitHub OIDC
+ shell: bash
+ run: |
+ set -euo pipefail
+ (cd screenshots && zip -qr "$GITHUB_WORKSPACE/webview-screenshots.zip" .)
+ TOKEN=$(curl --fail-with-body --show-error --silent --get \
+ -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
+ --data-urlencode "audience=$SCREENSHOT_SERVICE_URL" \
+ "$ACTIONS_ID_TOKEN_REQUEST_URL" | jq -er .value)
+ echo "::add-mask::$TOKEN"
+ curl --fail-with-body --show-error --silent --retry 3 \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/zip" \
+ --data-binary @"$GITHUB_WORKSPACE/webview-screenshots.zip" \
+ --output "$GITHUB_WORKSPACE/screenshot-upload-response.json" \
+ "${SCREENSHOT_SERVICE_URL%/}/upload"
+ cat "$GITHUB_WORKSPACE/screenshot-upload-response.json"
+ node -e 'const fs = require("node:fs"); const assert = require("node:assert/strict"); const result = JSON.parse(fs.readFileSync(process.argv[1])); const manifest = JSON.parse(fs.readFileSync("screenshots/manifest.json")); assert.equal(result.commitSha, manifest.commit.hash); assert.equal(result.fixtureCount, manifest.fixtures.length);' "$GITHUB_WORKSPACE/screenshot-upload-response.json"
diff --git a/.gitignore b/.gitignore
index 3520467a42..512f63e191 100644
--- a/.gitignore
+++ b/.gitignore
@@ -349,3 +349,5 @@ src/constants.insiders.ts
.eslintcache
*.tsbuildinfo
+.screenshots/
+.tmp/
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 07d36ff613..69f2afcaa5 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -1,6 +1,13 @@
{
"version": "0.2.0",
"configurations": [
+ {
+ "name": "Component Explorer",
+ "type": "chrome",
+ "request": "launch",
+ "url": "http://localhost:5338/___explorer",
+ "preLaunchTask": "Component Explorer Server"
+ },
{
"name": "Launch Extension",
"type": "extensionHost",
diff --git a/.vscode/mcp.json b/.vscode/mcp.json
new file mode 100644
index 0000000000..1c2fb8981e
--- /dev/null
+++ b/.vscode/mcp.json
@@ -0,0 +1,14 @@
+{
+ "servers": {
+ "component-explorer": {
+ "type": "stdio",
+ "command": "node",
+ "args": [
+ "${workspaceFolder}/node_modules/@vscode/component-explorer-cli/dist/index.js",
+ "mcp",
+ "-p",
+ "${workspaceFolder}/component-explorer.json"
+ ]
+ }
+ }
+}
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index 1eb8296257..926b924897 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -3,6 +3,26 @@
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
+ {
+ "label": "Component Explorer Server",
+ "type": "npm",
+ "script": "explorer:serve",
+ "isBackground": true,
+ "problemMatcher": {
+ "owner": "component-explorer",
+ "pattern": {
+ "regexp": "^\\s*at\\s+(.+?):(\\d+):(\\d+)\\s*$",
+ "file": 1,
+ "line": 2,
+ "column": 3
+ },
+ "background": {
+ "activeOnStart": true,
+ "beginsPattern": ".*Setting up sessions.*",
+ "endsPattern": ".*Redirection server listening on.*"
+ }
+ }
+ },
{
"type": "npm",
"script": "build",
diff --git a/.vscodeignore b/.vscodeignore
index e64d84d935..0844c40c2d 100644
--- a/.vscodeignore
+++ b/.vscodeignore
@@ -1,4 +1,7 @@
.github
+component-explorer.json
+.screenshots/**
+.tmp/**
.husky
.readme/**
.vscode/**
@@ -27,6 +30,7 @@ azure-pipeline.*
**/tsconfig*.json
*.tsbuildinfo
**/webpack.config*.js
+**/webpack.config*.mjs
npm-debug.log
**/*.map
**/*.svg
diff --git a/README.md b/README.md
index ab59434c66..25278da8cd 100644
--- a/README.md
+++ b/README.md
@@ -106,3 +106,120 @@ If you're interested in contributing, or want to explore the source code of this
- [Architecture](https://github.com/Microsoft/vscode-pull-request-github/wiki/Contributing#architecture)
- [Making Pull Requests](https://github.com/Microsoft/vscode-pull-request-github/wiki/Contributing#pull-requests)
- [Code of Conduct](https://github.com/Microsoft/vscode-pull-request-github/wiki/Contributing#code-of-conduct)
+
+### Webview Component Explorer
+
+The webviews have browser fixtures using Component Explorer's Webpack plugin,
+sharing the production webview loaders. VS Code and GitHub authentication are not
+required. The explorer tools are root `devDependencies`, with versions pinned to
+the `next` releases used by this integration. The CLI still depends on
+Vite internally, but fixture compilation and serving use Webpack, not Vite.
+
+With Node.js 22 or later:
+
+```sh
+npm install --no-save
+npx --no-install playwright install chromium
+npm run explorer:serve
+```
+
+Open , or use the **Component Explorer** launch
+configuration. The **Component Explorer Server** task and the workspace MCP
+configuration use the same setup. `npm run explorer` starts only the Webpack
+server on an available local port and prints its URL.
+
+```sh
+npm run explorer:check
+npm run explorer:build
+npm run explorer:render -- --workers 1
+npm run explorer:render -- --workers 1 --accept
+npm run explorer:render -- --workers 1 --compare --report .screenshots/report
+```
+
+Screenshots and manifests are written under `.screenshots` and are not committed.
+Use the same OS, browser version, and fonts when comparing images. Fixture files
+are named `*.fixture.tsx` and import the local wrapper in `webviews/fixtures`.
+The gallery covers full editor and Activity Bar states, isolated headers, checks
+and merge actions, comments and the comment composer, reviewers and the sidebar,
+timeline entries, dropdowns, signature popovers, labels, and create-pull-request
+stack and branch controls. Each fixture owns its PR context and memory-backed
+host, uses deterministic time and local image assets, and disposes its React tree
+and host. The local `defineComponentFixture` wrapper selects the appropriate
+view's CSS. Fixtures render in the same document without per-fixture iframes.
+The wrapper scopes the real webview styles with CSS `@scope`, maps viewport-width
+media queries to fixture-width container queries, and provides the fixture width
+to responsive React components. Theme tokens from `@vscode/webview-themes` stay
+on each fixture root, including when theme variants are displayed side by side.
+Each fixture also establishes a containing block and paint boundary, so fixed
+headers and loading indicators cannot cover the Explorer UI or adjacent fixtures.
+Set `viewportHeight` to give a fixture its own scrollable viewport; `height`
+continues to set a minimum content height for full-content screenshots.
+Use a current Chromium browser with CSS scope and container-query support.
+
+Every fixture exposes a **Theme** enum input in the toolbar and properties panel.
+Changing it updates the CSS theme and `isDarkTheme` together without resetting
+drafts or other component state. `defaultTheme` selects the fixture's initial
+theme (dark when omitted); resetting the input restores that default.
+The dropdown also offers all 28 bundled themes, including VS Code high-contrast
+themes and all nine GitHub Theme variants. `dark` and `light` remain aliases for
+Dark Modern and Light Modern. Extension-specific color defaults are taken from
+this extension's `contributes.colors`, including high-contrast color references.
+The bundled catalog does not include custom overrides for these extension colors.
+
+`@vscode/webview-themes` is pinned to the published npm version `0.0.2-0`.
+No sibling theme-package checkout or local build is required.
+Run `npm install --no-save` here to install the development dependencies.
+The install command reads the existing lockfile without modifying it; `npm ci` requires a
+separate lockfile update before it can install the new root development dependencies.
+
+Use the local `defineThemeVariants` helper to compare the same scenario side by
+side instead of adding a separate "Light" scenario:
+
+```tsx
+Ready: defineThemeVariants(defaultTheme => defineComponentFixture({
+ defaultTheme,
+ render: pr => ,
+})),
+```
+
+Each Dark/Light variant still accepts an explicit `{ "theme": "light" }` or
+`{ "theme": "dark" }` input override. Normal screenshot captures use each
+variant's default. Theme-dependent component props should read `pr.isDarkTheme`
+from the render callback, not capture the fixture's default theme.
+
+Production entry points, fixtures, and preview tests create their own context
+instances and supply explicit providers. Components use React's `useContext`
+with `PullRequestContext` or `PullRequestContextNew`. Both contexts require a
+provider; their default value is an unused sentinel, not a shared instance.
+Contexts require a `WebviewHost` for persisted state, request/reply messaging,
+and incoming commands. `createWebviewHost` wraps the VS Code transport in production
+and an isolated in-memory transport in fixtures. Contexts dispose only their
+command subscriptions; the code that creates the host owns its disposal.
+Mock unsupported commands by explicitly rejecting them rather than silently
+returning success. Native VS Code context menus are outside the browser fixture's
+scope.
+
+The **Webview screenshots** GitHub Actions workflow captures screenshots for PRs
+(including forks) and main, uploading them as a workflow artifact. Publication
+uses [VS Code's screenshot service integration](https://github.com/microsoft/vscode/blob/d3d31f62268b169a2dfab62ffb2d7d38158a5b7c/.github/workflows/component-fixtures.yml#L243-L275):
+the artifact's manifest and images are zipped at the archive root and posted to
+`https://hediet-screenshots.azurewebsites.net/upload`, using a GitHub OIDC token
+whose audience is `https://hediet-screenshots.azurewebsites.net`. The service
+authorizes the `microsoft` organization and checks that the manifest repository
+matches the token. No repository URL variable, stored secret, or per-repository
+service registration is required for `microsoft/vscode-pull-request-github`.
+
+Only main pushes, same-repository PRs, and manual runs on main publish, and only
+in the upstream repository. Fork PRs and fork repositories retain artifact-only
+coverage. The separate publication job has `id-token: write`; the render job
+never receives that permission. Publication validates the returned commit and
+fixture count, and fails visibly on authentication or upload errors.
+
+After merging, a maintainer can verify publication with a main push or a manual
+**Webview screenshots** run on main, then check the returned commit at
+`https://hediet-screenshots.azurewebsites.net/commits/microsoft/vscode-pull-request-github/`.
+Local rendering does not validate GitHub's live OIDC exchange. If organization
+Actions policy blocks `id-token: write`, a maintainer must permit it; if the
+service returns 401/403, its operator must check the audience, organization
+authorization, and manifest repository match. Do not add long-lived credentials
+as a workaround.
diff --git a/build/filters.js b/build/filters.js
index 95672b1ff2..59603753e7 100644
--- a/build/filters.js
+++ b/build/filters.js
@@ -43,6 +43,7 @@ module.exports.indentationFilter = [
'!**/ThirdPartyNotices.txt',
'!**/LICENSE.{txt,rtf}',
'!**/LICENSE',
+ '!*.yml',
'!**/*.yml',
'!resources/emojis.json',
@@ -63,10 +64,12 @@ module.exports.copyrightFilter = [
'!.husky/**/*',
'!tsconfig.base.json',
'!tsconfig.browser.json',
+ '!tsconfig.explorer.json',
'!tsconfig.json',
'!tsconfig.test.json',
'!tsconfig.webviews.json',
'!tsconfig.scripts.json',
+ '!component-explorer.json',
'!tsfmt.json',
'!**/queries*.gql',
'!**/*.yml',
@@ -84,4 +87,3 @@ module.exports.tsFormattingFilter = [
'webviews/**/*.ts',
'**/fixtures/**/*'
];
-
diff --git a/component-explorer.json b/component-explorer.json
new file mode 100644
index 0000000000..4c0965df18
--- /dev/null
+++ b/component-explorer.json
@@ -0,0 +1,19 @@
+{
+ "$schema": "./node_modules/@vscode/component-explorer-cli/dist/component-explorer-config.schema.json",
+ "screenshotDir": ".screenshots",
+ "sessions": [
+ { "name": "current" }
+ ],
+ "server": {
+ "type": "http",
+ "cmd": "npm run explorer",
+ "cwd": ".",
+ "wait": {
+ "stdout": "Component Explorer listening at http://127.0.0.1:(?\\d+)/"
+ },
+ "url": "http://127.0.0.1:${var:port}"
+ },
+ "redirection": {
+ "port": 5338
+ }
+}
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 2f509573d0..25b75c5624 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -281,4 +281,20 @@ export default defineConfig([
'rulesdir/public-methods-well-defined-types': 'error'
},
},
+ {
+ files: ['webviews/**/*.fixture.{ts,tsx}', 'webviews/fixtures/**/*.{ts,tsx}'],
+ languageOptions: {
+ parserOptions: {
+ project: 'tsconfig.explorer.json',
+ },
+ },
+ settings: {
+ 'import/resolver': {
+ typescript: {
+ project: 'tsconfig.explorer.json',
+ alwaysTryTypes: true,
+ },
+ },
+ },
+ },
]);
\ No newline at end of file
diff --git a/package.json b/package.json
index 4ac8eb05b9..092cf78aa9 100644
--- a/package.json
+++ b/package.json
@@ -4303,6 +4303,12 @@
"clean": "rm -r dist/",
"compile": "webpack --mode development --env esbuild",
"compile:test": "tsc -p tsconfig.test.json",
+ "explorer": "node scripts/component-explorer-serve.mjs",
+ "explorer:check": "tsc -p tsconfig.explorer.json --noEmit",
+ "explorer:build": "node scripts/component-explorer-build.mjs",
+ "explorer:render": "component-explorer render -p component-explorer.json",
+ "explorer:mcp": "component-explorer mcp -p component-explorer.json",
+ "explorer:serve": "component-explorer serve -p component-explorer.json",
"watch:test": "tsc -w -p tsconfig.test.json",
"compile:node": "webpack --mode development --config-name extension:node --config-name webviews",
"compile:web": "webpack --mode development --config-name extension:webworker --config-name webviews",
@@ -4344,10 +4350,14 @@
"@types/webpack-env": "^1.16.0",
"@typescript-eslint/eslint-plugin": "^8.44.0",
"@typescript-eslint/parser": "^8.44.0",
+ "@vscode/component-explorer": "0.2.1-161",
+ "@vscode/component-explorer-cli": "0.2.1-163",
+ "@vscode/component-explorer-webpack-plugin": "0.3.1-153",
"@vscode/dts": "^0.4.1",
"@vscode/test-cli": "^0.0.11",
"@vscode/test-electron": "^2.5.2",
"@vscode/test-web": "^0.0.71",
+ "@vscode/webview-themes": "0.0.2-0",
"assert": "^2.0.0",
"buffer": "^6.0.3",
"constants-browserify": "^1.0.0",
@@ -4365,6 +4375,7 @@
"graphql": "15.5.0",
"graphql-tag": "2.11.0",
"gulp-filter": "^7.0.0",
+ "html-webpack-plugin": "5.6.6",
"husky": "^8.0.1",
"jsdom": "28.1.0",
"jsdom-global": "3.0.2",
@@ -4378,6 +4389,7 @@
"os-browserify": "^0.3.0",
"p-all": "^1.0.0",
"path-browserify": "1.0.1",
+ "playwright": "1.64.0",
"process": "^0.11.10",
"react-testing-library": "7.0.1",
"sinon": "9.0.0",
@@ -4395,8 +4407,11 @@
"typescript-eslint": "^8.44.0",
"typescript-formatter": "^7.2.2",
"vinyl-fs": "^3.0.3",
+ "vite": "7.3.7",
"webpack": "5.104.1",
- "webpack-cli": "4.2.0"
+ "webpack-cli": "4.2.0",
+ "webpack-dev-server": "5.2.4",
+ "zod": "4.6.5"
},
"dependencies": {
"@joaomoreno/unique-names-generator": "^5.2.0",
diff --git a/scripts/component-explorer-build.mjs b/scripts/component-explorer-build.mjs
new file mode 100644
index 0000000000..171ef43d5d
--- /dev/null
+++ b/scripts/component-explorer-build.mjs
@@ -0,0 +1,21 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import webpack from 'webpack';
+import createConfig from '../webpack.config.explorer.mjs';
+
+const compiler = webpack(await createConfig());
+await new Promise((resolve, reject) => {
+ compiler.run((error, stats) => compiler.close(closeError => {
+ if (error || closeError) {
+ reject(error ?? closeError);
+ } else if (stats.hasErrors()) {
+ reject(new Error(stats.toString('errors-warnings')));
+ } else {
+ process.stdout.write(stats.toString('errors-warnings') + '\n');
+ resolve();
+ }
+ }));
+});
diff --git a/scripts/component-explorer-serve.mjs b/scripts/component-explorer-serve.mjs
new file mode 100644
index 0000000000..ba4d2c4a58
--- /dev/null
+++ b/scripts/component-explorer-serve.mjs
@@ -0,0 +1,17 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import WebpackDevServer from 'webpack-dev-server';
+import webpack from 'webpack';
+import createConfig from '../webpack.config.explorer.mjs';
+
+const compiler = webpack(await createConfig());
+const server = new WebpackDevServer(compiler.options.devServer, compiler);
+await server.start();
+const address = server.server.address();
+if (!address || typeof address === 'string') {
+ throw new Error('Component Explorer did not bind a TCP port.');
+}
+process.stdout.write(`Component Explorer listening at http://127.0.0.1:${address.port}/___explorer\n`);
diff --git a/tsconfig.base.json b/tsconfig.base.json
index 87e0c3e4fd..226b2d9e76 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -23,5 +23,5 @@
"noImplicitOverride": true
},
"include": ["src", "webviews"],
- "exclude": ["node_modules"]
+ "exclude": ["node_modules", "**/*.fixture.ts", "**/*.fixture.tsx", "webviews/fixtures"]
}
diff --git a/tsconfig.explorer.json b/tsconfig.explorer.json
new file mode 100644
index 0000000000..8c7bde90de
--- /dev/null
+++ b/tsconfig.explorer.json
@@ -0,0 +1,16 @@
+{
+ "extends": "./tsconfig.webviews.json",
+ "compilerOptions": {
+ "target": "es2022",
+ "lib": ["dom", "dom.iterable", "es2022"],
+ "moduleResolution": "bundler",
+ "tsBuildInfoFile": "out/component-explorer.tsbuildinfo"
+ },
+ "include": [
+ "src/@types",
+ "webviews/**/*.fixture.ts",
+ "webviews/**/*.fixture.tsx",
+ "webviews/fixtures"
+ ],
+ "exclude": ["node_modules"]
+}
diff --git a/webpack.config.explorer.mjs b/webpack.config.explorer.mjs
new file mode 100644
index 0000000000..68b006874f
--- /dev/null
+++ b/webpack.config.explorer.mjs
@@ -0,0 +1,53 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { fileURLToPath } from 'node:url';
+import { ComponentExplorerPlugin } from '@vscode/component-explorer-webpack-plugin';
+import HtmlWebpackPlugin from 'html-webpack-plugin';
+import webpack from 'webpack';
+import webviewBuild from './webpack.config.js';
+
+const root = fileURLToPath(new URL('./', import.meta.url));
+
+export default async function () {
+ const base = await webviewBuild.getWebviewConfig('development', { esbuild: true }, {}, 'tsconfig.explorer.json');
+ return {
+ ...base,
+ context: root,
+ name: 'component-explorer',
+ output: {
+ ...base.output,
+ path: fileURLToPath(new URL('./out/component-explorer', import.meta.url)),
+ publicPath: '/',
+ filename: 'bundled/[name].js',
+ chunkFilename: 'bundled/[name].js',
+ },
+ module: {
+ rules: [{
+ oneOf: [
+ { test: /\.css$/, resourceQuery: /inline/, type: 'asset/source' },
+ ...base.module.rules,
+ ],
+ }],
+ },
+ plugins: [
+ ...base.plugins.filter(plugin => !(plugin instanceof webpack.optimize.LimitChunkCountPlugin)),
+ new ComponentExplorerPlugin({ include: 'webviews/**/*.fixture.{ts,tsx}' }),
+ new HtmlWebpackPlugin({
+ filename: '___explorer.html',
+ templateContent: 'GitHub Pull Requests Component Explorer',
+ chunks: ['___explorer'],
+ }),
+ ],
+ devServer: {
+ host: '127.0.0.1',
+ port: 0,
+ hot: true,
+ static: false,
+ client: { overlay: false },
+ },
+ stats: 'errors-warnings',
+ };
+}
diff --git a/webpack.config.js b/webpack.config.js
index e2e5c31838..490a1d0b34 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -46,7 +46,7 @@ async function resolveTSConfig(configFile) {
* @param { WebpackConfig['entry'] } entry
* @returns { Promise }
*/
-async function getWebviewConfig(mode, env, entry) {
+async function getWebviewConfig(mode, env, entry, tsconfig = 'tsconfig.webviews.json') {
const basePath = path.join(__dirname, 'webviews');
/**
@@ -60,7 +60,7 @@ async function getWebviewConfig(mode, env, entry) {
async: false,
formatter: 'basic',
typescript: {
- configFile: path.join(__dirname, 'tsconfig.webviews.json'),
+ configFile: path.join(__dirname, tsconfig),
},
}),
];
@@ -112,13 +112,13 @@ async function getWebviewConfig(mode, env, entry) {
loader: 'esbuild-loader',
options: {
target: 'es2019',
- tsconfigRaw: await resolveTSConfig(path.join(__dirname, 'tsconfig.webviews.json')),
+ tsconfigRaw: await resolveTSConfig(path.join(__dirname, tsconfig)),
},
}
: {
loader: 'ts-loader',
options: {
- configFile: path.join(__dirname, 'tsconfig.webviews.json'),
+ configFile: path.join(__dirname, tsconfig),
experimentalWatchApi: true,
transpileOnly: true,
},
@@ -432,3 +432,5 @@ module.exports =
}),
]);
};
+
+module.exports.getWebviewConfig = getWebviewConfig;
diff --git a/webviews/activityBarView/overview.fixture.tsx b/webviews/activityBarView/overview.fixture.tsx
new file mode 100644
index 0000000000..beddc11bfe
--- /dev/null
+++ b/webviews/activityBarView/overview.fixture.tsx
@@ -0,0 +1,33 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import * as React from 'react';
+import { Overview } from './overview';
+import { CheckState, GithubItemStateEnum, PullRequestMergeability } from '../../src/github/interface';
+import { PullRequest } from '../../src/github/views';
+import { createCheck, createPullRequest, createStack } from '../fixtures/data';
+import { defineComponentFixture, defineFixtureGroup, defineThemeVariants, FixtureTheme } from '../fixtures/fixtureUtils';
+
+const renderOverview = (pr: PullRequest) => ;
+
+function overviewFixture(overrides: () => Partial, defaultTheme = FixtureTheme.Dark) {
+ return defineComponentFixture({
+ view: 'activityBar', width: 360, height: 650, defaultTheme,
+ createPullRequest: () => createPullRequest({ isAuthor: false, ...overrides() }),
+ render: renderOverview,
+ });
+}
+
+export default defineFixtureGroup({ path: 'Views/Activity bar overview' }, {
+ Ready: defineThemeVariants(defaultTheme => overviewFixture(() => ({}), defaultTheme)),
+ Draft: overviewFixture(() => ({ isDraft: true })),
+ FailedChecks: overviewFixture(() => ({
+ mergeable: PullRequestMergeability.NotMergeable,
+ status: { state: CheckState.Failure, statuses: [createCheck(CheckState.Failure)] },
+ })),
+ ReviewDraft: overviewFixture(() => ({ hasReviewDraft: true, pendingCommentText: 'Please cover keyboard focus when the stack changes.' })),
+ Merged: overviewFixture(() => ({ state: GithubItemStateEnum.Merged })),
+ Stacked: overviewFixture(() => ({ stack: createStack() })),
+});
diff --git a/webviews/common/hooks.ts b/webviews/common/hooks.ts
index d62d014932..41de71da8e 100644
--- a/webviews/common/hooks.ts
+++ b/webviews/common/hooks.ts
@@ -3,7 +3,26 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
-import { Dispatch, SetStateAction, useEffect, useState } from 'react';
+import { createContext, Dispatch, SetStateAction, useContext, useEffect, useState } from 'react';
+
+export const ViewportWidthContext = createContext(undefined);
+
+export function useMaxViewportWidth(maxWidth: number): boolean {
+ const width = useContext(ViewportWidthContext);
+ const query = `(max-width: ${maxWidth}px)`;
+ const [matches, setMatches] = useState(() => width === undefined ? window.matchMedia(query).matches : width <= maxWidth);
+ useEffect(() => {
+ if (width !== undefined) {
+ return;
+ }
+ const media = window.matchMedia(query);
+ const update = () => setMatches(media.matches);
+ update();
+ media.addEventListener('change', update);
+ return () => media.removeEventListener('change', update);
+ }, [query, width]);
+ return width === undefined ? matches : width <= maxWidth;
+}
/**
* useState, but track the value of a prop.
diff --git a/webviews/common/label.fixture.tsx b/webviews/common/label.fixture.tsx
new file mode 100644
index 0000000000..530a215977
--- /dev/null
+++ b/webviews/common/label.fixture.tsx
@@ -0,0 +1,33 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import * as React from 'react';
+import { Label } from './label';
+import { PullRequest } from '../../src/github/views';
+import { defineComponentFixture, defineFixtureGroup, defineThemeVariants, FixtureTheme } from '../fixtures/fixtureUtils';
+
+function labelsFixture(defaultTheme: FixtureTheme, width = 720) {
+ return defineComponentFixture({
+ defaultTheme, width,
+ render: renderLabels,
+ });
+}
+
+function renderLabels(pr: PullRequest) {
+ return
+ {[
+ { name: 'bug', color: 'd73a4a' },
+ { name: 'enhancement', color: 'a2eeef' },
+ { name: 'good first issue', color: '7057ff' },
+ { name: 'needs additional accessibility testing', color: 'ffffff' },
+ { name: 'blocked', color: '000000' },
+ ].map(label => )}
+
;
+}
+
+export default defineFixtureGroup({ path: 'Components/Labels' }, {
+ Default: defineThemeVariants(defaultTheme => labelsFixture(defaultTheme)),
+ Narrow: defineThemeVariants(defaultTheme => labelsFixture(defaultTheme, 360)),
+});
diff --git a/webviews/common/test/hooks.test.tsx b/webviews/common/test/hooks.test.tsx
new file mode 100644
index 0000000000..e2702aac39
--- /dev/null
+++ b/webviews/common/test/hooks.test.tsx
@@ -0,0 +1,60 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import assert from 'assert';
+import * as React from 'react';
+import { cleanup, render, wait } from 'react-testing-library';
+import { createSandbox } from 'sinon';
+import { useMaxViewportWidth, ViewportWidthContext } from '../hooks';
+
+describe('Viewport width', () => {
+ const sandbox = createSandbox();
+
+ afterEach(() => {
+ cleanup();
+ sandbox.restore();
+ });
+
+ const Width = ({ maxWidth = 768 }: { maxWidth?: number }) => {String(useMaxViewportWidth(maxWidth))};
+
+ it('uses independent provided widths, including the exact breakpoint, without reading the window', async () => {
+ const matchMedia = sandbox.stub(window, 'matchMedia').throws(new Error('Unexpected viewport access'));
+ const view = (width: number) => <>
+
+
+ >;
+ const result = render(view(768));
+ assert.strictEqual(result.container.textContent, 'truefalse');
+ result.rerender(view(900));
+ await wait(() => assert.strictEqual(result.container.textContent, 'falsefalse'));
+ assert.strictEqual(matchMedia.callCount, 0);
+ });
+
+ it('subscribes to the window by default and removes its listener on unmount', async () => {
+ const changes = new EventTarget();
+ const media: MediaQueryList = {
+ matches: false,
+ media: '(max-width: 768px)',
+ onchange: null,
+ addListener: () => { throw new Error('Unexpected legacy listener'); },
+ removeListener: () => { throw new Error('Unexpected legacy listener'); },
+ addEventListener: changes.addEventListener.bind(changes),
+ removeEventListener: changes.removeEventListener.bind(changes),
+ dispatchEvent: changes.dispatchEvent.bind(changes),
+ };
+ const add = sandbox.spy(media, 'addEventListener');
+ const remove = sandbox.spy(media, 'removeEventListener');
+ const matchMedia = sandbox.stub(window, 'matchMedia').returns(media);
+ const result = render();
+ await wait(() => assert.strictEqual(add.callCount, 1));
+ assert.strictEqual(result.container.textContent, 'false');
+ assert(matchMedia.alwaysCalledWith('(max-width: 768px)'));
+ Object.defineProperty(media, 'matches', { value: true });
+ changes.dispatchEvent(new Event('change'));
+ await wait(() => assert.strictEqual(result.container.textContent, 'true'));
+ result.unmount();
+ await wait(() => assert(remove.calledWith('change', add.firstCall.args[1])));
+ });
+});
diff --git a/webviews/components/comment.fixture.tsx b/webviews/components/comment.fixture.tsx
new file mode 100644
index 0000000000..438695d9f6
--- /dev/null
+++ b/webviews/components/comment.fixture.tsx
@@ -0,0 +1,91 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import * as React from 'react';
+import { AddComment, CommentPreview, CommentView } from './comment';
+import { GithubItemStateEnum } from '../../src/github/interface';
+import { PullRequest, ReviewType } from '../../src/github/views';
+import { createComment, createPullRequest, createReview } from '../fixtures/data';
+import { clickFixtureElement, defineComponentFixture, defineFixtureGroup, defineThemeVariants } from '../fixtures/fixtureUtils';
+
+function composerFixture(overrides: () => Partial, width = 720) {
+ return defineComponentFixture({
+ width,
+ createPullRequest: () => createPullRequest(overrides()),
+ render: pr => ,
+ });
+}
+
+function renderRichMarkdown() {
+ return Release checklistKeep keyboard focus visible.
- Support narrow layouts
- Add regression tests
const ready = checks.every(check => check.passed);
| Platform | Result |
|---|
| Windows | Passed |
| Linux | Passed |
',
+ })} />;
+}
+
+const comments = defineFixtureGroup({
+ Plain: defineComponentFixture({ render: () => }),
+ EmptyDescription: defineComponentFixture({
+ createPullRequest: () => createPullRequest({ body: '', bodyHTML: '' }),
+ render: pr => ,
+ }),
+ RichMarkdown: defineThemeVariants(defaultTheme => defineComponentFixture({
+ defaultTheme,
+ render: renderRichMarkdown,
+ })),
+ Reactions: defineComponentFixture({
+ render: () => ,
+ }),
+ EditingDraft: defineComponentFixture({
+ createPullRequest: () => createPullRequest({ pendingCommentDrafts: { 301: 'Saved draft: please keep focus on the selected pull request.' } }),
+ render: () => ,
+ }),
+ ActionToolbar: defineComponentFixture({
+ render: () => ,
+ prepare: container => {
+ const authorLink = container.querySelector('.review-comment-header a');
+ if (!authorLink) {
+ throw new Error('Comment author link missing');
+ }
+ authorLink.focus();
+ },
+ }),
+ Approval: defineComponentFixture({ render: () => }),
+ RequestedChanges: defineComponentFixture({
+ render: () => Please test focus restoration.
' })} />,
+ }),
+ LoadingPreview: defineComponentFixture({ render: pr => }),
+ QuoteReply: defineComponentFixture({
+ createPullRequest: () => createPullRequest({ isAuthor: false }),
+ render: pr => <>>,
+ prepare: async container => {
+ await clickFixtureElement('button[title="Quote reply"]')(container);
+ const textarea = container.querySelector('#comment-form textarea');
+ if (textarea?.value !== `> ${createComment().body} \n\n`) {
+ throw new Error('Quote reply did not populate the comment composer');
+ }
+ },
+ }),
+});
+
+const composer = defineFixtureGroup({
+ EmptyAuthor: composerFixture(() => ({})),
+ ReviewerDraft: composerFixture(() => ({ isAuthor: false, pendingCommentText: 'Looks good! The keyboard tests cover the regression.' })),
+ Approve: composerFixture(() => ({ isAuthor: false, lastReviewType: ReviewType.Approve })),
+ RequestChanges: composerFixture(() => ({ isAuthor: false, lastReviewType: ReviewType.RequestChanges, pendingCommentText: 'Please add a test for an empty stack.' })),
+ Busy: composerFixture(() => ({ busy: true, pendingCommentText: 'Submitting this review...' })),
+ ClosedIssue: composerFixture(() => ({ isIssue: true, state: GithubItemStateEnum.Closed })),
+ ContinueOnGitHub: composerFixture(() => ({ isAuthor: false, continueOnGitHub: true })),
+ Attestation: composerFixture(() => ({ isAuthor: false, attestationCommitsEnabled: true, isCurrentlyCheckedOut: true, lastReviewType: ReviewType.Approve })),
+ Narrow: composerFixture(() => ({ isAuthor: false, pendingCommentText: 'Review from a narrow panel.' }), 360),
+});
+
+export default defineFixtureGroup({ path: 'Components/Comments' }, {
+ Display: comments,
+ Composer: composer,
+});
diff --git a/webviews/components/commitVerification.fixture.tsx b/webviews/components/commitVerification.fixture.tsx
new file mode 100644
index 0000000000..09fe8a97bd
--- /dev/null
+++ b/webviews/components/commitVerification.fixture.tsx
@@ -0,0 +1,27 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import * as React from 'react';
+import { CommitVerificationBadge } from './commitVerification';
+import { CommitEvent } from '../../src/common/timelineEvent';
+import { createAccount } from '../fixtures/data';
+import { clickFixtureElement, defineComponentFixture, defineFixtureGroup } from '../fixtures/fixtureUtils';
+
+function verificationFixture(verification: CommitEvent['verification'], open = false) {
+ return defineComponentFixture({
+ width: 620, height: open ? 330 : undefined,
+ render: () => ,
+ prepare: open ? clickFixtureElement('.verified-pill') : undefined,
+ });
+}
+
+export default defineFixtureGroup({ path: 'Components/Commit verification' }, {
+ Verified: verificationFixture({ verified: true, state: 'VALID' }),
+ Unverified: verificationFixture({ verified: false, state: 'UNKNOWN_KEY' }),
+ GitHubSignature: verificationFixture({ verified: true, state: 'VALID', wasSignedByGitHub: true }, true),
+ GpgSignature: verificationFixture({ verified: true, state: 'VALID', signer: createAccount(), keyId: '1234567890ABCDEF', email: 'octocat@example.com' }, true),
+ SshSignature: verificationFixture({ verified: true, state: 'VALID', signer: createAccount(), keyFingerprint: 'SHA256:fixture-public-key-fingerprint', email: 'octocat@example.com' }, true),
+ UnknownKey: verificationFixture({ verified: false, state: 'UNKNOWN_KEY', keyId: '1234567890ABCDEF' }, true),
+});
diff --git a/webviews/components/dropdown.fixture.tsx b/webviews/components/dropdown.fixture.tsx
new file mode 100644
index 0000000000..017144f5ad
--- /dev/null
+++ b/webviews/components/dropdown.fixture.tsx
@@ -0,0 +1,29 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import * as React from 'react';
+import { Dropdown } from './dropdown';
+import { clickFixtureElement, defineComponentFixture, defineFixtureGroup } from '../fixtures/fixtureUtils';
+
+function dropdownFixture({ open = false, disabled = false, busy = false, single = false } = {}) {
+ return defineComponentFixture({
+ view: 'activityBar', width: 400,
+ render: () => busy ? new Promise(() => {}) : Promise.resolve()}
+ />,
+ prepare: open ? clickFixtureElement('button[aria-label="Expand button options"]') : busy ? clickFixtureElement('input[type="submit"]') : undefined,
+ });
+}
+
+export default defineFixtureGroup({ path: 'Components/Dropdown' }, {
+ Closed: dropdownFixture(),
+ Open: dropdownFixture({ open: true }),
+ Disabled: dropdownFixture({ disabled: true }),
+ Submitting: dropdownFixture({ busy: true }),
+ SingleOption: dropdownFixture({ single: true }),
+});
diff --git a/webviews/components/header.fixture.tsx b/webviews/components/header.fixture.tsx
new file mode 100644
index 0000000000..b583719f15
--- /dev/null
+++ b/webviews/components/header.fixture.tsx
@@ -0,0 +1,49 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import * as React from 'react';
+import { Header, HeaderPreview } from './header';
+import { EventType } from '../../src/common/timelineEvent';
+import { GithubItemStateEnum } from '../../src/github/interface';
+import { PullRequest } from '../../src/github/views';
+import { createAccount, createPullRequest, createStack } from '../fixtures/data';
+import { clickFixtureElement, defineComponentFixture, defineFixtureGroup } from '../fixtures/fixtureUtils';
+
+function headerFixture(overrides: () => Partial, rename = false, width = 900) {
+ return defineComponentFixture({
+ width,
+ createPullRequest: () => createPullRequest(overrides()),
+ render: pr => ,
+ prepare: rename ? clickFixtureElement('button[title="Rename"]') : undefined,
+ });
+}
+
+export default defineFixtureGroup({ path: 'Components/Header' }, {
+ Open: headerFixture(() => ({})),
+ Draft: headerFixture(() => ({ isDraft: true })),
+ Merged: headerFixture(() => ({ state: GithubItemStateEnum.Merged })),
+ Closed: headerFixture(() => ({ state: GithubItemStateEnum.Closed })),
+ Stacked: headerFixture(() => ({ stack: createStack() })),
+ CheckoutAvailable: headerFixture(() => ({ isCurrentlyCheckedOut: false })),
+ Busy: headerFixture(() => ({ busy: true })),
+ Rename: headerFixture(() => ({}), true),
+ ReadOnly: headerFixture(() => ({ canEdit: false, hasWritePermission: false })),
+ LongTitleNarrow: headerFixture(() => ({
+ title: 'Preserve keyboard focus when refreshing a pull request with a long branch name',
+ titleHTML: 'Preserve keyboard focus when refreshing a pull request with a long branch name',
+ head: 'feature/preserve-keyboard-focus-after-background-refresh',
+ }), false, 380),
+ IssueNotPlanned: headerFixture(() => ({ isIssue: true, state: GithubItemStateEnum.Closed, stateReason: 'NOT_PLANNED' })),
+ CopilotWorking: headerFixture(() => ({
+ events: [{
+ id: 'agent-start', event: EventType.CopilotStarted, createdAt: '2025-01-15T11:30:00Z',
+ onBehalfOf: createAccount(), sessionLink: { id: 1, host: 'github.com', owner: 'octocat', repo: 'component-explorer', pullNumber: 102, sessionIndex: 0 },
+ }],
+ })),
+ Preview: defineComponentFixture({
+ width: 900,
+ render: pr => ,
+ }),
+});
diff --git a/webviews/components/merge.fixture.tsx b/webviews/components/merge.fixture.tsx
new file mode 100644
index 0000000000..c4d4d167a0
--- /dev/null
+++ b/webviews/components/merge.fixture.tsx
@@ -0,0 +1,103 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import * as React from 'react';
+import { MergeStatus, PrActions, StatusChecksSection } from './merge';
+import { CheckState, GithubItemStateEnum, MergeQueueState, PullRequestMergeability } from '../../src/github/interface';
+import { PullRequest } from '../../src/github/views';
+import { createCheck, createPullRequest } from '../fixtures/data';
+import { clickFixtureElement, defineComponentFixture, defineFixtureGroup, defineThemeVariants, FixtureTheme } from '../fixtures/fixtureUtils';
+
+const renderChecks = (pr: PullRequest) => ;
+
+function checksFixture(overrides: () => Partial, expanded = true, defaultTheme = FixtureTheme.Dark) {
+ return defineComponentFixture({
+ defaultTheme,
+ createPullRequest: () => createPullRequest(overrides()),
+ render: renderChecks,
+ prepare: expanded ? async container => {
+ if (container.querySelector('#status-checks-display-button[aria-expanded="false"]')) {
+ await clickFixtureElement('#status-checks-display-button')(container);
+ }
+ } : undefined,
+ });
+}
+
+function actionFixture(overrides: () => Partial, confirm = false) {
+ return defineComponentFixture({
+ createPullRequest: () => createPullRequest(overrides()),
+ render: pr => ,
+ prepare: confirm ? clickFixtureElement('.automerge-section > button') : undefined,
+ });
+}
+
+function mergeStatusFixture(mergeable: PullRequestMergeability) {
+ return defineComponentFixture({
+ render: () => ,
+ });
+}
+
+const checks = defineFixtureGroup({
+ Successful: checksFixture(() => ({})),
+ Failed: defineThemeVariants(defaultTheme => checksFixture(() => ({
+ mergeable: PullRequestMergeability.NotMergeable,
+ status: { state: CheckState.Failure, statuses: [createCheck(CheckState.Failure, { description: 'Unit tests failed on Windows' })] },
+ }), true, defaultTheme)),
+ Pending: checksFixture(() => ({
+ mergeable: PullRequestMergeability.Unknown,
+ status: { state: CheckState.Pending, statuses: [createCheck(CheckState.Pending, { description: 'Waiting for a runner' })] },
+ })),
+ MixedWorkflows: checksFixture(() => ({
+ status: { state: CheckState.Failure, statuses: [
+ createCheck(CheckState.Success, { id: 'lint', context: 'Lint', workflowName: 'Quality' }),
+ createCheck(CheckState.Failure, { id: 'windows', context: 'Windows tests', description: '3 tests failed' }),
+ createCheck(CheckState.Pending, { id: 'linux', context: 'Linux tests', description: 'Running' }),
+ createCheck(CheckState.Neutral, { id: 'preview', context: 'Deploy preview', workflowName: 'Preview', isRequired: false, description: 'Skipped for draft pull requests' }),
+ ] },
+ })),
+ NoDetailsOrLogs: checksFixture(() => ({
+ status: { state: CheckState.Failure, statuses: [createCheck(CheckState.Failure, { targetUrl: null, databaseId: null, isCheckRun: false })] },
+ })),
+ NoChecks: checksFixture(() => ({ status: null })),
+ ReviewsRequired: checksFixture(() => ({
+ reviewRequirement: { count: 2, state: CheckState.Pending, approvals: ['alice'], requestedChanges: [] },
+ })),
+ ChangesRequested: checksFixture(() => ({
+ reviewRequirement: { count: 1, state: CheckState.Failure, approvals: [], requestedChanges: ['reviewer'] },
+ })),
+ Approved: checksFixture(() => ({
+ reviewRequirement: { count: 1, state: CheckState.Success, approvals: ['reviewer'], requestedChanges: [] },
+ })),
+ MergedBranchCleanup: checksFixture(() => ({ state: GithubItemStateEnum.Merged })),
+});
+
+const mergeStatus = defineFixtureGroup({
+ Mergeable: mergeStatusFixture(PullRequestMergeability.Mergeable),
+ Conflicting: mergeStatusFixture(PullRequestMergeability.Conflict),
+ Behind: mergeStatusFixture(PullRequestMergeability.Behind),
+ Protected: mergeStatusFixture(PullRequestMergeability.NotMergeable),
+ Checking: mergeStatusFixture(PullRequestMergeability.Unknown),
+});
+
+const mergeActions = defineFixtureGroup({
+ Ready: actionFixture(() => ({})),
+ ConfirmMerge: actionFixture(() => ({}), true),
+ ConfirmSquash: actionFixture(() => ({ defaultMergeMethod: 'squash', squashCommitMeta: { title: 'Add keyboard navigation', description: 'Keep focus stable while changing stacks.' } }), true),
+ Draft: actionFixture(() => ({ isDraft: true })),
+ DraftBusy: actionFixture(() => ({ isDraft: true, busy: true })),
+ AutoMergeOff: actionFixture(() => ({ mergeable: PullRequestMergeability.NotMergeable, allowAutoMerge: true, autoMerge: false })),
+ AutoMergeOn: actionFixture(() => ({ mergeable: PullRequestMergeability.NotMergeable, allowAutoMerge: true, autoMerge: true, autoMergeMethod: 'squash' })),
+ MergeWhenReady: actionFixture(() => ({ mergeable: PullRequestMergeability.NotMergeable, allowAutoMerge: true, autoMerge: true, mergeQueueMethod: 'squash' })),
+ AddToQueue: actionFixture(() => ({ mergeQueueMethod: 'squash' })),
+ QueueHead: actionFixture(() => ({ mergeQueueEntry: { position: 1, state: MergeQueueState.AwaitingChecks, url: 'https://github.com/octocat/component-explorer/queue/main' } })),
+ QueueBlocked: actionFixture(() => ({ mergeQueueEntry: { position: 2, state: MergeQueueState.Locked, url: 'https://github.com/octocat/component-explorer/queue/main' } })),
+ QueueConflicting: actionFixture(() => ({ mergeQueueEntry: { position: 2, state: MergeQueueState.Unmergeable, url: 'https://github.com/octocat/component-explorer/queue/main' } })),
+});
+
+export default defineFixtureGroup({ path: 'Components/Merge' }, {
+ Checks: checks,
+ Status: mergeStatus,
+ Actions: mergeActions,
+});
diff --git a/webviews/components/pullRequestStack.fixture.tsx b/webviews/components/pullRequestStack.fixture.tsx
new file mode 100644
index 0000000000..4d1027609a
--- /dev/null
+++ b/webviews/components/pullRequestStack.fixture.tsx
@@ -0,0 +1,82 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import * as React from 'react';
+import { PrActions } from './merge';
+import { StackSection } from './pullRequestStack';
+import { GithubItemStateEnum, PullRequestMergeability } from '../../src/github/interface';
+import { PullRequest } from '../../src/github/views';
+import { createPullRequest, createStack } from '../fixtures/data';
+import { defaultCommand, defineComponentFixture, defineFixtureGroup, defineThemeVariants, FixtureCommandHandler, FixtureTheme } from '../fixtures/fixtureUtils';
+
+const renderStack = (pr: PullRequest) => ;
+
+function stackFixture(create: () => PullRequest, { handleCommand, action, defaultTheme = FixtureTheme.Dark }: {
+ handleCommand?: FixtureCommandHandler;
+ action?: 'Update stack' | 'Unstack all';
+ defaultTheme?: FixtureTheme;
+} = {}) {
+ return defineComponentFixture({
+ width: 720,
+ defaultTheme,
+ createPullRequest: create,
+ render: renderStack,
+ handleCommand,
+ prepare: action ? async container => {
+ const button = [...container.querySelectorAll('button')].find(button => button.textContent === action);
+ if (!button) {
+ throw new Error(`Missing fixture action: ${action}`);
+ }
+ button.click();
+ await new Promise(resolve => container.ownerDocument.defaultView!.setTimeout(resolve, 0));
+ } : undefined,
+ });
+}
+
+function readyStack(): PullRequest {
+ return createPullRequest({ stack: createStack(), canUpdateStack: true });
+}
+
+const actionFailure: FixtureCommandHandler = (request, pr) => {
+ if (request.command === 'pr.update-stack' || request.command === 'pr.unstack-all') {
+ throw new Error('Push rejected by branch protection');
+ }
+ return defaultCommand(request, pr);
+};
+
+const actionPending: FixtureCommandHandler = (request, pr) => {
+ if (request.command === 'pr.update-stack' || request.command === 'pr.unstack-all') {
+ return new Promise(() => {});
+ }
+ return defaultCommand(request, pr);
+};
+
+export default defineFixtureGroup({ path: 'Components/Pull request stack' }, {
+ Ready: defineThemeVariants(defaultTheme => stackFixture(readyStack, { defaultTheme })),
+ DraftConflictBehind: defineThemeVariants(defaultTheme => stackFixture(() => {
+ const stack = createStack();
+ stack.pullRequests[0].mergeable = PullRequestMergeability.Behind;
+ stack.pullRequests[1].mergeable = PullRequestMergeability.Conflict;
+ stack.pullRequests[2].isDraft = true;
+ return createPullRequest({ stack, mergeable: PullRequestMergeability.Conflict });
+ }, { defaultTheme })),
+ ReadOnly: stackFixture(() => createPullRequest({ stack: createStack(), hasWritePermission: false, canEdit: false })),
+ Loading: stackFixture(() => createPullRequest({ stackLoaded: false })),
+ LoadFailed: stackFixture(() => createPullRequest({ stackLoadError: true })),
+ MergedAndClosed: stackFixture(() => {
+ const stack = createStack();
+ stack.pullRequests[0].state = GithubItemStateEnum.Merged;
+ stack.pullRequests[2].state = GithubItemStateEnum.Closed;
+ return createPullRequest({ stack });
+ }),
+ UpdateFailed: stackFixture(readyStack, { handleCommand: actionFailure, action: 'Update stack' }),
+ UpdatePending: stackFixture(readyStack, { handleCommand: actionPending, action: 'Update stack' }),
+ UnstackFailed: stackFixture(readyStack, { handleCommand: actionFailure, action: 'Unstack all' }),
+ UnstackPending: stackFixture(readyStack, { handleCommand: actionPending, action: 'Unstack all' }),
+ MergePending: stackFixture(() => createPullRequest({ stack: createStack(), stackMergeStatus: 'pending' })),
+});
diff --git a/webviews/components/sidebar.fixture.tsx b/webviews/components/sidebar.fixture.tsx
new file mode 100644
index 0000000000..58a573b160
--- /dev/null
+++ b/webviews/components/sidebar.fixture.tsx
@@ -0,0 +1,84 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import * as React from 'react';
+import { Reviewer } from './reviewer';
+import Sidebar, { CollapsibleSidebar, SidebarPreview } from './sidebar';
+import { AccountType, ReviewState } from '../../src/github/interface';
+import { PullRequest } from '../../src/github/views';
+import { avatarUrl, createAccount, createPullRequest } from '../fixtures/data';
+import { clickFixtureElement, defineComponentFixture, defineFixtureGroup, defineThemeVariants, FixtureTheme } from '../fixtures/fixtureUtils';
+
+function populatedSidebar(): Partial {
+ return {
+ reviewers: [
+ { reviewer: createAccount('alice'), state: 'APPROVED' },
+ { reviewer: createAccount('bob'), state: 'CHANGES_REQUESTED' },
+ { reviewer: createAccount('carol'), state: 'REQUESTED' },
+ ],
+ assignees: [createAccount('octocat'), createAccount('maintainer')],
+ labels: [
+ { name: 'bug', displayName: 'bug', color: 'd73a4a' },
+ { name: 'accessibility', displayName: 'accessibility', color: '7057ff' },
+ ],
+ projectItems: [{ id: 'item-1', project: { id: 'project-1', title: 'Accessibility improvements' } }],
+ milestone: { id: 'milestone-1', number: 1, title: 'January release', createdAt: '2025-01-01T00:00:00Z', dueOn: '2025-01-31T00:00:00Z' },
+ };
+}
+
+const renderSidebar = (pr: PullRequest) => ;
+const renderCollapsibleSidebar = (pr: PullRequest) => ;
+
+function sidebarFixture(overrides: () => Partial, { collapsed = false, expanded = false, defaultTheme = FixtureTheme.Dark }: {
+ collapsed?: boolean;
+ expanded?: boolean;
+ defaultTheme?: FixtureTheme;
+} = {}) {
+ return defineComponentFixture({
+ width: collapsed ? 380 : 400,
+ defaultTheme,
+ createPullRequest: () => createPullRequest(overrides()),
+ render: collapsed ? renderCollapsibleSidebar : renderSidebar,
+ prepare: expanded ? clickFixtureElement('.collapsible-label-see-more') : undefined,
+ });
+}
+
+function reviewerFixture(state: ReviewState['state'], bot = false) {
+ return defineComponentFixture({
+ width: 400,
+ render: () => ,
+ });
+}
+
+const sidebar = defineFixtureGroup({
+ Empty: sidebarFixture(() => ({ labels: [], assignees: [], projectItems: [] })),
+ Populated: defineThemeVariants(defaultTheme => sidebarFixture(populatedSidebar, { defaultTheme })),
+ ReadOnly: sidebarFixture(() => ({ ...populatedSidebar(), hasWritePermission: false, canEdit: false, isAuthor: false })),
+ CopilotActions: sidebarFixture(() => ({ canAssignCopilot: true, canRequestCopilotReview: true })),
+ Issue: sidebarFixture(() => ({ ...populatedSidebar(), isIssue: true })),
+ Collapsed: sidebarFixture(populatedSidebar, { collapsed: true }),
+ Expanded: defineThemeVariants(defaultTheme => sidebarFixture(populatedSidebar, { collapsed: true, expanded: true, defaultTheme })),
+ Loading: defineComponentFixture({ width: 400, render: () => }),
+});
+
+const reviewers = defineFixtureGroup({
+ Requested: reviewerFixture('REQUESTED'),
+ Approved: reviewerFixture('APPROVED'),
+ ChangesRequested: reviewerFixture('CHANGES_REQUESTED'),
+ Commented: reviewerFixture('COMMENTED'),
+ Bot: reviewerFixture('APPROVED', true),
+ Team: defineComponentFixture({
+ width: 400,
+ render: () => ,
+ }),
+});
+
+export default defineFixtureGroup({ path: 'Components/Sidebar' }, {
+ Overview: sidebar,
+ Reviewers: reviewers,
+});
diff --git a/webviews/components/sidebar.tsx b/webviews/components/sidebar.tsx
index e776a2e4d0..8ef3c99a03 100644
--- a/webviews/components/sidebar.tsx
+++ b/webviews/components/sidebar.tsx
@@ -11,6 +11,7 @@ import { gitHubLabelColor } from '../../src/common/utils';
import { GithubItemStateEnum, IAccount, IMilestone, IProjectItem, isITeam, IssueReference, reviewerId, reviewerLabel, ReviewState } from '../../src/github/interface';
import { ChangeReviewersReply, PullRequest } from '../../src/github/views';
import PullRequestContext from '../common/context';
+import { useMaxViewportWidth } from '../common/hooks';
import { Label } from '../common/label';
import { AuthorLink, Avatar } from '../components/user';
@@ -334,17 +335,7 @@ export function CollapsibleSidebar(props: PullRequest) {
function CollapsedLabel(props: PullRequest) {
const { reviewers, assignees, labels, projectItems, milestone, isIssue } = props;
- const [isNarrowViewport, setIsNarrowViewport] = useState(false);
-
- useEffect(() => {
- const checkViewportWidth = () => {
- setIsNarrowViewport(window.innerWidth <= 350);
- };
-
- checkViewportWidth();
- window.addEventListener('resize', checkViewportWidth);
- return () => window.removeEventListener('resize', checkViewportWidth);
- }, []);
+ const isNarrowViewport = useMaxViewportWidth(350);
const AvatarStack = ({ users }: { users: { avatarUrl: string; name: string }[] }) => (
TimelineEvent[], pending = false) {
+ return defineComponentFixture({
+ width: 820,
+ createPullRequest: () => createPullRequest({
+ events: events(), isAuthor: false, hasReviewDraft: pending,
+ pendingReviewSummaryText: pending ? 'One remaining question about focus restoration.' : undefined,
+ }),
+ render: pr =>
,
+ });
+}
+
+export default defineFixtureGroup({ path: 'Components/Timeline' }, {
+ Discussion: timelineFixture(() => [createComment(), createReview('APPROVED')]),
+ Commits: timelineFixture(() => [
+ createCommit(),
+ createCommit({ id: 'commit-402', sha: 'abcdef1234567890abcdef1234567890abcdef12', message: 'Fix keyboard regression\n\nCover the empty stack.', status: 'FAILURE', verification: { verified: true, state: 'VALID', wasSignedByGitHub: true } }),
+ { id: 'new-commits', event: EventType.NewCommitsSinceReview },
+ ]),
+ PendingReview: timelineFixture(() => [createReview('PENDING', { body: '', bodyHTML: '', submittedAt: '' })], true),
+ BranchLifecycle: timelineFixture(() => [
+ { id: 'base-change', event: EventType.BaseRefChanged, actor: createAccount(), createdAt: '2025-01-15T10:00:00Z', previousRefName: 'release', currentRefName: 'main' },
+ { id: 'close', event: EventType.Closed, actor: createAccount(), createdAt: '2025-01-15T10:30:00Z' },
+ { id: 'reopen', event: EventType.Reopened, actor: createAccount(), createdAt: '2025-01-15T11:00:00Z' },
+ ]),
+ Merged: timelineFixture(() => [
+ { id: 'merge', graphNodeId: 'merge-1', event: EventType.Merged, user: createAccount(), createdAt: '2025-01-15T11:00:00Z', mergeRef: 'main', sha: '1234567890abcdef', commitUrl: 'https://github.com/octocat/component-explorer/commit/1234567', url: 'https://github.com/octocat/component-explorer/pull/102' },
+ { id: 'delete', event: EventType.HeadRefDeleted, actor: createAccount(), createdAt: '2025-01-15T11:30:00Z', headRef: 'feature/stack-navigation' },
+ ]),
+ Assignments: timelineFixture(() => [
+ { id: 501, event: EventType.Assigned, actor: createAccount(), createdAt: '2025-01-15T10:00:00Z', assignees: [createAccount('alice'), createAccount('bob')] },
+ { id: 502, event: EventType.Unassigned, actor: createAccount(), createdAt: '2025-01-15T10:05:00Z', unassignees: [createAccount('carol')] },
+ ]),
+ RelatedIssue: timelineFixture(() => [{
+ id: 'reference', event: EventType.CrossReferenced, actor: createAccount(), createdAt: '2025-01-15T11:00:00Z', willCloseTarget: true,
+ source: { number: 88, url: 'https://github.com/octocat/component-explorer/issues/88', extensionUrl: 'https://github.com/octocat/component-explorer/issues/88', title: 'Keyboard focus is lost after refresh', isIssue: true, owner: 'octocat', repo: 'component-explorer' },
+ }]),
+ CopilotSession: timelineFixture(() => [
+ { id: 'agent-start', event: EventType.CopilotStarted, createdAt: '2025-01-15T10:00:00Z', onBehalfOf: createAccount(), sessionLink: { id: 1, host: 'github.com', owner: 'octocat', repo: 'component-explorer', pullNumber: 102, sessionIndex: 0 } },
+ { id: 'agent-finish', event: EventType.CopilotFinished, createdAt: '2025-01-15T11:00:00Z', onBehalfOf: createAccount() },
+ ]),
+ CopilotFailed: timelineFixture(() => [{
+ id: 'agent-error', event: EventType.CopilotFinishedError, createdAt: '2025-01-15T11:00:00Z', onBehalfOf: createAccount(),
+ sessionLink: { id: 1, host: 'github.com', owner: 'octocat', repo: 'component-explorer', pullNumber: 102, sessionIndex: 0 },
+ }]),
+});
diff --git a/webviews/createPullRequestViewNew/app.fixture.tsx b/webviews/createPullRequestViewNew/app.fixture.tsx
new file mode 100644
index 0000000000..8fadc95153
--- /dev/null
+++ b/webviews/createPullRequestViewNew/app.fixture.tsx
@@ -0,0 +1,54 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import * as React from 'react';
+import { ChooseRemoteAndBranch, StackOption } from './app';
+import { StackCandidate } from '../../common/views';
+import { defineComponentFixture, defineFixtureGroup } from '../fixtures/fixtureUtils';
+
+function StackChoice({ candidate, disabled }: { candidate: StackCandidate; disabled: boolean }) {
+ const [checked, setChecked] = React.useState(false);
+ return ;
+}
+
+function stackFixture(existing = false, disabled = false) {
+ return defineComponentFixture({
+ view: 'create', width: 400,
+ render: () => ,
+ });
+}
+
+function branchFixture(branch: string | undefined, remoteCount: number, disabled = false) {
+ return defineComponentFixture({
+ view: 'create', width: 400,
+ render: () => Promise.resolve()}
+ defaultRemote={branch ? { owner: 'octocat', repositoryName: 'component-explorer' } : undefined}
+ defaultBranch={branch} isBase={true} remoteCount={remoteCount} disabled={disabled}
+ />,
+ });
+}
+
+const stack = defineFixtureGroup({
+ NewStack: stackFixture(),
+ ExistingStack: stackFixture(true),
+ Disabled: stackFixture(true, true),
+});
+
+const branch = defineFixtureGroup({
+ Default: branchFixture('main', 1),
+ Fork: branchFixture('feature/keyboard-navigation', 2),
+ LongBranch: branchFixture('feature/keep-keyboard-focus-stable-after-background-refresh', 2),
+ Loading: branchFixture(undefined, 0, true),
+ Disabled: branchFixture('main', 1, true),
+});
+
+export default defineFixtureGroup({ path: 'Views/Create pull request' }, {
+ 'Stack option': stack,
+ 'Branch picker': branch,
+});
diff --git a/webviews/editorWebview/overview.fixture.tsx b/webviews/editorWebview/overview.fixture.tsx
new file mode 100644
index 0000000000..91860244e5
--- /dev/null
+++ b/webviews/editorWebview/overview.fixture.tsx
@@ -0,0 +1,78 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import * as React from 'react';
+import { Overview, OverviewPreview } from './overview';
+import { CheckState, GithubItemStateEnum, MergeQueueState, PullRequestMergeability } from '../../src/github/interface';
+import { PullRequest } from '../../src/github/views';
+import { createAccount, createCheck, createComment, createCommit, createPullRequest, createReview, createStack } from '../fixtures/data';
+import { defineFixtureGroup, definePullRequestFixture, defineThemeVariants, FixtureTheme } from '../fixtures/fixtureUtils';
+
+const renderOverview = (pr: PullRequest) => ;
+
+function overviewFixture(width: number, defaultTheme: FixtureTheme) {
+ return definePullRequestFixture({
+ width,
+ height: 1000,
+ defaultTheme,
+ createPullRequest: () => createPullRequest({ stack: createStack() }),
+ render: renderOverview,
+ });
+}
+
+function stateFixture(overrides: () => Partial) {
+ return definePullRequestFixture({
+ width: 1100,
+ height: 900,
+ createPullRequest: () => createPullRequest(overrides()),
+ render: renderOverview,
+ });
+}
+
+export default defineFixtureGroup({ path: 'Views/Pull request overview' }, {
+ Desktop: defineThemeVariants(defaultTheme => overviewFixture(1100, defaultTheme)),
+ Narrow: defineThemeVariants(defaultTheme => overviewFixture(360, defaultTheme)),
+ Scrollable: defineThemeVariants(defaultTheme => definePullRequestFixture({
+ width: 900,
+ viewportHeight: 420,
+ defaultTheme,
+ createPullRequest: () => createPullRequest({ stack: createStack() }),
+ render: renderOverview,
+ })),
+ Draft: stateFixture(() => ({ isDraft: true })),
+ Conflicts: stateFixture(() => ({ mergeable: PullRequestMergeability.Conflict, canUpdateBranch: true })),
+ FailedChecks: stateFixture(() => ({
+ mergeable: PullRequestMergeability.NotMergeable,
+ status: { state: CheckState.Failure, statuses: [createCheck(CheckState.Failure, { description: '2 tests failed' })] },
+ })),
+ AwaitingReview: stateFixture(() => ({
+ mergeable: PullRequestMergeability.NotMergeable,
+ reviewRequirement: { count: 2, state: CheckState.Pending, approvals: [], requestedChanges: [] },
+ reviewers: [{ reviewer: createAccount('reviewer'), state: 'REQUESTED' }],
+ })),
+ ChangesRequested: stateFixture(() => ({
+ mergeable: PullRequestMergeability.NotMergeable,
+ reviewRequirement: { count: 1, state: CheckState.Failure, approvals: [], requestedChanges: ['reviewer'] },
+ reviewers: [{ reviewer: createAccount('reviewer'), state: 'CHANGES_REQUESTED' }],
+ events: [createReview('CHANGES_REQUESTED', { body: 'Please add a regression test.', bodyHTML: 'Please add a regression test.
' })],
+ })),
+ Queued: stateFixture(() => ({ mergeQueueEntry: { position: 2, state: MergeQueueState.Queued, url: 'https://github.com/octocat/component-explorer/queue/main' } })),
+ Merged: stateFixture(() => ({ state: GithubItemStateEnum.Merged, isRemoteHeadDeleted: true, isLocalHeadDeleted: true })),
+ Closed: stateFixture(() => ({ state: GithubItemStateEnum.Closed })),
+ ReadOnly: stateFixture(() => ({ hasWritePermission: false, canEdit: false, isAuthor: false, isCurrentlyCheckedOut: false })),
+ Issue: stateFixture(() => ({ isIssue: true, title: 'Keyboard navigation skips the stack', titleHTML: 'Keyboard navigation skips the stack' })),
+ IssueCompleted: stateFixture(() => ({ isIssue: true, state: GithubItemStateEnum.Closed, stateReason: 'COMPLETED' })),
+ Discussion: stateFixture(() => ({ events: [createCommit(), createComment(), createReview('APPROVED')] })),
+ PendingReview: stateFixture(() => ({
+ isAuthor: false, hasReviewDraft: true, pendingReviewSummaryText: 'The navigation works well. One question about focus restoration.',
+ events: [createReview('PENDING', { body: '', bodyHTML: '', submittedAt: '' })],
+ })),
+ ColdLoadPreview: definePullRequestFixture({
+ width: 1100,
+ height: 600,
+ createPullRequest,
+ render: pr => ,
+ }),
+});
diff --git a/webviews/editorWebview/overview.tsx b/webviews/editorWebview/overview.tsx
index 478a06d74b..76c502bd23 100644
--- a/webviews/editorWebview/overview.tsx
+++ b/webviews/editorWebview/overview.tsx
@@ -5,6 +5,7 @@
import * as React from 'react';
import { OverviewItemPreview, PullRequest } from '../../src/github/views';
+import { useMaxViewportWidth } from '../common/hooks';
import { AddComment, CommentPreview, CommentView } from '../components/comment';
import { Header, HeaderPreview } from '../components/header';
@@ -13,25 +14,8 @@ import Sidebar, { CollapsibleSidebar, SidebarPreview } from '../components/sideb
import { StickyHeader, useStickyHeader } from '../components/stickyHeader';
import { Timeline } from '../components/timeline';
-const useMediaQuery = (query: string) => {
- const [matches, setMatches] = React.useState(window.matchMedia(query).matches);
-
- React.useEffect(() => {
- const mediaQueryList = window.matchMedia(query);
- const documentChangeHandler = () => setMatches(mediaQueryList.matches);
-
- mediaQueryList.addEventListener('change', documentChangeHandler);
-
- return () => {
- mediaQueryList.removeEventListener('change', documentChangeHandler);
- };
- }, [query]);
-
- return matches;
-};
-
export const Overview = (pr: PullRequest) => {
- const isSingleColumnLayout = useMediaQuery('(max-width: 768px)');
+ const isSingleColumnLayout = useMaxViewportWidth(768);
const titleRef = React.useRef(null);
const isStuck = useStickyHeader(titleRef);
@@ -57,7 +41,7 @@ export const Overview = (pr: PullRequest) => {
};
export const OverviewPreview = (preview: OverviewItemPreview | PullRequest) => {
- const isSingleColumnLayout = useMediaQuery('(max-width: 768px)');
+ const isSingleColumnLayout = useMaxViewportWidth(768);
return <>
diff --git a/webviews/fixtures/data.ts b/webviews/fixtures/data.ts
new file mode 100644
index 0000000000..8db1a7df29
--- /dev/null
+++ b/webviews/fixtures/data.ts
@@ -0,0 +1,95 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { CommentEvent, CommitEvent, EventType, ReviewEvent } from '../../src/common/timelineEvent';
+import { AccountType, CheckState, GithubItemStateEnum, IAccount, PullRequestCheckStatus, PullRequestMergeability, PullRequestStack, ReviewState } from '../../src/github/interface';
+import { PullRequest } from '../../src/github/views';
+import { PullRequestBuilder } from '../editorWebview/test/builder/pullRequest';
+
+export const fixtureNow = '2025-01-15T12:00:00Z';
+
+export const avatarUrl = `data:image/svg+xml,${encodeURIComponent('
')}`;
+
+export function createAccount(login = 'octocat', overrides: Partial
= {}): IAccount {
+ return { id: `account-${login}`, login, name: login, url: `https://github.com/${login}`, avatarUrl, accountType: AccountType.User, ...overrides };
+}
+
+export function createCheck(state: CheckState, overrides: Partial = {}): PullRequestCheckStatus {
+ return {
+ id: 'build', databaseId: 42, context: 'Build and test', state, description: 'Continuous integration',
+ targetUrl: 'https://github.com/octocat/component-explorer/actions/runs/42',
+ url: undefined, avatarUrl, workflowName: 'Continuous integration', event: 'pull_request',
+ isRequired: true, isCheckRun: true, ...overrides,
+ };
+}
+
+export function createReview(state: ReviewState['state'], overrides: Partial = {}): ReviewEvent {
+ return {
+ id: 201, event: EventType.Reviewed, comments: [], submittedAt: '2025-01-15T11:00:00Z',
+ body: 'Keyboard navigation looks good.', bodyHTML: 'Keyboard navigation looks good.
',
+ htmlUrl: 'https://github.com/octocat/component-explorer/pull/102#pullrequestreview-201',
+ user: createAccount('reviewer'), authorAssociation: 'MEMBER', state, ...overrides,
+ };
+}
+
+export function createComment(overrides: Partial = {}): CommentEvent {
+ return {
+ id: 301, graphNodeId: 'comment-301', event: EventType.Commented, user: createAccount('reviewer'),
+ body: 'Could we also cover keyboard navigation?', bodyHTML: 'Could we also cover keyboard navigation?
',
+ htmlUrl: 'https://github.com/octocat/component-explorer/pull/102#issuecomment-301',
+ createdAt: '2025-01-15T11:30:00Z', canEdit: true, canDelete: true, ...overrides,
+ };
+}
+
+export function createCommit(overrides: Partial = {}): CommitEvent {
+ return {
+ id: 'commit-401', event: EventType.Committed, author: createAccount(),
+ sha: '1234567890abcdef1234567890abcdef12345678',
+ htmlUrl: 'https://github.com/octocat/component-explorer/commit/1234567',
+ message: 'Support keyboard navigation in the stack',
+ committedDate: new Date('2025-01-15T10:30:00Z'), status: 'SUCCESS', ...overrides,
+ };
+}
+
+export function createPullRequest(overrides: Partial = {}): PullRequest {
+ const pr = new PullRequestBuilder()
+ .owner('octocat')
+ .repo('component-explorer')
+ .number(102)
+ .title('Add accessible pull request stack navigation')
+ .titleHTML('Add accessible pull request stack navigation')
+ .url('https://github.com/octocat/component-explorer/pull/102')
+ .createdAt('2025-01-15T10:00:00Z')
+ .body('Adds keyboard-friendly stack navigation and clearer merge readiness.')
+ .bodyHTML('Adds keyboard-friendly stack navigation and clearer merge readiness.
- Keep the current pull request visible
- Explain blocked merge requirements
')
+ .head('feature/stack-navigation')
+ .author(author => author.login('octocat').name('Octocat').avatarUrl(avatarUrl))
+ .labels([{ name: 'enhancement', displayName: 'enhancement', color: 'a2eeef' }])
+ .status(status => status.state(CheckState.Success).statuses([createCheck(CheckState.Success, { description: 'All checks passed' })]))
+ .build();
+ return {
+ ...pr,
+ events: [],
+ reviewers: [],
+ assignees: [{ ...pr.author }],
+ reactions: [],
+ closingIssues: [],
+ mergeMethodsAvailability: { ...pr.mergeMethodsAvailability },
+ ...overrides,
+ };
+}
+
+export function createStack(): PullRequestStack {
+ return {
+ position: 2,
+ size: 3,
+ base: 'main',
+ pullRequests: [
+ { position: 1, number: 101, title: 'Introduce stack metadata', head: 'feature/stack-metadata', url: 'https://github.com/octocat/component-explorer/pull/101', state: GithubItemStateEnum.Open, isDraft: false, mergeable: PullRequestMergeability.Mergeable },
+ { position: 2, number: 102, title: 'Add accessible pull request stack navigation', head: 'feature/stack-navigation', url: 'https://github.com/octocat/component-explorer/pull/102', state: GithubItemStateEnum.Open, isDraft: false, mergeable: PullRequestMergeability.Mergeable },
+ { position: 3, number: 103, title: 'Show stack merge readiness', head: 'feature/stack-readiness', url: 'https://github.com/octocat/component-explorer/pull/103', state: GithubItemStateEnum.Open, isDraft: false, mergeable: PullRequestMergeability.Mergeable },
+ ],
+ };
+}
diff --git a/webviews/fixtures/fixtureUtils.tsx b/webviews/fixtures/fixtureUtils.tsx
new file mode 100644
index 0000000000..160f27185d
--- /dev/null
+++ b/webviews/fixtures/fixtureUtils.tsx
@@ -0,0 +1,317 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import { defineFixture, defineFixtureGroup, defineFixtureVariants, SingleFixtureExport } from '@vscode/component-explorer';
+import { applyTheme, getTheme, parseCatalog, Theme } from '@vscode/webview-themes';
+import catalogData from '@vscode/webview-themes/catalog.json';
+import dayjs from 'dayjs';
+import * as React from 'react';
+import * as ReactDOM from 'react-dom';
+import { z } from 'zod';
+import { createPullRequest, fixtureNow } from './data';
+import themeStyles from './theme.css?inline';
+import extensionManifest from '../../package.json';
+import { MergeQueueState } from '../../src/github/interface';
+import { PullRequest } from '../../src/github/views';
+import activityBarStyles from '../activityBarView/index.css?inline';
+import commonStyles from '../common/common.css?inline';
+import PullRequestContext, { PRContext } from '../common/context';
+import { ViewportWidthContext } from '../common/hooks';
+import { createWebviewHost, ReplyMessage, RequestMessage, WebviewTransport } from '../common/host';
+import { TimestampFormat, TimestampFormatContext } from '../components/timestamp';
+import createStyles from '../createPullRequestViewNew/index.css?inline';
+import overviewStyles from '../editorWebview/index.css?inline';
+
+export { defineFixtureGroup };
+
+export enum FixtureTheme {
+ Dark = 'dark',
+ Light = 'light',
+}
+
+const themeCatalog = parseCatalog(catalogData);
+const themeSchema = z.enum([FixtureTheme.Dark, FixtureTheme.Light, ...themeCatalog.themes.map(theme => theme.id)]);
+
+function resolveTheme(id: string): Theme {
+ return getTheme(themeCatalog, id === FixtureTheme.Dark ? 'Dark Modern' : id === FixtureTheme.Light ? 'Light Modern' : id);
+}
+
+function isDarkTheme(theme: Theme): boolean {
+ return theme.kind === 'vscode-dark' || theme.kind === 'vscode-high-contrast';
+}
+
+function applyExtensionColors(root: HTMLElement, theme: Theme): void {
+ const kind = theme.kind === 'vscode-high-contrast-light' ? 'highContrastLight'
+ : theme.kind === 'vscode-high-contrast' ? 'highContrast'
+ : theme.kind === 'vscode-dark' ? 'dark' : 'light';
+ for (const color of extensionManifest.contributes.colors) {
+ const value = color.defaults[kind];
+ root.style.setProperty(`--vscode-${color.id.replace('.', '-')}`,
+ value.startsWith('#') ? value : `var(--vscode-${value.replace('.', '-')})`);
+ }
+}
+
+export function defineThemeVariants(createFixture: (defaultTheme: FixtureTheme) => SingleFixtureExport) {
+ return defineFixtureVariants({
+ Dark: createFixture(FixtureTheme.Dark),
+ Light: createFixture(FixtureTheme.Light),
+ });
+}
+
+export type FixtureCommandHandler = (request: RequestMessage, pr: PullRequest) => unknown | Promise;
+
+interface PullRequestFixtureOptions {
+ readonly width: number;
+ readonly height?: number;
+ readonly viewportHeight?: number;
+ readonly defaultTheme?: FixtureTheme;
+ readonly view?: 'overview' | 'component' | 'activityBar' | 'create';
+ readonly createPullRequest: () => PullRequest;
+ readonly render: (pr: PullRequest) => React.ReactElement;
+ readonly handleCommand?: FixtureCommandHandler;
+ readonly prepare?: (container: HTMLElement) => void | Promise;
+}
+
+const timestampFormat: TimestampFormat = {
+ relative: date => dayjs(date).from(dayjs(fixtureNow)),
+ title: date => new Date(date).toISOString(),
+};
+
+const viewStyles = {
+ overview: overviewStyles,
+ component: overviewStyles,
+ activityBar: activityBarStyles,
+ create: createStyles,
+};
+
+interface ComponentFixtureOptions extends Omit {
+ readonly width?: number;
+ readonly height?: number;
+ readonly createPullRequest?: () => PullRequest;
+}
+
+export function defineComponentFixture(options: ComponentFixtureOptions) {
+ return definePullRequestFixture({
+ width: 720,
+ view: 'component',
+ ...options,
+ createPullRequest: options.createPullRequest ?? createPullRequest,
+ });
+}
+
+export function clickFixtureElement(selector: string): (container: HTMLElement) => Promise {
+ return async container => {
+ await new Promise(resolve => container.ownerDocument.defaultView!.setTimeout(resolve, 0));
+ const element = container.querySelector(selector);
+ if (!element) {
+ throw new Error(`Fixture element not found: ${selector}`);
+ }
+ element.click();
+ await new Promise(resolve => container.ownerDocument.defaultView!.setTimeout(resolve, 0));
+ };
+}
+
+export function definePullRequestFixture(options: PullRequestFixtureOptions) {
+ const styles = [commonStyles, viewStyles[options.view ?? 'overview'], themeStyles]
+ .join('\n')
+ .replace(/(^|[\s,>+~])(?:html|body)(?=[\s.{:#>+~])/gm, '$1.fixture-root')
+ .replace(/#app\b/g, '.fixture-root')
+ .replace(/@media\s*(\(max-width:\s*\d+px\))/g, '@container fixture $1');
+ const defaultTheme = options.defaultTheme ?? FixtureTheme.Dark;
+ return defineFixture({
+ isolation: 'none',
+ background: defaultTheme,
+ inputSchema: z.object({
+ theme: themeSchema.default(defaultTheme),
+ }),
+ inputControls: {
+ theme: { placement: 'both', label: 'Theme' },
+ },
+ displayMode: { type: 'component' },
+ labels: ['.screenshot'],
+ render: (container, renderContext) => {
+ const viewport = container.ownerDocument.createElement('div');
+ viewport.style.width = `${options.width}px`;
+ viewport.style.containerType = 'inline-size';
+ viewport.style.containerName = 'fixture';
+ viewport.style.contain = 'layout paint';
+ const stylesheet = container.ownerDocument.createElement('style');
+ stylesheet.textContent = `@scope {
+ ${styles}
+ }`;
+ viewport.appendChild(stylesheet);
+ container.appendChild(viewport);
+ let theme = resolveTheme(themeSchema.parse(renderContext.input.theme));
+ const pr = { ...options.createPullRequest(), isDarkTheme: isDarkTheme(theme) };
+ const transport = new FixtureTransport(pr, options.handleCommand);
+ const host = createWebviewHost(transport);
+ const context = new PRContext(host);
+ const root = container.ownerDocument.createElement('div');
+ root.className = `fixture-root fixture-view-${options.view ?? 'overview'}`;
+ const themeController = applyTheme(root, theme);
+ applyExtensionColors(root, theme);
+ root.style.width = `${options.width}px`;
+ if (options.height !== undefined) {
+ root.style.minHeight = `${options.height}px`;
+ }
+ if (options.viewportHeight !== undefined) {
+ root.style.height = `${options.viewportHeight}px`;
+ root.style.overflow = 'auto';
+ }
+ if (options.view && options.view !== 'overview') {
+ root.style.display = 'flow-root';
+ }
+ viewport.appendChild(root);
+ let disposed = false;
+ let themeWatcher: { dispose(): void } | undefined;
+ const dispose = () => {
+ if (disposed) {
+ return;
+ }
+ disposed = true;
+ renderContext.signal.removeEventListener('abort', dispose);
+ themeWatcher?.dispose();
+ ReactDOM.unmountComponentAtNode(root);
+ context.dispose();
+ host.dispose();
+ transport.dispose();
+ themeController.dispose();
+ viewport.remove();
+ };
+ const render = () => {
+ if (!disposed) {
+ ReactDOM.render(
+
+
+
+ {options.render(context.pr!)}
+
+
+ ,
+ root,
+ );
+ }
+ };
+ context.onchange = render;
+ renderContext.signal.addEventListener('abort', dispose, { once: true });
+ try {
+ if (renderContext.signal.aborted) {
+ dispose();
+ return { dispose };
+ }
+ render();
+ themeWatcher = renderContext.watchInput('theme', value => {
+ const nextTheme = resolveTheme(themeSchema.parse(value));
+ if (!disposed && theme !== nextTheme) {
+ theme = nextTheme;
+ themeController.setTheme(theme);
+ applyExtensionColors(root, theme);
+ context.updatePR({ isDarkTheme: isDarkTheme(theme) });
+ }
+ });
+ const ready = options.prepare ? Promise.resolve(options.prepare(root)) : undefined;
+ return { dispose, ready };
+ } catch (error) {
+ dispose();
+ throw error;
+ }
+ },
+ });
+}
+
+class FixtureTransport implements WebviewTransport {
+ private _state: unknown;
+ private readonly _listeners = new Set<(message: ReplyMessage) => void>();
+ private _disposed = false;
+
+ constructor(private readonly _pr: PullRequest, private readonly _handler?: FixtureCommandHandler) {
+ this._state = _pr;
+ }
+
+ getState(): T | undefined {
+ return this._state as T | undefined;
+ }
+
+ setState(state: T): void {
+ this._state = state;
+ }
+
+ onMessage(listener: (message: ReplyMessage) => void): () => void {
+ this._listeners.add(listener);
+ return () => this._listeners.delete(listener);
+ }
+
+ postMessage(request: RequestMessage): void {
+ Promise.resolve().then(() => this._handler ? this._handler(request, this._pr) : defaultCommand(request, this._pr))
+ .then(res => this._respond({ seq: request.req, res }), error => this._respond({
+ seq: request.req, err: error instanceof Error ? error.message : String(error),
+ }));
+ }
+
+ dispose(): void {
+ this._disposed = true;
+ this._listeners.clear();
+ this._state = undefined;
+ }
+
+ private _respond(response: ReplyMessage): void {
+ if (!this._disposed) {
+ this._listeners.forEach(listener => listener(response));
+ }
+ }
+}
+
+export function defaultCommand(request: RequestMessage, pr: PullRequest): unknown {
+ switch (request.command) {
+ case 'pr.checkMergeability':
+ return { mergeability: pr.mergeable };
+ case 'pr.update-stack':
+ return { updatedPullRequests: pr.stack?.pullRequests.map(entry => entry.number) ?? [] };
+ case 'pr.unstack-all':
+ return { cancelled: false, remainingPullRequests: [] };
+ case 'pr.merge-stack':
+ return { status: 'pending' };
+ case 'pr.readyForReview':
+ return { isDraft: false, mergeable: pr.mergeable, allowAutoMerge: pr.allowAutoMerge };
+ case 'pr.update-automerge': {
+ const args = request.args;
+ if (!args || typeof args !== 'object') {
+ throw new Error('Fixture auto-merge command requires options');
+ }
+ const options = args as Record;
+ const autoMerge = options.autoMerge !== undefined ? options.autoMerge : !!pr.autoMerge;
+ const autoMergeMethod = options.autoMergeMethod !== undefined ? options.autoMergeMethod : pr.autoMergeMethod ?? pr.defaultMergeMethod;
+ if (typeof autoMerge !== 'boolean' || (autoMergeMethod !== 'merge' && autoMergeMethod !== 'squash' && autoMergeMethod !== 'rebase')) {
+ throw new Error('Invalid fixture auto-merge options');
+ }
+ return autoMerge ? { autoMerge, autoMergeMethod } : { autoMerge: false };
+ }
+ case 'pr.update-branch':
+ return { events: pr.events, mergeable: pr.mergeable, canUpdateBranch: false };
+ case 'pr.dequeue':
+ return true;
+ case 'pr.enqueue':
+ return {
+ mergeQueueEntry: pr.mergeQueueEntry ? { ...pr.mergeQueueEntry } : {
+ position: 1,
+ state: MergeQueueState.AwaitingChecks,
+ url: `https://github.com/${pr.owner}/${pr.repo}/queue/${encodeURIComponent(pr.base)}`,
+ },
+ };
+ case 'pr.re-request-review':
+ return {
+ reviewers: pr.reviewers.map(review => review.reviewer.id === request.args ? { ...review, state: 'REQUESTED' } : { ...review }),
+ };
+ case 'pr.openOnGitHub':
+ case 'pr.view-check-logs':
+ case 'pr.copy-prlink':
+ case 'pr.openCommitChanges':
+ case 'pr.open-session-log':
+ case 'pr.gotoChangesSinceReview':
+ return undefined;
+ default:
+ throw new Error(`No fixture response configured for ${request.command}`);
+ }
+}
diff --git a/webviews/fixtures/styles.d.ts b/webviews/fixtures/styles.d.ts
new file mode 100644
index 0000000000..6e0614086e
--- /dev/null
+++ b/webviews/fixtures/styles.d.ts
@@ -0,0 +1,9 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+declare module '*.css?inline' {
+ const content: string;
+ export default content;
+}
diff --git a/webviews/fixtures/theme.css b/webviews/fixtures/theme.css
new file mode 100644
index 0000000000..dd50849d47
--- /dev/null
+++ b/webviews/fixtures/theme.css
@@ -0,0 +1,36 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+.fixture-root {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 24px 32px;
+ background: var(--vscode-editor-background);
+ color: var(--vscode-foreground);
+ font-family: var(--vscode-font-family);
+ font-size: var(--vscode-font-size);
+ font-weight: var(--vscode-font-weight);
+}
+
+.fixture-root.fixture-view-component {
+ padding: 16px;
+}
+
+.fixture-root .loading {
+ animation: none;
+}
+
+.fixture-root .comment-body pre {
+ font-family: var(--vscode-editor-font-family);
+ line-height: 20px;
+}
+
+.fixture-root a {
+ color: var(--vscode-textLink-foreground);
+}
+
+.fixture-root a:hover {
+ color: var(--vscode-textLink-activeForeground);
+}