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 checklist
Keep keyboard focus visible.
  • Support narrow layouts
  • Add regression tests
const ready = checks.every(check => check.passed);
PlatformResult
WindowsPassed
LinuxPassed
', + })} />; +} + +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); +}