From 47afb4d4f33c1f00fd1ae4ce990d70849ffaa996 Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:23:45 -0500 Subject: [PATCH 01/29] Make Artifact Explorer resilient with recoverable jobs, soft limits, browser preview, and sharing skill Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 72ee5a2d-bcbb-4e0f-b86f-6ad56d4fc033 --- docs/README.plugins.md | 2 +- docs/README.skills.md | 1 + extensions/pr-artifact-explorer/README.md | 28 +- .../pr-artifact-explorer/assets/app.css | 307 +++++ extensions/pr-artifact-explorer/assets/app.js | 63 +- .../pr-artifact-explorer/assets/browser.js | 1095 +++++++++++++++++ .../assets/docs/continuing-job.png | Bin 0 -> 14356 bytes .../assets/docs/preview-controls.png | Bin 0 -> 23207 bytes .../assets/docs/preview-options.png | Bin 0 -> 38797 bytes .../assets/octicons/arrow-left.svg | 1 + .../assets/octicons/arrow-right.svg | 1 + .../assets/octicons/book.svg | 1 + .../assets/octicons/copy.svg | 1 + .../assets/octicons/dash.svg | 1 + .../assets/octicons/device-camera.svg | 1 + .../assets/octicons/device-mobile.svg | 1 + .../assets/octicons/keyboard.svg | 1 + .../assets/octicons/plus.svg | 1 + .../backend-gaps.test.mjs | 179 +++ extensions/pr-artifact-explorer/cache.mjs | 413 ++++--- extensions/pr-artifact-explorer/capture.mjs | 346 ++++++ .../pr-artifact-explorer/capture.test.mjs | 127 ++ extensions/pr-artifact-explorer/constants.mjs | 18 +- extensions/pr-artifact-explorer/extension.mjs | 366 +++++- extensions/pr-artifact-explorer/github.mjs | 24 + .../pr-artifact-explorer/jobs-limits.test.mjs | 117 ++ extensions/pr-artifact-explorer/jobs.mjs | 411 +++++++ extensions/pr-artifact-explorer/limits.mjs | 166 +++ extensions/pr-artifact-explorer/preview.mjs | 8 +- extensions/pr-artifact-explorer/render.mjs | 25 +- extensions/pr-artifact-explorer/server.mjs | 375 +++++- .../pr-artifact-explorer/server.test.mjs | 116 ++ extensions/pr-artifact-explorer/targets.mjs | 326 +++++ .../pr-artifact-explorer/targets.test.mjs | 102 ++ extensions/pr-artifact-explorer/zip.mjs | 392 +++--- .../pr-artifact-explorer/zip64.test.mjs | 90 ++ plugins/pr-artifact-explorer/plugin.json | 3 + skills/pr-artifact-explorer/SKILL.md | 44 + 38 files changed, 4724 insertions(+), 429 deletions(-) create mode 100644 extensions/pr-artifact-explorer/assets/browser.js create mode 100644 extensions/pr-artifact-explorer/assets/docs/continuing-job.png create mode 100644 extensions/pr-artifact-explorer/assets/docs/preview-controls.png create mode 100644 extensions/pr-artifact-explorer/assets/docs/preview-options.png create mode 100644 extensions/pr-artifact-explorer/assets/octicons/arrow-left.svg create mode 100644 extensions/pr-artifact-explorer/assets/octicons/arrow-right.svg create mode 100644 extensions/pr-artifact-explorer/assets/octicons/book.svg create mode 100644 extensions/pr-artifact-explorer/assets/octicons/copy.svg create mode 100644 extensions/pr-artifact-explorer/assets/octicons/dash.svg create mode 100644 extensions/pr-artifact-explorer/assets/octicons/device-camera.svg create mode 100644 extensions/pr-artifact-explorer/assets/octicons/device-mobile.svg create mode 100644 extensions/pr-artifact-explorer/assets/octicons/keyboard.svg create mode 100644 extensions/pr-artifact-explorer/assets/octicons/plus.svg create mode 100644 extensions/pr-artifact-explorer/backend-gaps.test.mjs create mode 100644 extensions/pr-artifact-explorer/capture.mjs create mode 100644 extensions/pr-artifact-explorer/capture.test.mjs create mode 100644 extensions/pr-artifact-explorer/jobs-limits.test.mjs create mode 100644 extensions/pr-artifact-explorer/jobs.mjs create mode 100644 extensions/pr-artifact-explorer/limits.mjs create mode 100644 extensions/pr-artifact-explorer/server.test.mjs create mode 100644 extensions/pr-artifact-explorer/targets.mjs create mode 100644 extensions/pr-artifact-explorer/targets.test.mjs create mode 100644 extensions/pr-artifact-explorer/zip64.test.mjs create mode 100644 skills/pr-artifact-explorer/SKILL.md diff --git a/docs/README.plugins.md b/docs/README.plugins.md index fa5fa31550..9cf7eefe72 100644 --- a/docs/README.plugins.md +++ b/docs/README.plugins.md @@ -97,7 +97,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-plugins) for guidelines on how t | [power-bi-development](../plugins/power-bi-development/README.md) | Comprehensive Power BI development resources including data modeling, DAX optimization, performance tuning, visualization design, security best practices, and DevOps/ALM guidance for building enterprise-grade Power BI solutions. | 8 items | power-bi, dax, data-modeling, performance, visualization, security, devops, business-intelligence | | [power-platform-architect](../plugins/power-platform-architect/README.md) | Solution Architect for the Microsoft Power Platform, turning business requirements into functioning Power Platform solution architectures. | 1 items | power-platform, power-platform-architect, power-apps, dataverse, power-automate, power-pages, power-bi | | [power-platform-mcp-connector-development](../plugins/power-platform-mcp-connector-development/README.md) | Complete toolkit for developing Power Platform custom connectors with Model Context Protocol integration for Microsoft Copilot Studio | 3 items | power-platform, mcp, copilot-studio, custom-connector, json-rpc | -| [pr-artifact-explorer](../plugins/pr-artifact-explorer/README.md) | Navigate pull requests and securely explore GitHub Actions artifacts, including test results, static sites, terminal recordings, and source files. | 1 items | actions-artifacts, artifact-browser, canvas, copilot-extension, github-actions, pull-requests, test-results | +| [pr-artifact-explorer](../plugins/pr-artifact-explorer/README.md) | Navigate pull requests and securely explore GitHub Actions artifacts, including test results, static sites, terminal recordings, and source files. | 2 items | actions-artifacts, artifact-browser, canvas, copilot-extension, github-actions, pull-requests, test-results | | [pr-review](../plugins/pr-review/README.md) | Review GitHub pull requests in a read-only canvas with AI-grouped changes, expandable diffs, and questions scoped to groups, files, or hunks. | 1 items | canvas, code-review, copilot-extension, pull-request, pull-request-review | | [project-documenter](../plugins/project-documenter/README.md) | Generate professional project documentation with draw.io architecture diagrams and Word (.docx) output with embedded images. Automatically discovers any project's technology stack and produces Markdown, diagrams, PNG exports, and a formatted Word document. | 3 items | documentation, architecture-diagrams, drawio, word-document, docx, png-images, c4-model, project-summary, auto-discovery | | [project-planning](../plugins/project-planning/README.md) | Tools and guidance for software project planning, feature breakdown, epic management, implementation planning, and task organization for development teams. | 15 items | planning, project-management, epic, feature, implementation, task, architecture, technical-spike | diff --git a/docs/README.skills.md b/docs/README.skills.md index 2e3530a45e..a6401fe734 100644 --- a/docs/README.skills.md +++ b/docs/README.skills.md @@ -330,6 +330,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-skills) for guidelines on how to | [power-platform-architect](../skills/power-platform-architect/SKILL.md)
`gh skills install github/awesome-copilot power-platform-architect` | Use this skill when the user needs to transform business requirements, use case descriptions, or meeting transcripts into a technical Power Platform solution architecture, including component selection and Mermaid.js diagrams. | None | | [power-platform-mcp-connector-suite](../skills/power-platform-mcp-connector-suite/SKILL.md)
`gh skills install github/awesome-copilot power-platform-mcp-connector-suite` | Generate complete Power Platform custom connector with MCP integration for Copilot Studio - includes schema generation, troubleshooting, and validation | None | | [powerbi-modeling](../skills/powerbi-modeling/SKILL.md)
`gh skills install github/awesome-copilot powerbi-modeling` | Power BI semantic modeling assistant for building optimized data models. Use when working with Power BI semantic models, creating measures, designing star schemas, configuring relationships, implementing RLS, or optimizing model performance. Triggers on queries about DAX calculations, table relationships, dimension/fact table design, naming conventions, model documentation, cardinality, cross-filter direction, calculation groups, and data model best practices. Always connects to the active model first using power-bi-modeling MCP tools to understand the data structure before providing guidance. | `references/MEASURES-DAX.md`
`references/PERFORMANCE.md`
`references/RELATIONSHIPS.md`
`references/RLS.md`
`references/STAR-SCHEMA.md` | +| [pr-artifact-explorer](../skills/pr-artifact-explorer/SKILL.md)
`gh skills install github/awesome-copilot pr-artifact-explorer` | Open, share, and inspect GitHub PRs, issues, Actions artifacts, and local or HTTP(S) web previews in the Artifact Explorer canvas, with viewport presets, zoom, screenshots, and recoverable download jobs. | None | | [pr-dashboard](../skills/pr-dashboard/SKILL.md)
`gh skills install github/awesome-copilot pr-dashboard` | Open a GitHub PR dashboard in the browser. Use when the user asks to see their pull requests, open the PR dashboard, show PRs for a date range, or check PR status. Trigger phrases include "show my PRs", "open PR dashboard", "pull request dashboard". | `assets/dashboard.html`
`scripts/lib/utils.mjs`
`scripts/pr-dashboard-cli.mjs` | | [pr-screenshots](../skills/pr-screenshots/SKILL.md)
`gh skills install github/awesome-copilot pr-screenshots` | Embed before/after screenshots and annotated images in pull request descriptions. Covers PR description patterns, image upload for Azure DevOps and GitHub, and sizing best practices. | None | | [prd](../skills/prd/SKILL.md)
`gh skills install github/awesome-copilot prd` | Generate high-quality Product Requirements Documents (PRDs) for software systems and AI-powered features. Includes executive summaries, user stories, technical specifications, and risk analysis. | None | diff --git a/extensions/pr-artifact-explorer/README.md b/extensions/pr-artifact-explorer/README.md index 84e2f70444..c975b0dfb9 100644 --- a/extensions/pr-artifact-explorer/README.md +++ b/extensions/pr-artifact-explorer/README.md @@ -22,6 +22,24 @@ Artifact Explorer is a community-built Copilot canvas extension with the interna - Keeps large files available for direct download and falls back to the original artifact archive when a ZIP entry uses an unsupported encoding. - Provides per-artifact cleanup, clear-all cleanup, and a cache usage view. - Uses a responsive Primer interface that follows Copilot's light and dark themes. +- Runs downloads, indexing, and decompression as recoverable background jobs that are independent of any request or view. Closing the view, retrying, or restarting the canvas never kills them, and a completed ZIP is reused on retry. A provider termination is reported honestly as `interrupted` with a Retry action. +- Soft limits (download size, ZIP entries, central-directory size, streamed entry size, idle time) never stop work. When one is crossed the job keeps running and the canvas asks for a larger value; the new limit must be at least the live value at submission and is checked atomically against live progress. +- Supports ZIP64 and validates the full central directory. The only hard stops are real integrity, safe-integer, path, and free-disk guards; there are no hidden size ceilings. +- Provides a browser-style preview for localhost and HTTP(S) URLs: Mobile/Tablet/Desktop/Native/Custom viewport presets, windowed zoom (separate from the viewport), back/forward/refresh, natural-language navigation, and a keyboard-hint overlay (`?`). Pages that forbid framing are reported, never bypassed. +- Captures real Playwright/Chromium screenshots with device emulation. `screenshot` defaults to a fresh render (labelled `fresh-render`); `mode: "controlled"` captures the live isolated Chromium page (`controlled-current`). PNGs are saved under the session `files/artifact-explorer-screenshots` folder. +- Offers an isolated controlled Chromium (in-memory profile, no login state) with real back/forward/refresh that survives device changes, for sites that block embedding. +- Includes copy buttons after titles and a `#number` link to the canonical GitHub issue or pull request, with an adjacent copy-link button. +- Ships a built-in Docs tab and a sharing skill (`pr-artifact-explorer`) for share links, artifact IDs, and routes. + +## Screenshot setup + +Screenshots need Playwright and a Chromium browser. Nothing is installed silently: + +```text +npm install playwright-core # then: npx playwright install chromium +``` + +Optional environment variables: `PR_ARTIFACT_EXPLORER_PLAYWRIGHT` (path to a `playwright`/`playwright-core` package) and `PR_ARTIFACT_EXPLORER_CHROMIUM` (path to a Chromium/Chrome/Edge executable). Without a runtime the action returns explicit setup instructions. A fresh isolated profile is used for every capture; no login profile is persisted. ## Security and local data @@ -55,7 +73,15 @@ Reload extensions, then ask Copilot to open the `pr-artifact-explorer` canvas. Y ## Agent actions - `open_pull_request { repository, pullNumber }` - navigate an open canvas to a pull request. -- `inspect_artifact { repository, artifactId }` - download, index, detect, and open an Actions artifact. +- `open_target { input }` - open any share link, URL, issue/PR, artifact, or route. +- `inspect_artifact { repository, artifactId }` - start a recoverable job to download, index, detect, and open an Actions artifact. +- `job_status`, `retry_job`, `cancel_job`, `set_limit { kind, value }` - manage jobs and soft limits. +- `restart_canvas` - restart the instance (same port and token) without stopping jobs. +- `navigate_browser`, `set_viewport`, `set_zoom`, `screenshot` - drive the browser preview. +- `controlled_navigate`, `controlled_back`, `controlled_forward`, `controlled_refresh`, `controlled_close` - drive the isolated controlled Chromium. +- `share { input, preset?, zoom?, width?, height? }` - build shareable links that round-trip through `open_target`. +- `share { input }` - produce a shareable link, route, and text for a target. +- `docs { section? }` - open the built-in Docs tab. - `cache_status` - report local artifact cache usage. - `clear_cache { artifactId? }` - remove one cached artifact or clear the entire cache. - `accounts` - list sanitized GitHub account metadata available to the extension. diff --git a/extensions/pr-artifact-explorer/assets/app.css b/extensions/pr-artifact-explorer/assets/app.css index d25e82b41a..0dd1cc1b5b 100644 --- a/extensions/pr-artifact-explorer/assets/app.css +++ b/extensions/pr-artifact-explorer/assets/app.css @@ -512,6 +512,8 @@ select { display: inline-flex; gap: var(--base-size-6, 6px); align-items: center; + flex: none; + white-space: nowrap; font-weight: 400; } @@ -3812,3 +3814,308 @@ select { border-top: 0; } } + +.icon-copy { --octicon-url: url("/assets/octicons/copy.svg"); } +.icon-arrow-left { --octicon-url: url("/assets/octicons/arrow-left.svg"); } +.icon-arrow-right { --octicon-url: url("/assets/octicons/arrow-right.svg"); } +.icon-book { --octicon-url: url("/assets/octicons/book.svg"); } +.icon-plus { --octicon-url: url("/assets/octicons/plus.svg"); } +.icon-dash { --octicon-url: url("/assets/octicons/dash.svg"); } +.icon-device-camera { --octicon-url: url("/assets/octicons/device-camera.svg"); } +.icon-keyboard { --octicon-url: url("/assets/octicons/keyboard.svg"); } +.icon-device-mobile { --octicon-url: url("/assets/octicons/device-mobile.svg"); } + +.header-button { background: none; border: 0; cursor: pointer; font: inherit; } +.connection-state { color: var(--app-attention); font-size: 12px; margin-left: 8px; } + +.title-copy, +.icon-button { + align-items: center; + background: transparent; + border: 1px solid transparent; + border-radius: var(--app-radius); + color: var(--app-fg-muted); + cursor: pointer; + display: inline-flex; + font: inherit; + justify-content: center; + min-height: 28px; + min-width: 28px; + padding: 0 6px; + vertical-align: middle; +} +.title-copy:hover, +.icon-button:hover:not(:disabled) { background: var(--app-bg-muted); border-color: var(--app-border); color: var(--app-fg); } +.title-copy:focus-visible, +.icon-button:focus-visible { outline: 2px solid var(--app-accent); outline-offset: 1px; } +.icon-button:disabled { cursor: not-allowed; opacity: 0.45; } +.icon-button[aria-pressed="true"] { background: var(--app-bg-muted); border-color: var(--app-border); } +.page-title-number-link { color: var(--app-fg-muted); font-weight: 300; text-decoration: none; } +.page-title-number-link:hover { color: var(--app-accent); text-decoration: underline; } + +.job-panel { + background: var(--app-bg); + border: 1px solid var(--app-border); + border-radius: var(--app-radius); + bottom: 16px; + box-shadow: 0 8px 24px rgba(31, 35, 40, 0.18); + display: grid; + gap: 10px; + max-height: 60vh; + max-width: 420px; + overflow: auto; + padding: 12px; + position: fixed; + right: 16px; + z-index: 40; +} +.job-panel[hidden] { display: none; } +.job-card { border-top: 1px solid var(--app-border-muted); display: grid; gap: 6px; padding-top: 8px; } +.job-card:first-child { border-top: 0; padding-top: 0; } +.job-card progress { width: 100%; } +.job-card .job-meta { color: var(--app-fg-muted); font-size: 12px; } +.job-card .job-error { color: var(--app-danger); font-size: 12px; } +.job-prompt { background: var(--app-bg-muted); border-left: 3px solid var(--app-attention); display: grid; gap: 6px; padding: 8px; } +.job-prompt form { align-items: center; display: flex; flex-wrap: wrap; gap: 6px; } +.job-prompt input { max-width: 140px; } +.job-actions { display: flex; gap: 6px; } + +.browser-view { display: grid; min-width: 0; gap: 0; } +.app-shell:has(.browser-view) .breadcrumb-bar { display: none; } +.app-main:has(.browser-view) { max-width: none; padding: 0; margin: 0; } +.browser-toolbar { + display: grid; + gap: 4px; + padding: 8px 12px; + background: var(--app-bg); + border-bottom: 1px solid var(--app-border-muted); +} +.browser-navigation, +.browser-view-controls, +.browser-zoom-controls { display: flex; align-items: center; gap: 4px; min-width: 0; } +.browser-navigation form { display: flex; flex: 1; min-width: 0; } +.browser-toolbar .browser-address { width: 100%; min-width: 0; height: 32px; font-size: 13px; } +.browser-toolbar .icon-button { width: 32px; min-width: 32px; height: 32px; padding: 0; } +.browser-toolbar .icon-button:hover:not(:disabled) { border-color: transparent; } +.browser-view-controls .form-select { width: 112px; min-width: 92px; height: 32px; font-size: 12px; } +.browser-zoom-controls { gap: 0; } +.browser-zoom-label { font-size: 12px; font-variant-numeric: tabular-nums; width: 42px; text-align: center; } +.browser-controls-spacer { flex: 1; } +.browser-status { color: var(--app-fg-muted); font-size: 12px; } +.browser-notice { background: var(--app-bg-muted); border-bottom: 1px solid var(--app-border); padding: 8px 12px; font-size: 13px; } +.browser-notice[hidden] { display: none; } +.browser-stage { + background: var(--app-bg-inset); + min-height: max(320px, calc(100dvh - 133px)); + min-width: 0; + max-width: 100%; + overflow: auto; + padding: 16px; +} +.browser-scale-box { margin: 0 auto; position: relative; } +.browser-scale-box[hidden], +.browser-empty[hidden] { display: none; } +.browser-empty { max-width: 360px; margin: 80px auto; text-align: center; font-size: 13px; } +.browser-empty strong { font-size: 16px; font-weight: 600; } +.browser-empty p { color: var(--app-fg-muted); margin: 8px 0 0; } +.browser-frame { + background: #fff; + border: 0; + box-sizing: content-box; + outline: 1px solid var(--app-border); + display: block; + transform-origin: 0 0; +} +.browser-shots { display: grid; gap: 8px; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); } +.browser-shots:not(:empty) { padding: 12px; } +.browser-shots figure { border: 1px solid var(--app-border); border-radius: var(--app-radius); margin: 0; padding: 6px; } +.browser-shots img { max-width: 100%; } +.browser-shots figcaption { color: var(--app-fg-muted); font-size: 12px; } + +.kbd-hints { background: var(--app-bg); border: 1px solid var(--app-border); border-radius: var(--app-radius); padding: 12px; } +.kbd-hints[hidden] { display: none; } +.kbd-hints dl { display: grid; gap: 4px 16px; grid-template-columns: auto 1fr; margin: 0; } +.kbd-hints dt { white-space: nowrap; } +kbd { background: var(--app-bg-muted); border: 1px solid var(--app-border); border-radius: 4px; font-family: var(--app-mono); font-size: 11px; padding: 1px 5px; } + +.docs-view { display: grid; gap: 40px; grid-template-columns: 200px minmax(0, 1fr); max-width: 1040px; margin: 8px auto 40px; } +.docs-nav { display: grid; align-content: start; align-self: start; gap: 12px; position: sticky; top: 106px; font-size: 13px; } +.docs-search { width: 100%; min-width: 0; font-size: 13px; } +.docs-link-list { display: grid; gap: 2px; } +.docs-link-list a { border-radius: var(--app-radius); color: var(--app-fg-muted); padding: 8px; text-decoration: none; line-height: 1.4; } +.docs-link-list a:hover { background: var(--app-bg-muted); color: var(--app-fg); } +.docs-link-list a[aria-current="page"] { background: var(--app-bg-muted); color: var(--app-fg); font-weight: 600; } +.docs-link-list a:focus-visible { outline: 2px solid var(--focus-outlineColor, var(--app-accent)); outline-offset: -2px; } +.docs-link-list a[hidden] { display: none; } +.docs-topic-select { display: none; } +.docs-search-status { color: var(--app-fg-muted); font-size: 12px; margin: 0; } +.docs-body { min-width: 0; max-width: 72ch; font-size: 16px; line-height: 1.65; overflow-wrap: anywhere; } +.docs-body h1 { margin: 0 0 20px; font-size: 26px; line-height: 1.25; font-weight: 600; } +.docs-body h2 { margin: 32px 0 12px; font-size: 20px; line-height: 1.35; font-weight: 600; } +.docs-body p { margin: 0 0 16px; } +.docs-body .docs-lead { margin-bottom: 24px; color: var(--app-fg-muted); font-size: 16px; } +.docs-body a { text-underline-offset: 3px; } +.docs-body code { padding: 2px 4px; background: var(--app-bg-muted); border-radius: 4px; font-family: var(--app-mono); font-size: 0.85em; } +.docs-steps { padding-left: 24px; margin: 0 0 24px; } +.docs-steps li { padding-left: 4px; margin-bottom: 16px; } +.docs-steps li::marker { color: var(--app-fg-muted); font-weight: 600; font-variant-numeric: tabular-nums; } +.docs-figure { margin: 28px 0; } +.docs-figure img { display: block; width: 100%; height: auto; border: 1px solid var(--app-border); border-radius: var(--app-radius); } +.docs-figure figcaption { margin-top: 8px; color: var(--app-fg-muted); font-size: 12px; line-height: 1.5; } +.docs-figure:has(img[src$="continuing-job.png"]) { max-width: 420px; } +.docs-next { display: flex; flex-wrap: wrap; gap: 8px 20px; padding-top: 20px; margin-top: 32px; border-top: 1px solid var(--app-border-muted); font-size: 14px; } +.docs-next strong { flex-basis: 100%; font-weight: 600; } +.docs-body .kbd-list { display: grid; grid-template-columns: minmax(120px, 0.8fr) minmax(0, 1.2fr); margin: 24px 0; font-size: 14px; } +.docs-body .kbd-list dt, +.docs-body .kbd-list dd { margin: 0; padding: 12px 0; border-bottom: 1px solid var(--app-border-muted); } +.docs-body kbd { font-size: 12px; } +.docs-details { margin-top: 28px; padding: 16px 0; border-block: 1px solid var(--app-border-muted); font-size: 14px; } +.docs-details summary { font-weight: 600; cursor: pointer; } +.docs-details[open] summary { margin-bottom: 16px; } +.docs-details p:last-child { margin-bottom: 0; } +.docs-details summary:focus-visible { outline: 2px solid var(--focus-outlineColor, var(--app-accent)); outline-offset: 4px; } +@media (max-width: 720px) { + .docs-view { grid-template-columns: minmax(0, 1fr); gap: 24px; margin-top: 0; } + .docs-nav { position: static; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; } + .docs-topic-select { display: block; width: 100%; min-width: 0; font-size: 13px; } + .docs-link-list { display: none; } + .docs-nav[data-searching="true"] .docs-link-list { display: grid; grid-column: 1 / -1; } + .docs-search-status { grid-column: 1 / -1; } + .docs-body h1 { font-size: 26px; } + .docs-body .kbd-list { grid-template-columns: minmax(112px, 0.85fr) minmax(0, 1.15fr); } +} + +.browser-frame[hidden] { display: none; } +.preview-open-browser { margin-bottom: 8px; } + +.app-header { + display: flex; + min-height: 48px; + padding: 8px 12px; + gap: 12px; +} +.app-brand { flex: none; font-size: 13px; } +.repository-form { flex: 1; max-width: 420px; margin: 0 auto 0 0; } +.repository-combobox { min-width: 0; } +.repository-input-shell { height: 32px; } +.repository-input { height: 30px; font-size: 13px; } +.repository-menu-button { height: 30px; } +.app-navigation { gap: 4px; } +.app-header .header-cache-item { + display: flex; + flex: none; + min-width: 0; + gap: 8px; + margin-right: 0; +} +.app-navigation .cache-link { min-height: 32px; padding: 0 8px; border-radius: var(--app-radius); font-size: 12px; } +.app-navigation .cache-link:hover, +.app-navigation .cache-link[aria-current="page"], +.app-navigation .cache-link[aria-expanded="true"] { background: var(--headerSearch-borderColor, rgb(255 255 255 / 12%)); text-decoration: none; } +.app-navigation .cache-link:focus-visible { outline: 2px solid var(--focus-outlineColor, var(--app-accent)); outline-offset: 2px; } +.account-chip { width: 32px; height: 32px; } +.account-avatar, +.account-avatar-fallback { width: 28px; height: 28px; } +.breadcrumb-bar { top: 48px; } + +.explorer-popover { + position: fixed; + inset: auto; + width: min(320px, calc(100vw - 16px)); + margin: 0; + padding: 8px; + overflow-y: auto; + color: var(--app-fg); + background: var(--app-bg); + border: 1px solid var(--app-border); + border-radius: var(--app-radius); + box-shadow: var(--shadow-floating-small, 0 4px 12px rgb(31 35 40 / 15%)); + font: 13px/1.5 var(--app-font); +} +.app-options { width: min(240px, calc(100vw - 16px)); } +.popover-section { padding: 8px; } +.popover-section + .popover-section { border-top: 1px solid var(--app-border-muted); } +.popover-section h2 { margin: 0 0 4px; font-size: 13px; font-weight: 600; } +.popover-action { + display: flex; + width: 100%; + min-height: 36px; + align-items: center; + gap: 8px; + padding: 6px 8px; + color: var(--app-fg); + background: transparent; + border: 0; + border-radius: var(--app-radius); + font: inherit; + text-align: left; + text-decoration: none; + cursor: pointer; +} +.popover-action:hover:not(:disabled) { background: var(--app-bg-muted); color: var(--app-fg); text-decoration: none; } +.popover-action:focus-visible { outline: 2px solid var(--focus-outlineColor, var(--app-accent)); outline-offset: -2px; } +.popover-action:disabled { opacity: 0.65; cursor: not-allowed; } +.popover-action[aria-pressed="true"] { background: var(--app-bg-muted); } +.popover-description { margin: 4px 8px 8px; color: var(--app-fg-muted); font-size: 12px; } +.popover-button-row { display: flex; gap: 6px; margin-top: 8px; } +.popover-button-row [aria-pressed="true"] { border-color: var(--app-accent); } +.toggle-state { margin-left: auto; color: var(--app-fg-muted); font-size: 12px; } +.toggle-state::after { content: "Off"; } +[aria-pressed="true"] > .toggle-state::after { content: "On"; color: var(--app-success); } +.browser-dimensions { display: flex; align-items: end; gap: 8px; margin-top: 8px; } +.browser-dimensions[hidden] { display: none; } +.browser-dimensions label { min-width: 0; flex: 1; font-size: 12px; color: var(--app-fg-muted); } +.browser-dimensions input { width: 100%; min-width: 0; margin-top: 4px; font-size: 13px; } +.browser-dimensions > span { line-height: 32px; color: var(--app-fg-muted); } +.browser-options .kbd-hints { padding: 8px 0 0; border: 0; font-size: 12px; } +.browser-options .kbd-hints dl { gap: 8px; } +.browser-options .kbd-hints dd { margin: 0; } +.browser-options .kbd-hints dt { white-space: normal; } +.manual-copy-dialog { + width: min(480px, calc(100vw - 32px)); + padding: 16px; + color: var(--app-fg); + background: var(--app-bg); + border: 1px solid var(--app-border); + border-radius: var(--app-radius); + font: 13px/1.5 var(--app-font); +} +.manual-copy-dialog::backdrop { background: var(--overlay-backdrop-bgColor, rgb(31 35 40 / 40%)); } +.manual-copy-dialog h2 { margin: 0; font-size: 16px; font-weight: 600; } +.manual-copy-dialog p { margin: 8px 0; color: var(--app-fg-muted); } +.manual-copy-dialog textarea { + width: 100%; + min-height: 96px; + padding: 8px; + color: var(--app-fg); + background: var(--app-bg); + border: 1px solid var(--app-border); + border-radius: var(--app-radius); + font: 12px/1.5 var(--app-mono); + resize: vertical; +} +.manual-copy-dialog textarea:focus-visible { outline: 2px solid var(--focus-outlineColor, var(--app-accent)); outline-offset: 1px; } +.manual-copy-dialog form { display: flex; justify-content: end; margin-top: 12px; } + +@media (max-width: 767px) { + .app-header { gap: 4px 8px; } + .app-navigation { margin-left: auto; } + .account-control { margin-left: 0; } + .repository-form { flex: 1 1 100%; order: 3; max-width: none; } + .breadcrumb-bar { top: auto; } + .browser-stage { min-height: max(320px, calc(100dvh - 169px)); } +} +@media (max-width: 480px) { + .app-header { padding: 6px 8px; } + .app-navigation .cache-link { padding: 0 6px; } + .app-navigation > a > .octicon { display: none; } + .browser-toolbar { padding: 6px 8px; } + .browser-controls-spacer { display: none; } + .browser-view-controls .form-select { flex: 1; width: 92px; } + .browser-view-controls .app-button { padding: 0 8px; } + .browser-stage { min-height: max(320px, calc(100dvh - 161px)); } +} +@media (max-width: 380px) { + .browser-shot-label { display: none; } + #br-shot { min-width: 32px; padding: 0; } +} \ No newline at end of file diff --git a/extensions/pr-artifact-explorer/assets/app.js b/extensions/pr-artifact-explorer/assets/app.js index bbc2969c79..589a0d886c 100644 --- a/extensions/pr-artifact-explorer/assets/app.js +++ b/extensions/pr-artifact-explorer/assets/app.js @@ -674,6 +674,19 @@ function parseRoute() { filePath: query.get("file"), }; } + if (parts[0] === "issue" && parts[1] && parts[2]) { + return { + name: "issue", + repository: decodeURIComponent(parts[1]), + issueNumber: Number(parts[2]), + }; + } + if (parts[0] === "docs") { + return { name: "docs", section: parts[1] ? decodeURIComponent(parts[1]) : null }; + } + if (parts[0] === "browser") { + return { name: "browser", query }; + } if (parts[0] === "cache") return { name: "cache" }; if (parts[0] === "accounts") return { name: "accounts" }; return { @@ -3151,10 +3164,7 @@ function renderPullPayload(repository, pullNumber, payload, cache) { view.innerHTML = ` -
- + +
+ Cache + +

Running jobs continue during a restart.

+ ${mismatch} diff --git a/extensions/pr-artifact-explorer/media-junit.test.mjs b/extensions/pr-artifact-explorer/media-junit.test.mjs index 994c9dfa51..8122b362c8 100644 --- a/extensions/pr-artifact-explorer/media-junit.test.mjs +++ b/extensions/pr-artifact-explorer/media-junit.test.mjs @@ -200,4 +200,19 @@ test("JUnit viewer detects, aggregates, escapes and falls back in real Chromium" return [...out.querySelectorAll("[data-trx-result]")].filter((r) => !r.hidden).length; }, FIXTURES.nested); assert.ok(filtered >= 1); + + const timing = await page.evaluate((cases) => cases.map(([xml]) => { + const out = document.getElementById("out"); + JunitPreview.render(xml, out, { highlightXml: (s) => s }); + return [out.querySelector("[data-junit-elapsed]")?.textContent, out.querySelector("[data-junit-test-time]")?.textContent]; + }), [ + [''], + [''], + [''], + [''], + ]); + assert.deepEqual(timing[0], ["Not recorded", "Not recorded"]); + assert.deepEqual(timing[1], ["0 s", "0 s"]); + assert.deepEqual(timing[2], ["Not recorded", "Not recorded"]); + assert.deepEqual(timing[3], ["1.5 s", "1 s"]); }); From 16c78af71dd1462258ca21ff67202751df62209a Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:17:57 -0500 Subject: [PATCH 05/29] Fix JUnit search to match contiguous phrases Per-term substring matching made 'Browser A' match every row via the 'a' term. Match the whole normalized phrase and cover it in the Chromium regression. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 72ee5a2d-bcbb-4e0f-b86f-6ad56d4fc033 --- .../pr-artifact-explorer/assets/junit-preview.js | 7 +++---- .../pr-artifact-explorer/media-junit.test.mjs | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/extensions/pr-artifact-explorer/assets/junit-preview.js b/extensions/pr-artifact-explorer/assets/junit-preview.js index fc81bd153e..54f6a22ba7 100644 --- a/extensions/pr-artifact-explorer/assets/junit-preview.js +++ b/extensions/pr-artifact-explorer/assets/junit-preview.js @@ -347,17 +347,16 @@ function applyFilters(preview) { if (!preview) return; - const terms = String(preview.querySelector("[data-trx-search-input]")?.value ?? "") + const phrase = String(preview.querySelector("[data-trx-search-input]")?.value ?? "") .trim() .toLocaleLowerCase() - .split(/\s+/) - .filter(Boolean); + .replace(/\s+/g, " "); const category = preview.querySelector('[data-trx-filter][aria-pressed="true"]')?.dataset.trxFilter ?? "all"; const results = [...preview.querySelectorAll("[data-trx-result]")]; let visible = 0; for (const result of results) { const matches = (category === "all" || result.dataset.trxCategory === category) && - terms.every((term) => (result.dataset.trxSearch ?? "").includes(term)); + (result.dataset.trxSearch ?? "").replace(/\s+/g, " ").includes(phrase); result.hidden = !matches; if (matches) visible += 1; } diff --git a/extensions/pr-artifact-explorer/media-junit.test.mjs b/extensions/pr-artifact-explorer/media-junit.test.mjs index 8122b362c8..85da5ece8f 100644 --- a/extensions/pr-artifact-explorer/media-junit.test.mjs +++ b/extensions/pr-artifact-explorer/media-junit.test.mjs @@ -215,4 +215,20 @@ test("JUnit viewer detects, aggregates, escapes and falls back in real Chromium" assert.deepEqual(timing[1], ["0 s", "0 s"]); assert.deepEqual(timing[2], ["Not recorded", "Not recorded"]); assert.deepEqual(timing[3], ["1.5 s", "1 s"]); + + const search = await page.evaluate((xml) => { + const out = document.getElementById("out"); + JunitPreview.render(xml, out, { highlightXml: (s) => s }); + const preview = out.querySelector(".trx-preview"); + const input = preview.querySelector("[data-trx-search-input]"); + const visible = () => [...preview.querySelectorAll("[data-trx-result]")].filter((r) => !r.hidden).length; + input.value = "Browser A"; + JunitPreview.applyFilters(preview); + const a = [visible(), preview.querySelector("[data-trx-result-count]").textContent]; + input.value = "browser b"; + JunitPreview.applyFilters(preview); + return [a, visible()]; + }, ``); + assert.deepEqual(search[0], [2, "2 of 4 results"]); + assert.equal(search[1], 2); }); From f6dcaeea3272a0612525687f396fd357ad14b822 Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:22:29 -0500 Subject: [PATCH 06/29] Label all-skipped JUnit runs as Skipped Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 72ee5a2d-bcbb-4e0f-b86f-6ad56d4fc033 --- .../pr-artifact-explorer/assets/junit-preview.js | 5 +++-- .../pr-artifact-explorer/media-junit.test.mjs | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/extensions/pr-artifact-explorer/assets/junit-preview.js b/extensions/pr-artifact-explorer/assets/junit-preview.js index 54f6a22ba7..4fe9c873b3 100644 --- a/extensions/pr-artifact-explorer/assets/junit-preview.js +++ b/extensions/pr-artifact-explorer/assets/junit-preview.js @@ -269,8 +269,9 @@ .slice(0, MAX_RENDERED_RESULTS) .map(({ result }) => result); const bad = report.counts.error + report.counts.failed; - const outcome = bad > 0 ? "failed" : report.total === 0 ? "skipped" : "passed"; - const outcomeLabel = bad > 0 ? "Failed" : report.total === 0 ? "No tests" : "Passed"; + const allSkipped = report.total > 0 && report.counts.skipped === report.total; + const outcome = bad > 0 ? "failed" : report.total === 0 || allSkipped ? "skipped" : "passed"; + const outcomeLabel = bad > 0 ? "Failed" : report.total === 0 ? "No tests" : allSkipped ? "Skipped" : "Passed"; const mismatch = report.declaredTotal !== null && report.declaredTotal !== report.total ? `
${icon("eye")} The report declares ${report.declaredTotal.toLocaleString()} tests but contains ${report.total.toLocaleString()} test cases. Totals below are counted from the test cases.
` : ""; diff --git a/extensions/pr-artifact-explorer/media-junit.test.mjs b/extensions/pr-artifact-explorer/media-junit.test.mjs index 85da5ece8f..8ce4ca166d 100644 --- a/extensions/pr-artifact-explorer/media-junit.test.mjs +++ b/extensions/pr-artifact-explorer/media-junit.test.mjs @@ -231,4 +231,18 @@ test("JUnit viewer detects, aggregates, escapes and falls back in real Chromium" }, ``); assert.deepEqual(search[0], [2, "2 of 4 results"]); assert.equal(search[1], 2); + + const outcomes = await page.evaluate((reports) => reports.map((xml) => { + const out = document.getElementById("out"); + JunitPreview.render(xml, out, { highlightXml: (s) => s }); + const el = out.querySelector(".trx-run-outcome"); + return [el.textContent.trim(), el.className.includes("trx-outcome-skipped")]; + }), [ + '', + '', + '', + ]); + assert.deepEqual(outcomes[0], ["Skipped", true]); + assert.deepEqual(outcomes[1], ["Passed", false]); + assert.deepEqual(outcomes[2], ["No tests", true]); }); From 8f9a0c3851296436f33be7482a20a7335eda2522 Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:30:46 -0500 Subject: [PATCH 07/29] feat(artifact-explorer): universal Raw toggle, inherited browser theme, open in default browser Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 72ee5a2d-bcbb-4e0f-b86f-6ad56d4fc033 --- .../pr-artifact-explorer/assets/app.css | 35 ++++ extensions/pr-artifact-explorer/assets/app.js | 96 ++++++++++- .../pr-artifact-explorer/assets/browser.js | 44 ++++- extensions/pr-artifact-explorer/capture.mjs | 21 ++- .../pr-artifact-explorer/external-open.mjs | 72 ++++++++ extensions/pr-artifact-explorer/preview.mjs | 24 ++- .../pr-artifact-explorer/raw-open.test.mjs | 160 ++++++++++++++++++ .../pr-artifact-explorer/raw-wiring.test.mjs | 37 ++++ extensions/pr-artifact-explorer/raw.mjs | 113 +++++++++++++ extensions/pr-artifact-explorer/server.mjs | 66 +++++++- skills/pr-artifact-explorer/SKILL.md | 3 + 11 files changed, 661 insertions(+), 10 deletions(-) create mode 100644 extensions/pr-artifact-explorer/external-open.mjs create mode 100644 extensions/pr-artifact-explorer/raw-open.test.mjs create mode 100644 extensions/pr-artifact-explorer/raw-wiring.test.mjs create mode 100644 extensions/pr-artifact-explorer/raw.mjs diff --git a/extensions/pr-artifact-explorer/assets/app.css b/extensions/pr-artifact-explorer/assets/app.css index 11bc0447e3..1523b91e69 100644 --- a/extensions/pr-artifact-explorer/assets/app.css +++ b/extensions/pr-artifact-explorer/assets/app.css @@ -2432,6 +2432,41 @@ select { align-items: center; } +.preview-mode { + display: inline-flex; + gap: 2px; +} + +.preview-mode [aria-pressed="true"] { + background: var(--app-bg-inset); + font-weight: 600; +} + +.raw-view { + display: flex; + min-height: 0; + padding: var(--base-size-12, 12px); + flex-direction: column; + gap: var(--base-size-8, 8px); +} + +.raw-note { + color: var(--app-fg-muted); + font-size: 12px; +} + +.raw-content { + margin: 0; + padding: var(--base-size-12, 12px); + overflow: auto; + background: var(--app-bg); + border: 1px solid var(--app-border); + border-radius: 6px; + font-family: var(--app-mono); + font-size: 12px; + white-space: pre; +} + .preview-path { min-width: 0; overflow: hidden; diff --git a/extensions/pr-artifact-explorer/assets/app.js b/extensions/pr-artifact-explorer/assets/app.js index 0bee4222bb..c3c8ec65b2 100644 --- a/extensions/pr-artifact-explorer/assets/app.js +++ b/extensions/pr-artifact-explorer/assets/app.js @@ -636,8 +636,11 @@ function pullHash(repository, pullNumber) { return `#/pull/${encodeURIComponent(repository)}/${Number(pullNumber)}`; } -function artifactHash(repository, pullNumber, artifactId, filePath = null) { - const query = filePath ? `?file=${encodeURIComponent(filePath)}` : ""; +function artifactHash(repository, pullNumber, artifactId, filePath = null, raw = false) { + const params = []; + if (filePath) params.push(`file=${encodeURIComponent(filePath)}`); + if (filePath && raw) params.push("view=raw"); + const query = params.length ? `?${params.join("&")}` : ""; return `#/artifact/${encodeURIComponent(repository)}/${Number(pullNumber)}/${Number(artifactId)}${query}`; } @@ -672,6 +675,7 @@ function parseRoute() { pullNumber: Number(parts[2]), artifactId: Number(parts[3]), filePath: query.get("file"), + raw: query.get("view") === "raw", }; } if (parts[0] === "issue" && parts[1] && parts[2]) { @@ -3922,6 +3926,77 @@ function stepPlayerTextSize(direction) { } } +let rawRequestToken = 0; + +function syncPreviewModeControl() { + const raw = currentArtifact?.rawMode === true; + for (const button of document.querySelectorAll("[data-preview-mode]")) { + const active = (button.dataset.previewMode === "raw") === raw; + button.setAttribute("aria-pressed", String(active)); + button.classList.toggle("is-active", active); + } +} + +async function setPreviewMode(mode) { + const artifact = currentArtifact; + if (!artifact?.selectedPath) return; + const raw = mode === "raw"; + if ((artifact.rawMode === true) === raw) return; + artifact.rawMode = raw; + history.replaceState( + null, + "", + artifactHash(artifact.repository, artifact.pullNumber, artifact.id, artifact.selectedPath, raw), + ); + const entry = artifact.analysis.entries.find((item) => item.path === artifact.selectedPath); + if (entry) await previewArtifactFile(entry, renderSequence); +} + +async function renderRawPreview(entry, preview, sequence) { + const artifact = currentArtifact; + const token = ++rawRequestToken; + const stale = () => + token !== rawRequestToken || + sequence !== renderSequence || + currentArtifact !== artifact || + artifact.selectedPath !== entry.path || + artifact.rawMode !== true; + preview.innerHTML = `
`; + let result; + try { + result = await api(`/api/artifacts/${artifact.id}/raw?path=${encodeURIComponent(entry.path)}`); + } catch (error) { + if (stale()) return; + const box = document.createElement("div"); + box.className = "preview-error"; + box.setAttribute("role", "alert"); + box.textContent = `Could not read the raw bytes: ${error instanceof Error ? error.message : String(error)}. Use Download to get the original file.`; + preview.replaceChildren(box); + return; + } + if (stale()) return; + const wrap = document.createElement("div"); + wrap.className = "raw-view"; + wrap.dataset.rawMode = result.mode; + const note = document.createElement("p"); + note.className = "raw-note"; + if (result.mode === "unsupported") { + note.textContent = result.reason || "These bytes cannot be inspected safely. Download the original artifact ZIP instead."; + wrap.append(note); + } else { + const parts = [formatBytes(result.size)]; + if (result.encoding) parts.push(result.encoding); + if (result.truncated) parts.push(`partial: showing the first ${formatBytes(result.shown)}; Download has the full file`); + note.textContent = [parts.join(" · "), result.note].filter(Boolean).join(" — "); + const pre = document.createElement("pre"); + pre.className = "raw-content"; + pre.tabIndex = 0; + pre.textContent = result.mode === "empty" ? "(empty file)" : result.content; + wrap.append(note, pre); + } + preview.replaceChildren(wrap); +} + async function previewArtifactFile(entry, sequence) { disposePlayer(); const artifact = currentArtifact; @@ -3942,6 +4017,11 @@ async function previewArtifactFile(entry, sequence) { downloadLink.hidden = false; const browserAction = document.getElementById("preview-open-browser"); if (browserAction) browserAction.hidden = true; + syncPreviewModeControl(entry); + if (artifact.rawMode) { + await renderRawPreview(entry, preview, sequence); + return; + } preview.innerHTML = `
@@ -4950,6 +5036,12 @@ view.addEventListener("input", (event) => { }); view.addEventListener("click", (event) => { + const modeButton = event.target.closest("[data-preview-mode]"); + if (modeButton) { + event.preventDefault(); + void setPreviewMode(modeButton.dataset.previewMode); + return; + } const trxView = event.target.closest("[data-trx-view]"); if (trxView) { event.preventDefault(); diff --git a/extensions/pr-artifact-explorer/assets/browser.js b/extensions/pr-artifact-explorer/assets/browser.js index e3b332066e..c5cf635979 100644 --- a/extensions/pr-artifact-explorer/assets/browser.js +++ b/extensions/pr-artifact-explorer/assets/browser.js @@ -449,7 +449,7 @@ const DOCS_SECTIONS = [
  1. Choose a repository. Enter github/awesome-copilot in the header, or use the repository quick switch. Pins and favorites make frequently used repositories easier to return to.
  2. Find the change. Select a pull request from the list. To go straight to one, paste its GitHub URL or enter open PR 123 in the address bar (g then b) using the current repository context.
  3. -
  4. Inspect an artifact. Choose an available workflow artifact, then browse the file list. Select a supported file to read, watch, or preview it.
  5. +
  6. Inspect an artifact. Choose an available workflow artifact, then browse the file list. Select a supported file to read, watch, or preview it. Use Raw beside Download to see the original source or a bounded hex view; Preview returns to the rendered view.

Jump straight to an artifact

Paste a GitHub artifact URL or an Explorer share route into the address field. Explorer resolves the target; you do not need to reconstruct its local preview address.

@@ -468,6 +468,10 @@ const DOCS_SECTIONS = [

Device size changes the layout. Mobile uses 390 × 844 CSS pixels, Tablet 768 × 1024, and Desktop 1440 × 900. Native follows the panel. Choose Custom to reveal width and height in Options; Rotate switches orientation.

Zoom changes only what you see. The − and + buttons scale the preview without changing its CSS viewport. In Options, Fit makes it fit the available width, and 100% resets the visual scale.

${docsFigure("preview-controls", "Mobile preview controls with visual zoom separate from the device preset.", "Sample mobile preview: the device sets the page layout; zoom adjusts its display size.", 882, 480)} +

Open it in your default browser

+

Choose Options → Open in default browser to open the current page in your operating system’s browser. For a website artifact, Explorer restarts its local preview with your current light or dark theme and keeps the same route, query and hash. That local server runs only while Artifact Explorer is open; it is not a published site.

+

Theme

+

Explorer’s own controls follow the canvas theme. Website artifacts it serves, and its fresh screenshots and controlled Chromium, follow the current light or dark setting and update when it changes. External websites keep their own theme rules.

If the page will not embed

Some sites deliberately refuse to appear in an iframe. Explorer reports the restriction rather than bypassing it. Open Options → Controlled browser to use an isolated Chromium view with its own real history.

`], @@ -705,6 +709,7 @@ function renderBrowser(route) {
+
@@ -781,7 +786,7 @@ function renderBrowser(route) { const controlledBody = () => { const { w, h } = viewport(); - return { preset: state.preset === "native" ? "custom" : state.preset, width: w, height: h }; + return { preset: state.preset === "native" ? "custom" : state.preset, width: w, height: h, theme: resolvedCanvasTheme() }; }; async function showControlledFrame() { @@ -868,6 +873,16 @@ function renderBrowser(route) { notice.hidden = false; notice.textContent = error instanceof Error ? error.message : String(error); } + let owned = false; + try { + owned = (await api("/api/browser/owned", { method: "POST", body: { url } })).owned === true; + } catch { + owned = false; + } + if (browserState !== state) return; + state.owned = owned; + if (owned) frame.setAttribute("data-theme-aware-preview", ""); + else frame.removeAttribute("data-theme-aware-preview"); frame.src = url; } @@ -910,7 +925,7 @@ function renderBrowser(route) { ? await api("/api/controlled/screenshot", { method: "POST", body: { fullPage: false } }) : await api("/api/capture", { method: "POST", - body: { url: state.url, preset: state.preset === "native" ? "custom" : state.preset, width: w, height: h, fullPage: false }, + body: { url: state.url, preset: state.preset === "native" ? "custom" : state.preset, width: w, height: h, fullPage: false, theme: resolvedCanvasTheme() }, }); const file = capture.path.split(/[\\/]/).pop(); const src = `/api/screenshots/${file}?token=${encodeURIComponent(capabilityToken)}`; @@ -969,7 +984,7 @@ function renderBrowser(route) { const view = { preset: state.preset, width: state.preset === "custom" ? w : undefined, height: state.preset === "custom" ? h : undefined, zoom: state.fit ? undefined : state.zoom, orientation: state.orientation === "landscape" ? "landscape" : undefined }; let sourceRoute = null; try { - sourceRoute = state.url && new URL(state.url, location.href).origin === location.origin ? sessionStorage.getItem("pae.shareRoute") : null; + sourceRoute = state.url && state.owned ? sessionStorage.getItem("pae.shareRoute") : null; } catch { sourceRoute = null; } @@ -977,6 +992,26 @@ function renderBrowser(route) { const route = sourceRoute ? `${sourceRoute}${sourceRoute.includes("?") ? "&" : "?"}v=${encodeViewClient(view)}` : browserHash({ ...view, url: state.url || undefined }); void copyText(route, sourceRoute ? "Copied artifact share route" : "Copied share route"); }); + $("br-open-external").addEventListener("click", async (event) => { + if (!state.url) return showToast("Open an address first.", "error"); + const button = event.currentTarget; + button.disabled = true; + try { + await api("/api/browser/open-external", { method: "POST", body: { url: state.url, theme: resolvedCanvasTheme() } }); + showToast("Opened in your default browser"); + } catch (error) { + showToast(error instanceof Error ? error.message : String(error), "error"); + } finally { + button.disabled = false; + } + }); + frame.addEventListener("load", () => { + if (frame.hasAttribute("data-theme-aware-preview")) syncPreviewTheme(); + }); + const themeObserver = new MutationObserver(() => { + if (state.controlled && browserState === state) void api("/api/controlled/theme", { method: "POST", body: { theme: resolvedCanvasTheme() } }).then(showControlledFrame).catch(() => {}); + }); + themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-color-mode", "data-theme-tone"] }); $("br-kbd").addEventListener("click", (event) => { const hints = $("br-hints"); hints.hidden = !hints.hidden; @@ -990,6 +1025,7 @@ function renderBrowser(route) { state.api = { go, refresh, setZoom, screenshot, load, applyLayout, viewport, setControlled }; browserCleanup = () => { window.removeEventListener("resize", onResize); + themeObserver.disconnect(); disposeOptions(); }; diff --git a/extensions/pr-artifact-explorer/capture.mjs b/extensions/pr-artifact-explorer/capture.mjs index b90be9d596..7f32d24671 100644 --- a/extensions/pr-artifact-explorer/capture.mjs +++ b/extensions/pr-artifact-explorer/capture.mjs @@ -139,9 +139,16 @@ async function savePng(png, outputDir) { * Renders the URL in a fresh, isolated headless Chromium and saves a PNG. * This is a NEW render, not a snapshot of the page currently shown in the canvas. */ -export async function captureScreenshot({ url, fullPage = false, outputDir = getScreenshotRoot(), ...viewportOptions }) { +export function normalizeColorScheme(value) { + if (value === undefined || value === null) return undefined; + if (value === "light" || value === "dark") return value; + throw new CaptureError("invalid_viewport", 'Color scheme must be "light" or "dark".'); +} + +export async function captureScreenshot({ url, fullPage = false, outputDir = getScreenshotRoot(), colorScheme, ...viewportOptions }) { const target = assertHttpUrl(url); const viewport = resolveViewport(viewportOptions); + const scheme = normalizeColorScheme(colorScheme); const browser = await launchChromium(); try { // A new context has no cookies or profile data and is discarded afterwards. @@ -150,6 +157,7 @@ export async function captureScreenshot({ url, fullPage = false, outputDir = get deviceScaleFactor: viewport.deviceScaleFactor, isMobile: viewport.isMobile, hasTouch: viewport.hasTouch, + ...(scheme ? { colorScheme: scheme } : {}), }); const page = await context.newPage(); let status = null; @@ -169,6 +177,7 @@ export async function captureScreenshot({ url, fullPage = false, outputDir = get return { ...(await savePng(png, outputDir)), source: "fresh-render", + colorScheme: scheme ?? null, note: "Fresh headless Chromium render of the URL. It does not include unsaved page state from the canvas preview.", requestedUrl: target.href, finalUrl, @@ -193,11 +202,20 @@ export class ControlledBrowser { #page = null; #viewport = null; #starting = null; + #colorScheme = undefined; get active() { return Boolean(this.#page); } + async setColorScheme(value) { + const scheme = normalizeColorScheme(value); + if (!scheme || scheme === this.#colorScheme) return { colorScheme: this.#colorScheme ?? null }; + this.#colorScheme = scheme; + if (this.#page) await this.#page.emulateMedia({ colorScheme: scheme }); + return { colorScheme: scheme }; + } + async #ensure(viewportOptions) { if (this.#page) return; this.#starting ??= (async () => { @@ -217,6 +235,7 @@ export class ControlledBrowser { deviceScaleFactor: v.deviceScaleFactor, isMobile: v.isMobile, hasTouch: v.hasTouch, + ...(this.#colorScheme ? { colorScheme: this.#colorScheme } : {}), }); this.#page = await this.#context.newPage(); } diff --git a/extensions/pr-artifact-explorer/external-open.mjs b/extensions/pr-artifact-explorer/external-open.mjs new file mode 100644 index 0000000000..183576479f --- /dev/null +++ b/extensions/pr-artifact-explorer/external-open.mjs @@ -0,0 +1,72 @@ +import { spawn } from "node:child_process"; + +export class ExternalOpenError extends Error { + constructor(message, code = "invalid_url") { + super(message); + this.name = "ExternalOpenError"; + this.code = code; + } +} + +const MAX_URL_LENGTH = 2048; + +/** Returns a normalized http(s) URL or throws. Rejects credentials, control characters and other schemes. */ +export function validateExternalUrl(value) { + const text = String(value ?? ""); + if (!text || text.length > MAX_URL_LENGTH) throw new ExternalOpenError("Enter an http(s) URL (max 2048 characters)."); + // eslint-disable-next-line no-control-regex + if (/[\u0000-\u0020\u007f-\u009f]/.test(text)) throw new ExternalOpenError("The URL contains spaces or control characters."); + let parsed; + try { + parsed = new URL(text); + } catch { + throw new ExternalOpenError("Enter a valid http(s) URL."); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new ExternalOpenError("Only http(s) URLs can be opened in the default browser."); + } + if (parsed.username || parsed.password) throw new ExternalOpenError("URLs with embedded credentials are not allowed."); + if (!parsed.hostname) throw new ExternalOpenError("The URL has no host."); + return parsed.href; +} + +export function launcherCommand(url, platform = process.platform) { + if (platform === "win32") return { command: "rundll32.exe", args: ["url.dll,FileProtocolHandler", url] }; + if (platform === "darwin") return { command: "open", args: [url] }; + return { command: "xdg-open", args: [url] }; +} + +function defaultOpener(url) { + const { command, args } = launcherCommand(url); + return new Promise((resolve, reject) => { + let child; + try { + child = spawn(command, args, { shell: false, windowsHide: true, detached: true, stdio: "ignore" }); + } catch (error) { + reject(error); + return; + } + child.once("error", reject); + child.once("spawn", () => { + child.unref(); + resolve(); + }); + }); +} + +let opener = defaultOpener; + +/** Test seam: replaces the OS launcher. Pass null to restore the default. */ +export function setExternalOpener(fn) { + opener = fn ?? defaultOpener; +} + +export async function openInDefaultBrowser(url) { + const safe = validateExternalUrl(url); + try { + await opener(safe); + } catch (error) { + throw new ExternalOpenError(`Could not launch the default browser: ${error.message}`, "launch_failed"); + } + return safe; +} diff --git a/extensions/pr-artifact-explorer/preview.mjs b/extensions/pr-artifact-explorer/preview.mjs index 052867028b..a90463df00 100644 --- a/extensions/pr-artifact-explorer/preview.mjs +++ b/extensions/pr-artifact-explorer/preview.mjs @@ -258,10 +258,32 @@ export async function startStaticPreview(artifactId, entryPath, options) { } serverEntry.host = `127.0.0.1:${address.port}`; const url = `http://127.0.0.1:${address.port}/`; - previewServers.set(key, { artifactId: String(artifactId), server, url, canvasOrigin: parentOrigin }); + previewServers.set(key, { + artifactId: String(artifactId), + entryPath: context.entry.name, + server, + url, + canvasOrigin: parentOrigin, + theme, + }); return url; } +/** Maps an origin back to the owned static preview serving it, or null for any foreign origin. */ +export function describeOwnedPreview(origin) { + for (const value of previewServers.values()) { + if (new URL(value.url).origin === origin) { + return { + artifactId: value.artifactId, + entryPath: value.entryPath, + theme: value.theme, + canvasOrigin: value.canvasOrigin, + }; + } + } + return null; +} + export async function stopStaticPreviewsForArtifact(artifactId) { const id = String(artifactId); const matching = [...previewServers.entries()].filter( diff --git a/extensions/pr-artifact-explorer/raw-open.test.mjs b/extensions/pr-artifact-explorer/raw-open.test.mjs new file mode 100644 index 0000000000..fe3403f3df --- /dev/null +++ b/extensions/pr-artifact-explorer/raw-open.test.mjs @@ -0,0 +1,160 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { crc32, deflateRawSync } from "node:zlib"; +import { ExternalOpenError, launcherCommand, setExternalOpener, validateExternalUrl } from "./external-open.mjs"; + +const u16 = (v) => Buffer.from(Uint16Array.of(v).buffer); +const u32 = (v) => Buffer.from(Uint32Array.of(v).buffer); + +function zipOf(name, raw, { deflate = false, flags = 0, method } = {}) { + const data = Buffer.from(deflate ? deflateRawSync(raw) : raw); + const nameBuf = Buffer.from(name); + const m = method ?? (deflate ? 8 : 0); + const crc = crc32(raw); + const local = Buffer.concat([ + u32(0x04034b50), u16(20), u16(flags), u16(m), u16(0), u16(0x21), + u32(crc), u32(data.length), u32(raw.length), u16(nameBuf.length), u16(0), nameBuf, data, + ]); + const central = Buffer.concat([ + u32(0x02014b50), u16(20), u16(20), u16(flags), u16(m), u16(0), u16(0x21), + u32(crc), u32(data.length), u32(raw.length), + u16(nameBuf.length), u16(0), u16(0), u16(0), u16(0), u32(0), u32(0), nameBuf, + ]); + return Buffer.concat([ + local, central, + u32(0x06054b50), u16(0), u16(0), u16(1), u16(1), u32(central.length), u32(local.length), u16(0), + ]); +} + +const sharedHome = await mkdtemp(join(tmpdir(), "pae-raw-")); +process.env.COPILOT_HOME = sharedHome; + +test("hexDump formats offset, hex and ASCII without decoding", async () => { + const { hexDump } = await import("./raw.mjs"); + const dump = hexDump(Buffer.from([0x41, 0x00, 0xff, 0x7e]), 16); + assert.match(dump, /^00000010 {2}41 00 ff 7e/); + assert.match(dump, /\|A\.\.~\|$/); +}); + +test("raw view returns exact source, bounded hex, and explains unsupported entries", async (t) => { + const home = sharedHome; + const instanceId = `raw-${Date.now()}`; + const { startInstance, stopInstance } = await import("./server.mjs"); + const server = await startInstance(instanceId, null, () => {}); + t.after(async () => { + await stopInstance(instanceId); + await rm(home, { recursive: true, force: true }); + }); + const cache = join(home, "extensions", "pr-artifact-explorer", "artifacts", "cache"); + await mkdir(cache, { recursive: true }); + + const ugly = Buffer.from('{"b":1,\r\n "a" :[1,2],"é":"ü"}\r\n'); + const bin = Buffer.from(Array.from({ length: 40000 }, (_, i) => (i * 7) % 256)); + const bigText = Buffer.from("line of text\n".repeat(400000)); + const fixtures = [ + ["2001", "d/data.json", ugly, {}], + ["2002", "d/empty.txt", Buffer.alloc(0), {}], + ["2003", "d/movie.webm", bin, { deflate: true }], + ["2004", "d/big.log", bigText, { deflate: true }], + ["2005", "d/secret.txt", Buffer.from("x"), { flags: 1 }], + ["2006", "d/odd.txt", Buffer.from("abc"), { method: 14 }], + ["2007", "d/tiny.png", Buffer.from([0x89, 0x50, 0x4e, 0x47]), {}], + ]; + for (const [id, name, content, options] of fixtures) { + await writeFile(join(cache, `${id}.zip`), zipOf(name, content, options)); + await writeFile( + join(cache, `${id}.json`), + JSON.stringify({ repository: "o/r", artifact: { id: Number(id), name: "a" }, entries: [{ name, kind: "binary" }] }), + ); + } + const raw = async (id, name) => { + const response = await fetch( + `${server.origin}/api/artifacts/${id}/raw?path=${encodeURIComponent(name)}`, + { headers: { "x-pr-artifact-explorer-token": server.token } }, + ); + return { status: response.status, body: await response.json() }; + }; + + const json = await raw(2001, "d/data.json"); + assert.equal(json.status, 200, JSON.stringify(json.body)); + assert.equal(json.body.mode, "text"); + assert.equal(json.body.content, ugly.toString("utf8"), "JSON is not reserialized"); + assert.equal(json.body.truncated, false); + + const empty = await raw(2002, "d/empty.txt"); + assert.equal(empty.body.mode, "empty"); + assert.equal(empty.body.size, 0); + + const movie = await raw(2003, "d/movie.webm"); + assert.equal(movie.body.mode, "hex"); + assert.equal(movie.body.truncated, true); + assert.equal(movie.body.size, bin.length); + assert.equal(movie.body.shown, 16 * 1024); + assert.equal(movie.body.content.split("\n").length, 1024); + + const big = await raw(2004, "d/big.log"); + assert.equal(big.body.mode, "text"); + assert.equal(big.body.truncated, true); + assert.equal(big.body.size, bigText.length); + assert.ok(big.body.shown < bigText.length); + assert.equal(big.body.content, bigText.subarray(0, big.body.shown).toString("utf8")); + + const small = await raw(2007, "d/tiny.png"); + assert.equal(small.body.mode, "hex"); + assert.equal(small.body.truncated, false); + + const encrypted = await raw(2005, "d/secret.txt"); + assert.equal(encrypted.body.mode, "unsupported"); + assert.match(encrypted.body.reason, /encrypted/); + const odd = await raw(2006, "d/odd.txt"); + assert.equal(odd.body.mode, "unsupported"); + assert.match(odd.body.reason, /method 14/); + + const missing = await raw(2001, "d/nope.txt"); + assert.ok(missing.status >= 400); +}); + +test("external open validates URLs and routes through an injectable launcher", async (t) => { + assert.equal(validateExternalUrl("http://127.0.0.1:3000/a?b=1#c"), "http://127.0.0.1:3000/a?b=1#c"); + for (const bad of ["", "file:///etc/passwd", "javascript:alert(1)", "http://u:p@example.com/", "http://exa mple.com/", "http://a.com/\n", "ftp://x.com", "not a url"]) { + assert.throws(() => validateExternalUrl(bad), ExternalOpenError, bad); + } + assert.deepEqual(launcherCommand("http://a/", "win32"), { command: "rundll32.exe", args: ["url.dll,FileProtocolHandler", "http://a/"] }); + assert.equal(launcherCommand("http://a/", "darwin").command, "open"); + assert.equal(launcherCommand("http://a/", "linux").command, "xdg-open"); + + const home = await mkdtemp(join(tmpdir(), "pae-open-")); + process.env.COPILOT_HOME = home; + const { startInstance, stopInstance } = await import("./server.mjs"); + const instanceId = `open-${Date.now()}`; + const server = await startInstance(instanceId, null, () => {}); + const opened = []; + setExternalOpener(async (url) => { opened.push(url); }); + t.after(async () => { + setExternalOpener(null); + await stopInstance(instanceId); + await rm(home, { recursive: true, force: true }); + }); + const post = (body) => + fetch(`${server.origin}/api/browser/open-external`, { + method: "POST", + headers: { "content-type": "application/json", "x-pr-artifact-explorer-token": server.token }, + body: JSON.stringify(body), + }); + + const ok = await post({ url: "https://example.com/x?y=1#z", theme: "dark" }); + assert.equal(ok.status, 200); + assert.deepEqual(opened, ["https://example.com/x?y=1#z"]); + + const rejected = await post({ url: "javascript:alert(1)" }); + assert.equal(rejected.status, 400); + assert.equal(opened.length, 1, "invalid URLs never reach the launcher"); + + setExternalOpener(async () => { throw new Error("no handler"); }); + const failed = await post({ url: "https://example.com/" }); + assert.equal(failed.status, 502); + assert.match((await failed.json()).error ?? "", /Could not launch/); +}); diff --git a/extensions/pr-artifact-explorer/raw-wiring.test.mjs b/extensions/pr-artifact-explorer/raw-wiring.test.mjs new file mode 100644 index 0000000000..0d422f1384 --- /dev/null +++ b/extensions/pr-artifact-explorer/raw-wiring.test.mjs @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { normalizeColorScheme } from "./capture.mjs"; + +const asset = (name) => readFile(new URL(`./assets/${name}`, import.meta.url), "utf8"); + +test("color scheme is validated", () => { + assert.equal(normalizeColorScheme("dark"), "dark"); + assert.equal(normalizeColorScheme("light"), "light"); + assert.throws(() => normalizeColorScheme("neon")); +}); + +test("preview toolbar wires a single Preview/Raw toggle before Download", async () => { + const app = await asset("app.js"); + assert.ok(app.indexOf('class="preview-mode"') < app.indexOf('id="preview-download"')); + assert.match(app, /view=raw/); + assert.match(app, /renderRawPreview/); + assert.equal((app.match(/class="preview-mode"/g) ?? []).length, 1); +}); + +test("browser view syncs theme on load/change and exposes native open", async () => { + const browser = await asset("browser.js"); + assert.match(browser, /data-theme-aware-preview/); + assert.match(browser, /\/api\/browser\/owned/); + assert.match(browser, /id="br-open-external"/); + assert.match(browser, /\/api\/browser\/open-external/); + assert.match(browser, /MutationObserver/); + assert.match(browser, /theme: resolvedCanvasTheme\(\)/); +}); + +test("raw styles exist", async () => { + const css = await asset("app.css"); + for (const selector of [".preview-mode", ".raw-view", ".raw-note", ".raw-content"]) { + assert.ok(css.includes(selector), selector); + } +}); diff --git a/extensions/pr-artifact-explorer/raw.mjs b/extensions/pr-artifact-explorer/raw.mjs new file mode 100644 index 0000000000..e492e001d3 --- /dev/null +++ b/extensions/pr-artifact-explorer/raw.mjs @@ -0,0 +1,113 @@ +import { Writable } from "node:stream"; +import { MAX_INLINE_PREVIEW_BYTES, MAX_TRX_PREVIEW_BYTES } from "./constants.mjs"; +import { getCachedEntry, readCachedEntry, streamCachedEntry, verifyCachedEntry } from "./cache.mjs"; +import { isInlineTextKind, kindForPath } from "./detector.mjs"; + +export const RAW_HEX_PREFIX_BYTES = 16 * 1024; +export const RAW_TEXT_PREFIX_BYTES = 256 * 1024; + +/** Classic offset / hex / ASCII dump. Non-printable bytes render as "."; no decoding, so no mojibake. */ +export function hexDump(bytes, baseOffset = 0) { + const lines = []; + for (let i = 0; i < bytes.length; i += 16) { + const chunk = bytes.subarray(i, i + 16); + const hex = [...chunk].map((b) => b.toString(16).padStart(2, "0")); + while (hex.length < 16) hex.push(" "); + const ascii = [...chunk].map((b) => (b >= 0x20 && b < 0x7f ? String.fromCharCode(b) : ".")).join(""); + lines.push( + `${(baseOffset + i).toString(16).padStart(8, "0")} ${hex.slice(0, 8).join(" ")} ${hex.slice(8).join(" ")} |${ascii}|`, + ); + } + return lines.join("\n"); +} + +function unsupportedReason(entry) { + if (entry.directory) return "This entry is a directory."; + if (entry.symlink) return "This entry is a symbolic link; its target is never followed or read."; + if (entry.encrypted) return "This entry is encrypted, so its original bytes cannot be decoded here."; + return `This entry uses ZIP compression method ${entry.method}, which cannot be safely decoded here.`; +} + +function decodeUtf8(bytes) { + try { + return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes); + } catch { + return null; + } +} + +/** Drops an incomplete trailing UTF-8 sequence so a truncated prefix still decodes strictly. */ +function trimIncompleteUtf8(bytes) { + let end = bytes.length; + for (let back = 1; back <= 3 && end - back >= 0; back += 1) { + const b = bytes[end - back]; + if ((b & 0xc0) === 0x80) continue; + const need = b >= 0xf0 ? 4 : b >= 0xe0 ? 3 : b >= 0xc0 ? 2 : 1; + if (need > back) end -= back; + break; + } + return bytes.subarray(0, end); +} + +async function readPrefix(context, length) { + const chunks = []; + const sink = new Writable({ + write(chunk, _enc, cb) { + chunks.push(Buffer.from(chunk)); + cb(); + }, + }); + await verifyCachedEntry(context); + await streamCachedEntry(context, sink, null, { start: 0, end: length - 1 }); + sink.end(); + return Buffer.concat(chunks); +} + +/** + * Builds the Raw view payload for one cached entry. Whole-entry CRC/size verification always + * happens before any bytes are returned; large files return only a bounded, clearly partial prefix. + */ +export async function buildRawView(artifactId, entryPath) { + const context = await getCachedEntry(artifactId, entryPath); + const { entry } = context; + const size = entry.uncompressedSize; + const base = { path: entry.name, size }; + if (!entry.supported) { + return { ...base, mode: "unsupported", supported: false, reason: unsupportedReason(entry) }; + } + if (size === 0) return { ...base, mode: "empty", supported: true, truncated: false, shown: 0 }; + + const kind = kindForPath(entry.name); + if (isInlineTextKind(kind)) { + const limit = kind === "trx" ? MAX_TRX_PREVIEW_BYTES : MAX_INLINE_PREVIEW_BYTES; + if (size <= limit) { + const { content } = await readCachedEntry(artifactId, entryPath, limit); + const text = decodeUtf8(content); + if (text !== null) { + return { ...base, mode: "text", supported: true, truncated: false, shown: size, encoding: "utf-8", content: text }; + } + return hexResult(base, content.subarray(0, RAW_HEX_PREFIX_BYTES), size, "Not valid UTF-8; shown as bytes."); + } + const prefix = trimIncompleteUtf8(await readPrefix(context, RAW_TEXT_PREFIX_BYTES)); + const text = decodeUtf8(prefix); + if (text !== null) { + return { ...base, mode: "text", supported: true, truncated: true, shown: prefix.length, encoding: "utf-8", content: text }; + } + return hexResult(base, prefix.subarray(0, RAW_HEX_PREFIX_BYTES), size, "Not valid UTF-8; shown as bytes."); + } + const prefix = size <= RAW_HEX_PREFIX_BYTES ? await readPrefix(context, size) : await readPrefix(context, RAW_HEX_PREFIX_BYTES); + return hexResult(base, prefix, size, "Binary content; shown as bytes."); +} + +function hexResult(base, bytes, size, encodingNote) { + return { + ...base, + mode: "hex", + supported: true, + truncated: bytes.length < size, + shown: bytes.length, + encoding: "binary", + note: encodingNote, + content: hexDump(bytes), + }; +} diff --git a/extensions/pr-artifact-explorer/server.mjs b/extensions/pr-artifact-explorer/server.mjs index 47dd0eb26a..3b7e8dacb2 100644 --- a/extensions/pr-artifact-explorer/server.mjs +++ b/extensions/pr-artifact-explorer/server.mjs @@ -43,12 +43,15 @@ import { suggestRepositories, } from "./github.mjs"; import { + describeOwnedPreview, startStaticPreview, stopAllStaticPreviews, stopStaticPreviewsForArtifact, stopStaticPreviewsForOrigin, } from "./preview.mjs"; import { ExpiringPromiseCache } from "./memory-cache.mjs"; +import { buildRawView } from "./raw.mjs"; +import { ExternalOpenError, openInDefaultBrowser, validateExternalUrl } from "./external-open.mjs"; import { renderHtml } from "./render.mjs"; import { hasCapabilityToken, @@ -831,6 +834,7 @@ async function handleApi(req, res, entry, url) { height: body.height, deviceScaleFactor: body.deviceScaleFactor, orientation: body.orientation, + colorScheme: body.theme, fullPage: body.fullPage === true, }); return sendJson(res, 200, { capture: result }); @@ -842,14 +846,18 @@ async function handleApi(req, res, entry, url) { } } - const controlledMatch = path.match(/^\/api\/controlled\/(navigate|history|viewport|screenshot|frame|close)$/); + const controlledMatch = path.match(/^\/api\/controlled\/(navigate|history|viewport|screenshot|frame|close|theme)$/); if (req.method === "POST" && controlledMatch) { const body = await readJsonBody(req); const action = controlledMatch[1]; const viewport = { preset: body.preset, width: body.width, height: body.height, deviceScaleFactor: body.deviceScaleFactor, orientation: body.orientation }; try { entry.controlled ??= new ControlledBrowser(); - if (action === "navigate") return sendJson(res, 200, { controlled: await entry.controlled.navigate(body.url, viewport) }); + if (action === "theme") return sendJson(res, 200, { controlled: await entry.controlled.setColorScheme(body.theme) }); + if (action === "navigate") { + await entry.controlled.setColorScheme(body.theme); + return sendJson(res, 200, { controlled: await entry.controlled.navigate(body.url, viewport) }); + } if (action === "history") { if (!["back", "forward", "refresh"].includes(body.direction)) throw new HttpError(400, "direction must be back, forward or refresh."); return sendJson(res, 200, { controlled: await entry.controlled.history(body.direction) }); @@ -1038,6 +1046,60 @@ async function handleApi(req, res, entry, url) { }); } + const rawMatch = path.match(/^\/api\/artifacts\/(\d+)\/raw$/); + if (req.method === "GET" && rawMatch) { + const entryPath = url.searchParams.get("path"); + if (!entryPath) throw new HttpError(400, "File path is required."); + try { + return sendJson(res, 200, await buildRawView(rawMatch[1], entryPath)); + } catch (error) { + if (error instanceof HttpError || error instanceof LimitError) throw error; + const status = /not found|not cached/i.test(error?.message ?? "") ? 404 : 502; + throw new HttpError(status, error?.message || "Could not read the original bytes.", { + code: "raw_unavailable", + cause: error, + }); + } + } + + if (req.method === "POST" && path === "/api/browser/owned") { + const body = await readJsonBody(req); + let origin = null; + try { + origin = new URL(String(body.url ?? "")).origin; + } catch { + origin = null; + } + const owned = origin ? describeOwnedPreview(origin) : null; + return sendJson(res, 200, { owned: Boolean(owned), artifactId: owned?.artifactId ?? null }); + } + + if (req.method === "POST" && path === "/api/browser/open-external") { + const body = await readJsonBody(req); + try { + let target = validateExternalUrl(body.url); + const parsed = new URL(target); + const owned = describeOwnedPreview(parsed.origin); + if (owned && (body.theme === "light" || body.theme === "dark")) { + // Owned artifact sites bake their theme at start; rebuild for the current theme. + const fresh = new URL( + await startStaticPreview(owned.artifactId, owned.entryPath, { + theme: body.theme, + parentOrigin: owned.canvasOrigin, + }), + ); + target = `${fresh.origin}${parsed.pathname}${parsed.search}${parsed.hash}`; + } + await openInDefaultBrowser(target); + return sendJson(res, 200, { opened: true, url: target }); + } catch (error) { + if (error instanceof ExternalOpenError) { + throw new HttpError(error.code === "launch_failed" ? 502 : 400, error.message, { code: error.code }); + } + throw error; + } + } + const previewMatch = path.match(/^\/api\/artifacts\/(\d+)\/preview-url$/); if (req.method === "GET" && previewMatch) { const entryPath = url.searchParams.get("path"); diff --git a/skills/pr-artifact-explorer/SKILL.md b/skills/pr-artifact-explorer/SKILL.md index 92bbc05eec..8c5b5f99d8 100644 --- a/skills/pr-artifact-explorer/SKILL.md +++ b/skills/pr-artifact-explorer/SKILL.md @@ -43,6 +43,9 @@ Use `share { input, preset?, zoom?, width?, height? }` to produce a link, route, - A contextual **Browser view** action appears in the preview toolbar only for static-site HTML artifacts; it is not a global nav item. Open URLs directly with `open_target` / `navigate_browser` or a `#/browser?v=...` route. - `.webm`, `.mp4` and `.mov` use native `