diff --git a/docs/README.plugins.md b/docs/README.plugins.md
index 123e84ab66..6d5a25a6a7 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 16f19e1fb1..5eca49e4c2 100644
--- a/docs/README.skills.md
+++ b/docs/README.skills.md
@@ -331,6 +331,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..593e6f4bf0 100644
--- a/extensions/pr-artifact-explorer/README.md
+++ b/extensions/pr-artifact-explorer/README.md
@@ -17,11 +17,30 @@ Artifact Explorer is a community-built Copilot canvas extension with the interna
- Bounds artifact discovery to the newest 30 workflow runs per head commit and reports when older runs are omitted.
- Downloads an artifact once, indexes its ZIP, and streams entries directly from the archive without extracting them to disk.
- Previews root-level static sites in a sandboxed loopback frame when the artifact contains `index.html`.
-- Plays asciinema terminal recordings and renders structured TRX test reports.
+- Plays asciinema terminal recordings and renders structured TRX and JUnit XML test reports (content-detected; other XML keeps the plain viewer). Plays `.webm`, `.mp4` and `.mov` with native video controls and byte-range seeking; playback depends on the browser's codecs, with a download fallback. A contextual "Browser view" action appears only when previewing an HTML artifact.
- Syntax-highlights JSON and XML, previews images and PDFs, renders text with safe escaping, and identifies binary content.
- 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 # in the extension folder; installs optional playwright-core
+npx playwright-core install chromium
+```
+
+Playwright is loaded lazily from the extension's own `playwright-core` dependency; loading it from arbitrary paths is not supported. The legacy `PR_ARTIFACT_EXPLORER_PLAYWRIGHT` variable is rejected with an explicit `playwright_config_unsupported` error until it is unset. Optional: `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 +74,14 @@ 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`.
+- `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..449afcf77a 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;
}
@@ -526,6 +528,30 @@ select {
border-bottom: 1px solid var(--app-border);
}
+.breadcrumb-inner {
+ display: flex;
+ width: 100%;
+ max-width: 1280px;
+ margin: 0 auto;
+ align-items: center;
+ gap: var(--base-size-8, 8px);
+}
+
+.breadcrumb-trail {
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+.route-history {
+ display: inline-flex;
+ flex: 0 0 auto;
+ gap: 2px;
+}
+
+@media (min-width: 768px) {
+ .route-history { margin-right: var(--base-size-16, 16px); }
+}
+
.breadcrumbs {
position: relative;
display: flex;
@@ -925,6 +951,16 @@ select {
overflow: visible;
}
+.artifact-list-box > :first-child {
+ border-top-left-radius: calc(var(--app-radius) - 1px);
+ border-top-right-radius: calc(var(--app-radius) - 1px);
+}
+
+.artifact-list-box > :last-child {
+ border-bottom-left-radius: calc(var(--app-radius) - 1px);
+ border-bottom-right-radius: calc(var(--app-radius) - 1px);
+}
+
.artifact-toolbar {
position: relative;
gap: var(--base-size-8, 8px);
@@ -2430,6 +2466,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;
@@ -2465,6 +2536,34 @@ select {
object-fit: contain;
}
+.markdown-preview {
+ width: 100%;
+ min-height: 100%;
+ box-sizing: border-box;
+ padding: var(--base-size-16, 16px) var(--base-size-24, 24px);
+ overflow: auto;
+ color: var(--app-fg);
+ background: var(--app-bg);
+ border: 1px solid var(--app-border);
+ border-radius: var(--app-radius);
+ line-height: 1.6;
+ overflow-wrap: anywhere;
+}
+.markdown-preview > :first-child { margin-top: 0; }
+.markdown-preview > :last-child { margin-bottom: 0; }
+.markdown-preview h1, .markdown-preview h2 { padding-bottom: 0.3em; border-bottom: 1px solid var(--app-border); }
+.markdown-preview h1, .markdown-preview h2, .markdown-preview h3,
+.markdown-preview h4, .markdown-preview h5, .markdown-preview h6 { margin: 1.2em 0 0.6em; line-height: 1.25; }
+.markdown-preview a { color: var(--app-accent, var(--fgColor-accent, #0969da)); }
+.markdown-preview code { font-family: var(--app-mono); font-size: 85%; padding: 0.15em 0.4em; background: var(--app-muted-bg, rgba(127,127,127,.15)); border-radius: 6px; }
+.markdown-preview pre.md-code { overflow: auto; padding: 12px 16px; background: var(--app-muted-bg, rgba(127,127,127,.15)); border-radius: var(--app-radius); }
+.markdown-preview pre.md-code code { padding: 0; background: none; }
+.markdown-preview blockquote { margin: 0.8em 0; padding: 0 1em; border-left: 4px solid var(--app-border); opacity: 0.85; }
+.markdown-preview hr { border: 0; border-top: 1px solid var(--app-border); }
+.markdown-preview .md-table-wrap { overflow-x: auto; margin: 0.8em 0; }
+.markdown-preview table { border-collapse: collapse; }
+.markdown-preview th, .markdown-preview td { padding: 6px 12px; border: 1px solid var(--app-border); }
+.markdown-preview .md-image { font-style: italic; opacity: 0.8; }
.text-preview {
width: 100%;
min-height: 100%;
@@ -3280,6 +3379,41 @@ select {
border-radius: var(--app-radius);
}
+.pull-loading-title,
+.pull-loading-title .skeleton {
+ display: block;
+ height: 32px;
+}
+
+.pull-loading-title {
+ width: min(560px, 100%);
+}
+
+.pull-loading-action {
+ display: inline-block;
+ width: 120px;
+ height: 28px;
+}
+
+.pull-loading-badge {
+ display: inline-block;
+ width: 72px;
+ height: 26px;
+ border-radius: 2em;
+}
+
+.pull-loading-subtext {
+ display: inline-block;
+ width: min(360px, 60%);
+ height: 18px;
+}
+
+.pull-loading-toolbar {
+ display: block;
+ width: 160px;
+ height: 20px;
+}
+
.skeleton.short {
width: 55%;
margin-top: var(--base-size-8, 8px);
@@ -3812,3 +3946,320 @@ 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 { min-height: 36px; }
+.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(--bgColor-inset, var(--background-color-default, var(--app-bg-inset)));
+ color: var(--app-fg, var(--text-color-default, inherit));
+ color-scheme: light dark;
+ 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; }
+.video-preview { display: flex; flex-direction: column; gap: 12px; width: 100%; min-width: 0; }
+.video-preview-player { width: 100%; max-height: 70vh; background: #000; border-radius: 6px; }
+.video-preview-player[hidden], .video-preview-error[hidden] { display: none; }
+.trx-outcome-error { border-left-color: var(--danger-fg, #d1242f); }
+.trx-metric-failed strong, .trx-metric-error strong { color: var(--danger-fg, #d1242f); }
+
+.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; }
+.rerun-dialog form.rerun-form { display: block; }
+.rerun-scopes { border: 0; margin: 8px 0; padding: 0; }
+.rerun-scopes legend { font-weight: 600; padding: 0; }
+.rerun-scope { display: block; margin: 4px 0; }
+.rerun-warning, .rerun-disabled { color: var(--app-danger-fg, var(--app-fg)); }
+.rerun-actions { display: flex; justify-content: end; gap: 8px; 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..e6119b87bd 100644
--- a/extensions/pr-artifact-explorer/assets/app.js
+++ b/extensions/pr-artifact-explorer/assets/app.js
@@ -1,5 +1,89 @@
const view = document.getElementById("view");
const breadcrumbs = document.getElementById("breadcrumbs");
+const breadcrumbTrail = document.getElementById("breadcrumb-trail");
+const routeBack = document.getElementById("route-back");
+const routeForward = document.getElementById("route-forward");
+
+// In-canvas route history: independent of the outer app and embedded sites.
+const NAV_HISTORY_KEY = "pae.routeHistory";
+const NAV_HISTORY_LIMIT = 100;
+const navHistory = (() => {
+ let stack = [];
+ let index = -1;
+ const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
+ const decode = (value) => {
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return null;
+ }
+ };
+ const positive = (value) => /^[1-9]\d{0,15}$/.test(value ?? "");
+ const trackable = (hash) => {
+ if (typeof hash !== "string" || !hash.startsWith("#/") || hash.length >= 4096) return false;
+ const raw = hash.slice(2);
+ const queryIndex = raw.indexOf("?");
+ const parts = (queryIndex >= 0 ? raw.slice(0, queryIndex) : raw).split("/");
+ const [name, repo, a, b] = parts;
+ if (name === "cache" || name === "accounts") return parts.length === 1;
+ if (name === "browser") return parts.length === 1;
+ if (name === "docs") return parts.length <= 2 && (parts[1] === undefined || decode(parts[1]) !== null);
+ if (name === "pulls") return parts.length === 2 && REPO.test(decode(repo) ?? "");
+ if (name === "pull" || name === "issue") return parts.length === 3 && REPO.test(decode(repo) ?? "") && positive(a);
+ if (name === "artifact") {
+ return parts.length === 4 && REPO.test(decode(repo) ?? "") && positive(a) && positive(b);
+ }
+ return false;
+ };
+ const isBrowser = (hash) => typeof hash === "string" && hash.startsWith("#/browser");
+ try {
+ const saved = JSON.parse(sessionStorage.getItem(NAV_HISTORY_KEY) || "null");
+ if (saved && Array.isArray(saved.stack) && Number.isInteger(saved.index)) {
+ stack = saved.stack.filter(trackable);
+ index = stack.length === saved.stack.length ? Math.min(saved.index, stack.length - 1) : stack.length - 1;
+ }
+ } catch {}
+ const sync = () => {
+ try {
+ sessionStorage.setItem(NAV_HISTORY_KEY, JSON.stringify({ stack, index }));
+ } catch {}
+ if (routeBack) routeBack.disabled = index <= 0;
+ if (routeForward) routeForward.disabled = index >= stack.length - 1;
+ };
+ return {
+ // Browser view URL/zoom/resize updates replace the current browser entry.
+ replaceBrowser(hash) {
+ if (!isBrowser(hash)) return;
+ if (isBrowser(stack[index])) {
+ if (stack[index] !== hash) {
+ stack[index] = hash;
+ sync();
+ }
+ } else this.record(hash);
+ },
+ record(hash = location.hash) {
+ if (isBrowser(hash) && isBrowser(stack[index]) && trackable(hash)) return this.replaceBrowser(hash);
+ if (!trackable(hash) || stack[index] === hash) return sync();
+ stack = stack.slice(0, index + 1);
+ stack.push(hash);
+ if (stack.length > NAV_HISTORY_LIMIT) stack = stack.slice(-NAV_HISTORY_LIMIT);
+ index = stack.length - 1;
+ sync();
+ },
+ step(delta) {
+ const next = index + delta;
+ if (next < 0 || next >= stack.length) return;
+ index = next;
+ sync();
+ if (location.hash === stack[index]) void renderRoute();
+ else location.hash = stack[index];
+ },
+ sync,
+ };
+})();
+navHistory.sync();
+routeBack?.addEventListener("click", () => navHistory.step(-1));
+routeForward?.addEventListener("click", () => navHistory.step(1));
const repositoryForm = document.getElementById("repository-form");
const repositoryInput = document.getElementById("repository-input");
const repositoryCombobox = document.getElementById("repository-combobox");
@@ -416,9 +500,35 @@ function resolvedCanvasTheme() {
const mode = element?.getAttribute("data-color-mode");
if (mode === "light" || mode === "dark") return mode;
}
+ for (const element of [document.documentElement, document.body]) {
+ const scheme = element ? getComputedStyle(element).colorScheme.trim() : "";
+ if (scheme === "light" || scheme === "dark") return scheme;
+ }
return previewThemeMedia.matches ? "dark" : "light";
}
+const THEME_ATTRIBUTES = ["data-color-mode", "data-theme-tone", "data-dark-theme", "data-light-theme", "data-theme-source", "class", "style"];
+
+/** Calls onChange(theme) only when the resolved theme actually changes (attributes, inherited style, or system preference). */
+function observeCanvasTheme(onChange) {
+ let last = resolvedCanvasTheme();
+ const check = () => {
+ const theme = resolvedCanvasTheme();
+ if (theme === last) return;
+ last = theme;
+ onChange(theme);
+ };
+ const observer = new MutationObserver(check);
+ const options = { attributes: true, attributeFilter: THEME_ATTRIBUTES };
+ observer.observe(document.documentElement, options);
+ observer.observe(document.body, options);
+ previewThemeMedia.addEventListener("change", check);
+ return () => {
+ observer.disconnect();
+ previewThemeMedia.removeEventListener("change", check);
+ };
+}
+
function syncPreviewTheme() {
const theme = resolvedCanvasTheme();
for (const frame of document.querySelectorAll("iframe[data-theme-aware-preview]")) {
@@ -428,21 +538,14 @@ function syncPreviewTheme() {
{ type: "copilot-preview-theme", theme },
new URL(frame.src).origin,
);
- } catch {
- // The preview may be navigating between artifact pages.
+ } catch (error) {
+ if (frame.contentWindow && frame.src) showToast(`Theme sync failed: ${error instanceof Error ? error.message : error}`, "error");
}
}
}
function installPreviewThemeSync() {
- const observer = new MutationObserver(syncPreviewTheme);
- const options = {
- attributes: true,
- attributeFilter: ["data-color-mode", "data-theme-tone"],
- };
- observer.observe(document.documentElement, options);
- observer.observe(document.body, options);
- previewThemeMedia.addEventListener("change", syncPreviewTheme);
+ observeCanvasTheme(syncPreviewTheme);
}
function relativeTime(value) {
@@ -636,12 +739,27 @@ 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}`;
}
function parseRoute() {
+ try {
+ return parseRouteUnsafe();
+ } catch {
+ return {
+ name: "pulls",
+ repository: bootstrapState?.prefs?.repository ?? "microsoft/aspire",
+ state: bootstrapState?.prefs?.pullState ?? "open",
+ };
+ }
+}
+
+function parseRouteUnsafe() {
const raw = location.hash.startsWith("#/") ? location.hash.slice(2) : "";
const queryIndex = raw.indexOf("?");
const path = queryIndex >= 0 ? raw.slice(0, queryIndex) : raw;
@@ -672,8 +790,22 @@ 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]) {
+ 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 {
@@ -766,7 +898,7 @@ function setBreadcrumbs(items) {
`
: "";
- breadcrumbs.innerHTML = `
+ breadcrumbTrail.innerHTML = `
${values
.map((item, index) => {
@@ -804,11 +936,40 @@ function loadingView() {
`;
}
+function pullLoadingView(pullNumber) {
+ view.setAttribute("aria-busy", "true");
+ view.innerHTML = `
+
+
+
+
+
+
+
+
+
+ ${Array.from({ length: 4 }, () => `
+
+
+
+
+
`).join("")}
+
+
+
Loading pull request #${Number(pullNumber)}…
+
`;
+}
+
function renderError(error, title = "Unable to load this view") {
+ view.removeAttribute("aria-busy");
view.innerHTML = `
${escapeHtml(title)}
${escapeHtml(error instanceof Error ? error.message : String(error))}
+
${icon("sync")} Retry
`;
}
@@ -2496,7 +2657,10 @@ function replacePullTable(payload, route, { updateHistory = true } = {}) {
payload.query ?? route.query,
payload.filters ?? route,
);
- if (updateHistory) history.pushState(null, "", hash);
+ if (updateHistory) {
+ history.pushState(null, "", hash);
+ navHistory.record(hash);
+ }
setBreadcrumbs([
{ label: route.repository, href: hash },
{ label: "Pull requests" },
@@ -2857,11 +3021,130 @@ function artifactRowHtml(artifact, repository, pullNumber, cachedById) {
? `${icon("eye")} Open `
: `${icon("download")} Inspect `
}
+ ${
+ downloading || !artifact.run?.id
+ ? ""
+ : `${icon("sync")} Re-run `
+ }
${showTransferRow ? artifactTransferRowHtml(progress, artifact.id) : ""}`;
}
+let rerunBusy = false;
+
+function rerunDialogHtml(context, accountLabel) {
+ const run = context.run;
+ const sha = run.headSha ? run.headSha.slice(0, 7) : "unknown";
+ const failedOption = context.failedEligible
+ ? ` Re-run failed jobs (failed and dependent jobs only) `
+ : "";
+ return `
+ `;
+}
+
+async function openRerunDialog(button) {
+ if (rerunBusy || document.querySelector(".rerun-dialog")) return;
+ const repository = button.dataset.repository;
+ const body = {
+ repository,
+ runId: Number(button.dataset.rerunRun),
+ artifactId: Number(button.dataset.artifactId),
+ pullNumber: Number(button.dataset.pullNumber) || null,
+ };
+ rerunBusy = true;
+ button.disabled = true;
+ let payload;
+ try {
+ payload = await api("/api/runs/rerun-context", { method: "POST", body });
+ } catch (error) {
+ showToast(`Re-run unavailable: ${error.message}`, "error");
+ return;
+ } finally {
+ rerunBusy = false;
+ button.disabled = false;
+ }
+ const dialog = document.createElement("dialog");
+ dialog.className = "manual-copy-dialog rerun-dialog";
+ dialog.setAttribute("aria-labelledby", "rerun-title");
+ dialog.innerHTML = rerunDialogHtml(payload.context, payload.account?.login ?? payload.account?.label ?? "the active account");
+ document.body.append(dialog);
+ const status = dialog.querySelector(".rerun-status");
+ const confirm = dialog.querySelector("[data-rerun-confirm]");
+ let submitting = false;
+ const close = () => {
+ dialog.close();
+ dialog.remove();
+ button.focus();
+ };
+ dialog.addEventListener("cancel", (event) => {
+ if (submitting) event.preventDefault();
+ else queueMicrotask(() => dialog.remove());
+ });
+ dialog.querySelector("[data-rerun-cancel]").addEventListener("click", () => {
+ if (!submitting) close();
+ });
+ confirm.addEventListener("click", async () => {
+ if (submitting) return;
+ submitting = true;
+ confirm.disabled = true;
+ status.textContent = "Requesting re-run...";
+ const scope = dialog.querySelector("input[name='rerun-scope']:checked")?.value ?? "all";
+ try {
+ const result = await api("/api/runs/rerun", {
+ method: "POST",
+ body: {
+ ...body,
+ scope,
+ attempt: payload.context.run.attempt,
+ accountId: payload.account?.id,
+ confirmed: true,
+ },
+ });
+ submitting = false;
+ confirm.hidden = true;
+ dialog.querySelector("input[name='rerun-scope']")?.closest("fieldset")?.setAttribute("disabled", "");
+ const cancelButton = dialog.querySelector("[data-rerun-cancel]");
+ cancelButton.textContent = "Close";
+ status.textContent = `${result.rerun.message} `;
+ if (/^https:\/\/github\.com\//.test(result.rerun.url ?? "")) {
+ const link = document.createElement("a");
+ link.href = result.rerun.url;
+ link.target = "_blank";
+ link.rel = "noopener noreferrer";
+ link.textContent = "View workflow run";
+ status.append(link);
+ }
+ cancelButton.focus();
+ void renderRoute({ force: true });
+ } catch (error) {
+ status.textContent = error.message;
+ status.classList.add("rerun-disabled");
+ submitting = false;
+ }
+ });
+ dialog.showModal();
+ dialog.querySelector("[data-rerun-cancel]").focus();
+}
+
function artifactDownloadPhrase(progress) {
if (progress.stage === "preparing") return "Opening the artifact vault...";
if (progress.stage === "indexing") return "Download complete. Mapping the archive...";
@@ -3114,6 +3397,7 @@ function artifactResultLimitMessage(payload) {
function renderPullPayload(repository, pullNumber, payload, cache) {
updateAccountFromPayload(payload);
updateCacheHeader(cache);
+ view.removeAttribute("aria-busy");
const pull = payload.pullRequest;
const state = pullState(pull);
const cachedById = new Map(cache.artifacts.map((item) => [String(item.id), item]));
@@ -3151,10 +3435,7 @@ function renderPullPayload(repository, pullNumber, payload, cache) {
view.innerHTML = `
@@ -69,12 +72,28 @@ export const HTML = `
-
diff --git a/extensions/pr-artifact-explorer/report-state.test.mjs b/extensions/pr-artifact-explorer/report-state.test.mjs
new file mode 100644
index 0000000000..0e36dd34fb
--- /dev/null
+++ b/extensions/pr-artifact-explorer/report-state.test.mjs
@@ -0,0 +1,86 @@
+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 } from "node:zlib";
+
+const sharedHome = await mkdtemp(join(tmpdir(), "pae-report-state-"));
+process.env.COPILOT_HOME = sharedHome;
+
+const u16 = (v) => Buffer.from(Uint16Array.of(v).buffer);
+const u32 = (v) => Buffer.from(Uint32Array.of(v).buffer);
+
+function zipOf(name, raw) {
+ const nameBuf = Buffer.from(name);
+ const crc = crc32(raw);
+ const local = Buffer.concat([u32(0x04034b50), u16(20), u16(0), u16(0), u16(0), u16(0x21), u32(crc), u32(raw.length), u32(raw.length), u16(nameBuf.length), u16(0), nameBuf, raw]);
+ const central = Buffer.concat([u32(0x02014b50), u16(20), u16(20), u16(0), u16(0), u16(0), u16(0x21), u32(crc), u32(raw.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 XML = `
+
+
+
+trace
+ `;
+
+test("report filter, query and details survive Preview/Raw toggles", async (t) => {
+ const { loadPlaywright } = await import("./capture.mjs");
+ let chromium;
+ try {
+ chromium = await loadPlaywright();
+ } catch (error) {
+ return t.skip(error.message.split("\n")[0]);
+ }
+ let browser;
+ for (const options of [{}, { channel: "chrome" }, { channel: "msedge" }]) {
+ try {
+ browser = await chromium.launch({ headless: true, ...options });
+ break;
+ } catch {
+ // try the next channel
+ }
+ }
+ if (!browser) return t.skip("Chromium is not available");
+
+ const { startInstance, stopInstance } = await import("./server.mjs");
+ const instanceId = `report-state-${Date.now()}`;
+ const server = await startInstance(instanceId, null, () => {});
+ t.after(async () => {
+ await browser.close();
+ await stopInstance(instanceId);
+ await rm(sharedHome, { recursive: true, force: true });
+ });
+ const cache = join(sharedHome, "extensions", "pr-artifact-explorer", "artifacts", "cache");
+ await mkdir(cache, { recursive: true });
+ await writeFile(join(cache, "3002.zip"), zipOf("results.xml", Buffer.from(XML)));
+ await writeFile(join(cache, "3002.json"), JSON.stringify({ repository: "o/r", artifact: { id: 3002, name: "a" }, downloadedAt: new Date().toISOString(), lastAccessedAt: new Date().toISOString(), compressedBytes: 0, analysis: { primary: { kind: "xml", path: "results.xml", label: "XML code" }, entries: [{ path: "results.xml", name: "results.xml", size: Buffer.byteLength(XML), compressedSize: 0, kind: "xml", mime: "application/xml", supported: true }], counts: { xml: 1 }, fileCount: 1, totalUncompressedBytes: Buffer.byteLength(XML), zipBacked: true } }));
+
+ const page = await browser.newPage();
+ const errors = [];
+ page.on("pageerror", (error) => errors.push(error.message));
+ await page.goto(`${server.url ?? server.origin + "/"}#/artifact/o%2Fr/1/3002?file=results.xml`);
+ const rows = () => page.locator("[data-trx-result]:visible").count();
+ await page.waitForSelector('[data-junit="report"]', { timeout: 20_000 });
+ assert.equal(await rows(), 4);
+
+ await page.click('[data-trx-filter="skipped"]');
+ await page.fill("[data-trx-search-input]", "skip");
+ assert.equal(await rows(), 2);
+ await page.locator("[data-trx-result]:visible summary").first().click();
+
+ for (const toRaw of ['[data-preview-mode="raw"]', "[data-trx-view=raw]"]) {
+ await page.click(toRaw);
+ await page.waitForSelector(".raw-view", { timeout: 10_000 });
+ assert.equal(await page.locator("[data-junit]").count(), 0);
+ await page.click('[data-preview-mode="preview"]');
+ await page.waitForSelector('[data-junit="report"]', { timeout: 10_000 });
+ assert.equal(await rows(), 2, "outcome filter and query restored");
+ assert.equal(await page.inputValue("[data-trx-search-input]"), "skip");
+ assert.equal(await page.getAttribute('[data-trx-filter="skipped"]', "aria-pressed"), "true");
+ assert.equal(await page.getAttribute('[data-trx-filter="all"]', "aria-pressed"), "false");
+ }
+ assert.deepEqual(errors, []);
+});
diff --git a/extensions/pr-artifact-explorer/rerun.mjs b/extensions/pr-artifact-explorer/rerun.mjs
new file mode 100644
index 0000000000..3410f2c53a
--- /dev/null
+++ b/extensions/pr-artifact-explorer/rerun.mjs
@@ -0,0 +1,167 @@
+import { GitHubApiError, githubRequest } from "./github.mjs";
+import { normalizeRepository } from "./state.mjs";
+
+export const RERUN_WINDOW_DAYS = 30;
+export const MAX_RERUNS = 50;
+const DAY_MS = 86_400_000;
+const FAILED_CONCLUSIONS = new Set(["failure", "timed_out"]);
+const inFlight = new Map();
+
+export class RerunError extends Error {
+ constructor(status, message, extra = {}) {
+ super(message);
+ this.name = "RerunError";
+ this.status = status;
+ this.extra = extra;
+ }
+}
+
+function repositoryPath(repository) {
+ return normalizeRepository(repository).split("/").map(encodeURIComponent).join("/");
+}
+
+function positiveInt(value, label) {
+ const parsed = Number(value);
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) {
+ throw new RerunError(400, `${label} must be a positive integer.`);
+ }
+ return parsed;
+}
+
+/** Pure eligibility rules shared by the prep read and the confirmed write. */
+export function evaluateRerun(run, { permissions = null, now = Date.now() } = {}) {
+ const attempt = Number(run.attempt) || 1;
+ const initialAt = Date.parse(run.createdAt);
+ const reruns = Math.max(0, attempt - 1);
+ const failedAllowed = FAILED_CONCLUSIONS.has(run.conclusion);
+ let disabledReason = null;
+ if (!Number.isFinite(initialAt)) {
+ disabledReason = "GitHub did not report when the original run started, so eligibility cannot be confirmed.";
+ } else if (run.status !== "completed") {
+ disabledReason = "This run is still in progress. A run can be re-run only after it completes.";
+ } else if (now - initialAt > RERUN_WINDOW_DAYS * DAY_MS) {
+ disabledReason = `Runs can only be re-run within ${RERUN_WINDOW_DAYS} days of the original run.`;
+ } else if (reruns >= MAX_RERUNS) {
+ disabledReason = `This run already used the maximum of ${MAX_RERUNS} re-runs.`;
+ } else if (permissions && permissions.push !== true && permissions.admin !== true && permissions.maintain !== true) {
+ disabledReason = "This account has read-only access to the repository, so GitHub will not allow a re-run.";
+ }
+ return {
+ eligible: disabledReason === null,
+ failedEligible: disabledReason === null && failedAllowed,
+ disabledReason,
+ permissionKnown: Boolean(permissions),
+ rerunsUsed: reruns,
+ rerunsRemaining: Math.max(0, MAX_RERUNS - reruns),
+ expiresAt: Number.isFinite(initialAt) ? new Date(initialAt + RERUN_WINDOW_DAYS * DAY_MS).toISOString() : null,
+ };
+}
+
+/** Read-only, fresh GETs. Validates that the run belongs to the repository (and artifact/PR when supplied). */
+export async function prepareRerun(token, { repository, runId, artifactId = null, pullNumber = null, now = Date.now() }) {
+ const repo = normalizeRepository(repository);
+ const id = positiveInt(runId, "Run id");
+ const base = `/repos/${repositoryPath(repo)}`;
+ const raw = await githubRequest(token, `${base}/actions/runs/${id}`);
+ if (!raw || Number(raw.id) !== id) throw new RerunError(404, "GitHub did not return the requested workflow run.");
+ if (raw.repository?.full_name && raw.repository.full_name.toLowerCase() !== repo.toLowerCase()) {
+ throw new RerunError(409, "The workflow run does not belong to this repository.");
+ }
+ if (artifactId != null) {
+ const artifact = await githubRequest(token, `${base}/actions/artifacts/${positiveInt(artifactId, "Artifact id")}`);
+ if (Number(artifact?.workflow_run?.id) !== id) {
+ throw new RerunError(409, "The artifact was not produced by this workflow run.");
+ }
+ }
+ let permissions = null;
+ try {
+ const repoInfo = await githubRequest(token, base);
+ permissions = repoInfo?.permissions && typeof repoInfo.permissions === "object" ? repoInfo.permissions : null;
+ } catch (error) {
+ if (error instanceof RerunError) throw error;
+ const status = error instanceof GitHubApiError ? error.status : null;
+ throw new RerunError(status ?? 502, `Could not verify repository permissions, so no re-run was sent. ${describeGitHubFailure(error)}`, { code: "permission_check_failed" });
+ }
+ let pullHeadSha = null;
+ if (pullNumber != null) {
+ const pull = await githubRequest(token, `${base}/pulls/${positiveInt(pullNumber, "Pull request number")}`);
+ pullHeadSha = pull?.head?.sha ?? null;
+ }
+ const run = {
+ id,
+ name: raw.name ?? raw.display_title ?? "Workflow",
+ number: raw.run_number,
+ attempt: raw.run_attempt ?? 1,
+ status: raw.status,
+ conclusion: raw.conclusion ?? null,
+ event: raw.event,
+ createdAt: raw.created_at,
+ headSha: raw.head_sha ?? null,
+ headBranch: raw.head_branch ?? null,
+ actor: raw.actor?.login ?? null,
+ triggeringActor: raw.triggering_actor?.login ?? null,
+ url: raw.html_url ?? null,
+ };
+ return {
+ repository: repo,
+ run,
+ ...evaluateRerun(run, { permissions, now }),
+ pullHeadSha,
+ newerHead: Boolean(pullHeadSha && run.headSha && pullHeadSha !== run.headSha),
+ };
+}
+
+function describeGitHubFailure(error) {
+ const status = error.status;
+ const detail = error.message ? ` GitHub said: ${error.message}` : "";
+ if (status === 403 || status === 429) {
+ const limited = status === 429 || /rate limit/i.test(error.message ?? "");
+ return limited
+ ? `GitHub rate-limited the request. Wait before trying again.${detail}`
+ : `GitHub denied the re-run. The account needs write access (repo scope or Actions: write).${detail}`;
+ }
+ if (status === 404) return `GitHub could not find the run, or this account cannot re-run it.${detail}`;
+ if (status === 422) return `GitHub rejected the re-run (it may be too old, already running, or out of re-runs).${detail}`;
+ if (status === 401) return `GitHub rejected the account credentials.${detail}`;
+ return `GitHub could not start the re-run (${status ?? "network error"}).${detail}`;
+}
+
+/** One revalidated POST. No retries; concurrent calls for the same run are refused. */
+export async function requestRerun(token, input) {
+ const scope = input.scope === "failed" ? "failed" : input.scope === "all" ? "all" : null;
+ if (!scope) throw new RerunError(400, 'Scope must be "all" or "failed".');
+ const expectedAttempt = positiveInt(input.attempt, "Attempt");
+ const repo = normalizeRepository(input.repository);
+ const id = positiveInt(input.runId, "Run id");
+ const key = `${repo.toLowerCase()}#${id}`;
+ if (inFlight.has(key)) throw new RerunError(409, "A re-run request for this workflow run is already in progress.");
+ inFlight.set(key, scope);
+ try {
+ const fresh = await prepareRerun(token, { repository: repo, runId: id, artifactId: input.artifactId ?? null, pullNumber: input.pullNumber ?? null });
+ if (Number(fresh.run.attempt) !== expectedAttempt) {
+ throw new RerunError(409, `The run changed (now attempt ${fresh.run.attempt}). Review it and confirm again.`, { code: "attempt_changed", context: fresh });
+ }
+ if (!fresh.eligible) throw new RerunError(409, fresh.disabledReason, { code: "ineligible", context: fresh });
+ if (scope === "failed" && !fresh.failedEligible) {
+ throw new RerunError(409, "Only failed or timed-out runs can re-run failed jobs.", { code: "ineligible", context: fresh });
+ }
+ const suffix = scope === "failed" ? "rerun-failed-jobs" : "rerun";
+ try {
+ await githubRequest(token, `/repos/${repositoryPath(repo)}/actions/runs/${id}/${suffix}`, { method: "POST" });
+ } catch (error) {
+ if (error instanceof GitHubApiError) throw new RerunError(error.status ?? 502, describeGitHubFailure(error), { code: "github_error" });
+ throw error;
+ }
+ return {
+ requested: true,
+ scope,
+ repository: repo,
+ runId: id,
+ previousAttempt: expectedAttempt,
+ url: fresh.run.url,
+ message: "Re-run requested. GitHub is queueing the workflow; the status is not final yet.",
+ };
+ } finally {
+ inFlight.delete(key);
+ }
+}
diff --git a/extensions/pr-artifact-explorer/rerun.test.mjs b/extensions/pr-artifact-explorer/rerun.test.mjs
new file mode 100644
index 0000000000..46a45f1dc2
--- /dev/null
+++ b/extensions/pr-artifact-explorer/rerun.test.mjs
@@ -0,0 +1,317 @@
+import assert from "node:assert/strict";
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import test from "node:test";
+
+const sharedHome = await mkdtemp(join(tmpdir(), "pae-rerun-"));
+process.env.COPILOT_HOME = sharedHome;
+process.env.GH_TOKEN = "mock-rerun-token";
+
+const {
+ MAX_RERUNS,
+ RerunError,
+ evaluateRerun,
+ prepareRerun,
+ requestRerun,
+} = await import("./rerun.mjs");
+
+const NOW = Date.parse("2026-06-15T00:00:00Z");
+const json = (status, body = null, headers = {}) =>
+ new Response(body === null ? null : JSON.stringify(body), {
+ status,
+ headers: { "content-type": "application/json", ...headers },
+ });
+
+function mockGitHub(t, options = {}) {
+ const original = globalThis.fetch;
+ const state = {
+ posts: [],
+ gets: [],
+ run: {
+ id: 900,
+ name: "CI",
+ run_number: 12,
+ run_attempt: 1,
+ status: "completed",
+ conclusion: "failure",
+ event: "pull_request",
+ created_at: new Date(Date.now() - 86_400_000).toISOString(),
+ head_sha: "aaaaaaa1111111",
+ head_branch: "feature",
+ html_url: "https://github.com/o/r/actions/runs/900",
+ triggering_actor: { login: "octo" },
+ ...options.run,
+ },
+ permissions: options.permissions === undefined ? { push: true } : options.permissions,
+ artifactRunId: options.artifactRunId ?? 900,
+ pullHead: options.pullHead ?? "aaaaaaa1111111",
+ postResponse: options.postResponse ?? (() => json(201)),
+ postDelay: options.postDelay ?? 0,
+ };
+ globalThis.fetch = async (url, init = {}) => {
+ const target = new URL(url);
+ if (target.hostname !== "api.github.com") return original(url, init);
+ const method = init.method ?? "GET";
+ if (method === "POST") {
+ state.posts.push(target.pathname);
+ if (state.postDelay) await new Promise((resolve) => setTimeout(resolve, state.postDelay));
+ return state.postResponse();
+ }
+ state.gets.push(target.pathname);
+ if (target.pathname === "/user") {
+ return json(200, { login: "tester", avatar_url: null }, { "x-oauth-scopes": "repo" });
+ }
+ if (target.pathname === "/repos/o/r") {
+ if (options.repoFailure) return options.repoFailure();
+ return json(200, { full_name: "o/r", ...(state.permissions ? { permissions: state.permissions } : {}) });
+ }
+ if (target.pathname === "/repos/o/r/actions/runs/900") return json(200, state.run);
+ if (target.pathname === "/repos/o/r/actions/artifacts/5") {
+ return json(200, { id: 5, workflow_run: { id: state.artifactRunId } });
+ }
+ if (target.pathname === "/repos/o/r/pulls/3") return json(200, { head: { sha: state.pullHead } });
+ return json(404, { message: "Not Found" });
+ };
+ t.after(() => {
+ globalThis.fetch = original;
+ });
+ return state;
+}
+
+const input = (extra = {}) => ({
+ repository: "o/r",
+ runId: 900,
+ artifactId: 5,
+ pullNumber: 3,
+ attempt: 1,
+ scope: "all",
+ ...extra,
+});
+
+test("eligibility enforces the 30-day window, 50 re-run cap and permissions", () => {
+ const base = { status: "completed", conclusion: "failure", attempt: 1 };
+ const day = 86_400_000;
+ const at = (days) => new Date(NOW - days * day).toISOString();
+ assert.equal(evaluateRerun({ ...base, createdAt: at(29.9) }, { now: NOW }).eligible, true);
+ assert.equal(evaluateRerun({ ...base, createdAt: at(30.1) }, { now: NOW }).eligible, false);
+ // 50 re-runs means attempt 51; attempt 50 still has one left.
+ assert.equal(evaluateRerun({ ...base, createdAt: at(1), attempt: MAX_RERUNS }, { now: NOW }).eligible, true);
+ const capped = evaluateRerun({ ...base, createdAt: at(1), attempt: MAX_RERUNS + 1 }, { now: NOW });
+ assert.equal(capped.eligible, false);
+ assert.match(capped.disabledReason, /50/);
+ const readOnly = evaluateRerun({ ...base, createdAt: at(1) }, { now: NOW, permissions: { pull: true, push: false } });
+ assert.equal(readOnly.eligible, false);
+ const unknown = evaluateRerun({ ...base, createdAt: at(1) }, { now: NOW, permissions: null });
+ assert.equal(unknown.eligible, true);
+ assert.equal(unknown.permissionKnown, false);
+ assert.equal(evaluateRerun({ ...base, status: "in_progress", createdAt: at(1) }, { now: NOW }).eligible, false);
+ assert.equal(evaluateRerun({ ...base, conclusion: "success", createdAt: at(1) }, { now: NOW }).failedEligible, false);
+ assert.equal(evaluateRerun({ ...base, createdAt: "garbage" }, { now: NOW }).eligible, false);
+});
+
+test("prepare is read-only, validates the artifact and flags newer heads", async (t) => {
+ const state = mockGitHub(t, { pullHead: "bbbbbbb2222222" });
+ const context = await prepareRerun("tok", { repository: "o/r", runId: 900, artifactId: 5, pullNumber: 3 });
+ assert.equal(context.newerHead, true);
+ assert.equal(context.failedEligible, true);
+ assert.equal(state.posts.length, 0);
+
+ const other = mockGitHub(t, { artifactRunId: 901 });
+ await assert.rejects(
+ prepareRerun("tok", { repository: "o/r", runId: 900, artifactId: 5 }),
+ (error) => error instanceof RerunError && error.status === 409,
+ );
+ assert.equal(other.posts.length, 0);
+});
+
+test("all and failed scopes POST the exact run endpoint once and accept an empty 201", async (t) => {
+ const state = mockGitHub(t);
+ const all = await requestRerun("tok", input());
+ assert.equal(all.requested, true);
+ assert.match(all.message, /requested/i);
+ assert.doesNotMatch(all.message, /artifact.*ready/i);
+ await requestRerun("tok", input({ scope: "failed" }));
+ assert.deepEqual(state.posts, [
+ "/repos/o/r/actions/runs/900/rerun",
+ "/repos/o/r/actions/runs/900/rerun-failed-jobs",
+ ]);
+});
+
+test("revalidation refuses changed attempts, ineligible runs and wrong scopes without POSTing", async (t) => {
+ const changed = mockGitHub(t, { run: { run_attempt: 2 } });
+ await assert.rejects(requestRerun("tok", input()), (e) => e.status === 409 && e.extra.code === "attempt_changed");
+ assert.equal(changed.posts.length, 0);
+
+ const old = mockGitHub(t, { run: { created_at: new Date(Date.now() - 31 * 86_400_000).toISOString() } });
+ await assert.rejects(requestRerun("tok", input()), (e) => e.status === 409 && e.extra.code === "ineligible");
+ assert.equal(old.posts.length, 0);
+
+ const readOnly = mockGitHub(t, { permissions: { pull: true, push: false } });
+ await assert.rejects(requestRerun("tok", input()), (e) => e.status === 409);
+ assert.equal(readOnly.posts.length, 0);
+
+ const passed = mockGitHub(t, { run: { conclusion: "success" } });
+ await assert.rejects(requestRerun("tok", input({ scope: "failed" })), (e) => e.status === 409);
+ assert.equal(passed.posts.length, 0);
+
+ const mismatch = mockGitHub(t, { artifactRunId: 1 });
+ await assert.rejects(requestRerun("tok", input()), (e) => e.status === 409);
+ assert.equal(mismatch.posts.length, 0);
+});
+
+test("confirmation shows the ORIGINAL actor, separate from the re-run initiator", async (t) => {
+ mockGitHub(t, { run: { actor: { login: "original" }, triggering_actor: { login: "rerunner" } } });
+ const context = await prepareRerun("tok", { repository: "o/r", runId: 900, artifactId: 5 });
+ assert.equal(context.run.actor, "original");
+ assert.equal(context.run.triggeringActor, "rerunner");
+});
+
+test("a failing permission GET surfaces during prep and revalidation with zero POSTs", async (t) => {
+ for (const status of [403, 404, 429]) {
+ const state = mockGitHub(t, { repoFailure: () => json(status, { message: `nope ${status}` }) });
+ await assert.rejects(
+ prepareRerun("tok", { repository: "o/r", runId: 900, artifactId: 5 }),
+ (e) => e instanceof RerunError && e.status === status && e.extra.code === "permission_check_failed",
+ );
+ await assert.rejects(requestRerun("tok", input()), (e) => e.status === status);
+ assert.equal(state.posts.length, 0);
+ }
+ const net = mockGitHub(t, { repoFailure: () => { throw new TypeError("fetch failed"); } });
+ await assert.rejects(requestRerun("tok", input()), (e) => e instanceof RerunError && e.status === 502);
+ assert.equal(net.posts.length, 0);
+
+ const unknown = mockGitHub(t, { permissions: null });
+ const context = await prepareRerun("tok", { repository: "o/r", runId: 900, artifactId: 5 });
+ assert.equal(context.permissionKnown, false);
+ assert.equal(unknown.posts.length, 0);
+});
+
+test("double submit sends exactly one POST", async (t) => {
+ const state = mockGitHub(t, { postDelay: 60 });
+ const results = await Promise.allSettled([requestRerun("tok", input()), requestRerun("tok", input())]);
+ assert.equal(results.filter((r) => r.status === "fulfilled").length, 1);
+ assert.equal(results.filter((r) => r.status === "rejected")[0].reason.status, 409);
+ assert.equal(state.posts.length, 1);
+});
+
+test("GitHub 403, 404, 422 and 429 surface clearly with no retry", async (t) => {
+ for (const [status, pattern] of [
+ [403, /write access/],
+ [404, /could not find/],
+ [422, /rejected/],
+ [429, /rate-limited/],
+ ]) {
+ const state = mockGitHub(t, { postResponse: () => json(status, { message: `boom ${status}` }) });
+ await assert.rejects(requestRerun("tok", input()), (error) => error.status === status && pattern.test(error.message));
+ assert.equal(state.posts.length, 1, `status ${status} must not be retried`);
+ }
+});
+
+test("Chromium dialog: cancel sends no POST, confirm sends one, failure keeps the cache and artifacts", async (t) => {
+ const { loadPlaywright } = await import("./capture.mjs");
+ let chromium;
+ try {
+ chromium = await loadPlaywright();
+ } catch (error) {
+ return t.skip(error.message.split("\n")[0]);
+ }
+ let browser;
+ for (const options of [{}, { channel: "chrome" }, { channel: "msedge" }]) {
+ try {
+ browser = await chromium.launch({ headless: true, ...options });
+ break;
+ } catch {
+ // try the next channel
+ }
+ }
+ if (!browser) return t.skip("Chromium is not available");
+
+ const state = mockGitHub(t, { pullHead: "bbbbbbb2222222" });
+ const { startInstance, stopInstance } = await import("./server.mjs");
+ const instanceId = `rerun-${Date.now()}`;
+ const server = await startInstance(instanceId, null, () => {});
+ t.after(async () => {
+ await browser.close();
+ await stopInstance(instanceId);
+ await rm(sharedHome, { recursive: true, force: true });
+ });
+
+ const page = await browser.newPage({ viewport: { width: 1000, height: 800 } });
+ const errors = [];
+ page.on("pageerror", (error) => errors.push(error.message));
+ await page.route("**/api/pulls/3?*", (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ pullRequest: {
+ number: 3,
+ title: "Rerun",
+ url: "https://github.com/o/r/pull/3",
+ state: "open",
+ author: "octo",
+ baseRef: "main",
+ updatedAt: new Date().toISOString(),
+ },
+ artifacts: [
+ {
+ id: 5,
+ name: "report",
+ sizeInBytes: 10,
+ expired: false,
+ createdAt: new Date().toISOString(),
+ expiresAt: new Date(Date.now() + 86_400_000).toISOString(),
+ run: { id: 900, name: "CI", number: 12, attempt: 1, status: "completed", conclusion: "failure" },
+ },
+ ],
+ runs: [],
+ artifactCount: 1,
+ runCount: 0,
+ }),
+ }),
+ );
+
+ const base = server.url ?? `${server.origin}/`;
+ await page.goto(`${base}#/pull/o%2Fr/3`);
+ await page.waitForSelector("[data-rerun-run]");
+
+ // Cancel: context reads only, zero POSTs.
+ await page.click("[data-rerun-run]");
+ await page.waitForSelector("dialog.rerun-dialog[open]");
+ const text = await page.textContent("dialog.rerun-dialog");
+ assert.match(text, /CI/);
+ assert.match(text, /run #12/);
+ assert.match(text, /attempt 1/);
+ assert.match(text, /aaaaaaa/);
+ assert.match(text, /newer commits/);
+ assert.match(text, /original commit, ref and original triggering actor/);
+ assert.equal(await page.locator("input[value=failed]").count(), 1);
+ await page.click("[data-rerun-cancel]");
+ await page.waitForSelector("dialog.rerun-dialog", { state: "detached" });
+ assert.equal(state.posts.length, 0);
+
+ // Confirm failing: error shown inside the dialog, one POST, no retry.
+ state.postResponse = () => json(403, { message: "Resource not accessible" });
+ await page.click("[data-rerun-run]");
+ await page.waitForSelector("dialog.rerun-dialog[open]");
+ await page.click("[data-rerun-confirm]", { clickCount: 2, delay: 5 });
+ await page.waitForFunction(() => /write access/.test(document.querySelector(".rerun-status")?.textContent ?? ""));
+ assert.equal(state.posts.length, 1);
+ assert.equal(await page.locator("[data-rerun-run]").count(), 1, "artifact row preserved after failure");
+ await page.click("[data-rerun-cancel]");
+ await page.waitForSelector("dialog.rerun-dialog", { state: "detached" });
+
+ // Confirm succeeding with failed-only scope: "requested", not "ready".
+ state.postResponse = () => json(201);
+ state.posts.length = 0;
+ await page.click("[data-rerun-run]");
+ await page.waitForSelector("dialog.rerun-dialog[open]");
+ await page.check("input[value=failed]");
+ await page.click("[data-rerun-confirm]");
+ await page.waitForFunction(() => /Re-run requested/.test(document.querySelector(".rerun-status")?.textContent ?? ""));
+ assert.deepEqual(state.posts, ["/repos/o/r/actions/runs/900/rerun-failed-jobs"]);
+ assert.equal(await page.locator(".rerun-status a").getAttribute("href"), "https://github.com/o/r/actions/runs/900");
+ assert.doesNotMatch(await page.textContent(".rerun-status"), /ready/i);
+ assert.deepEqual(errors, []);
+});
diff --git a/extensions/pr-artifact-explorer/server.mjs b/extensions/pr-artifact-explorer/server.mjs
index a7179bc770..4ff8d0651a 100644
--- a/extensions/pr-artifact-explorer/server.mjs
+++ b/extensions/pr-artifact-explorer/server.mjs
@@ -11,13 +11,17 @@ import {
getCacheSummary,
getCachedArtifact,
getCachedEntry,
- inspectArtifact,
+ streamCachedEntry,
+ verifyCachedEntry,
+ JobsActiveError,
+ jobRegistry,
readCachedEntry,
+ startArtifactJob,
} from "./cache.mjs";
+import { LimitError, limitPolicy } from "./limits.mjs";
import {
ASSET_ROOT,
MAX_INLINE_PREVIEW_BYTES,
- MAX_STREAMED_ENTRY_BYTES,
MAX_TRX_PREVIEW_BYTES,
} from "./constants.mjs";
import {
@@ -29,6 +33,7 @@ import {
import {
enrichPullRequests,
filterPullRequests,
+ getIssue,
getRepository,
GitHubApiError,
listRepositoryContributors,
@@ -38,13 +43,17 @@ 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 { RerunError, prepareRerun, requestRerun } from "./rerun.mjs";
import {
hasCapabilityToken,
isCanonicalHost,
@@ -62,7 +71,8 @@ import {
setPlayerPreferences,
setPullFilterPreferences,
} from "./state.mjs";
-import { streamZipEntry } from "./zip.mjs";
+import { CaptureError, ControlledBrowser, SCREENSHOT_ROOT, captureScreenshot, getScreenshotRoot } from "./capture.mjs";
+import { TargetError, describeShare, parseTarget, routeFor } from "./targets.mjs";
const servers = new Map();
const MAX_BODY_BYTES = 1024 * 1024;
@@ -83,8 +93,8 @@ const MAIN_CSP = [
"connect-src 'self'",
"font-src 'self' data:",
"form-action 'self'",
- "frame-src http://127.0.0.1:*",
- "img-src 'self' data: https://avatars.githubusercontent.com https://github.com",
+ "frame-src http: https:",
+ "img-src 'self' data: blob: https://avatars.githubusercontent.com https://github.com",
"object-src 'none'",
"script-src 'self' 'wasm-unsafe-eval'",
"style-src 'self' 'unsafe-inline'",
@@ -92,13 +102,32 @@ const MAIN_CSP = [
].join("; ");
class HttpError extends Error {
- constructor(status, message) {
- super(message);
+ constructor(status, message, extra = {}) {
+ super(message, extra.cause ? { cause: extra.cause } : undefined);
this.name = "HttpError";
this.status = status;
+ const { cause, ...rest } = extra;
+ this.extra = rest;
}
}
+/** Owned artifact sites bake their theme at start; re-resolve to the requested theme, keeping path/query/hash. Foreign URLs return null. */
+async function themedOwnedUrl(value, theme) {
+ if (theme !== "light" && theme !== "dark") return null;
+ let parsed;
+ try {
+ parsed = new URL(String(value ?? ""));
+ } catch {
+ return null;
+ }
+ const owned = describeOwnedPreview(parsed.origin);
+ if (!owned || owned.theme === theme) return null;
+ const fresh = new URL(
+ await startStaticPreview(owned.artifactId, owned.entryPath, { theme, parentOrigin: owned.canvasOrigin }),
+ );
+ return `${fresh.origin}${parsed.pathname}${parsed.search}${parsed.hash}`;
+}
+
function createCapabilityToken() {
return randomBytes(32).toString("base64url");
}
@@ -270,6 +299,33 @@ function broadcast(entry, event, payload) {
}
}
+function assertNoActiveJobs(artifactId = null) {
+ const active = jobRegistry
+ .activeJobs()
+ .filter((job) => artifactId === null || job.artifactId === String(artifactId));
+ if (active.length) {
+ throw new HttpError(409, new JobsActiveError(active).message, {
+ code: "jobs_active",
+ jobs: active.map((job) => jobRegistry.snapshot(job)),
+ });
+ }
+}
+
+let jobSubscription = null;
+function ensureJobBroadcast() {
+ if (jobSubscription) return;
+ let lastStatus = new Map();
+ jobSubscription = jobRegistry.subscribe((snapshot) => {
+ for (const entry of servers.values()) broadcast(entry, "job", snapshot);
+ const previous = lastStatus.get(snapshot.id);
+ lastStatus.set(snapshot.id, snapshot.status);
+ if (previous !== snapshot.status && snapshot.status !== "running") {
+ void broadcastCache();
+ if (lastStatus.size > 100) lastStatus = new Map([...lastStatus].slice(-50));
+ }
+ });
+}
+
async function resolveAuth(repository, force = false) {
const prefs = await loadPrefs();
const previousRepository = prefs.repository;
@@ -363,16 +419,13 @@ async function handleContentRequest(req, res, pathname, searchParams) {
await sendArchiveDownload(req, res, context, artifactId);
return true;
}
- if (context.entry.uncompressedSize > MAX_STREAMED_ENTRY_BYTES) {
- throw new HttpError(
- 413,
- "This file exceeds the 512 MiB streaming safety limit.",
- );
- }
+ const size = context.entry.uncompressedSize;
+ const range = download ? { kind: "none" } : parseByteRange(req.headers.range, size);
const headers = {
+ "Accept-Ranges": "bytes",
"Cache-Control": "no-store",
- "Content-Length": String(context.entry.uncompressedSize),
+ "Content-Length": String(size),
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",
"Content-Type": mimeForPath(context.entry.name),
"Cross-Origin-Resource-Policy": "same-origin",
@@ -383,15 +436,59 @@ async function handleContentRequest(req, res, pathname, searchParams) {
headers["Content-Disposition"] =
`attachment; filename*=UTF-8''${encodeURIComponent(posix.basename(context.entry.name))}`;
}
- res.writeHead(200, headers);
+ let status = 200;
+ let partial = null;
+ if (range.kind === "unsatisfiable") {
+ headers["Content-Range"] = `bytes */${size}`;
+ headers["Content-Length"] = "0";
+ res.writeHead(416, headers);
+ res.end();
+ return true;
+ }
+ if (range.kind === "range") {
+ if (req.method !== "HEAD") {
+ try {
+ await verifyCachedEntry(context);
+ } catch (error) {
+ throw new HttpError(502, `Entry failed integrity verification: ${error.message}`);
+ }
+ }
+ status = 206;
+ partial = { start: range.start, end: range.end };
+ headers["Content-Range"] = `bytes ${range.start}-${range.end}/${size}`;
+ headers["Content-Length"] = String(range.end - range.start + 1);
+ }
+ res.writeHead(status, headers);
if (req.method === "HEAD") {
res.end();
} else {
- await streamZipEntry(context.archivePath, context.entry, res);
+ await streamCachedEntry(context, res, null, partial);
}
return true;
}
+/** Parses a single-range `Range` header; multi-range or malformed values fall back to a full 200. */
+export function parseByteRange(header, size) {
+ if (typeof header !== "string") return { kind: "none" };
+ const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
+ if (!match || (match[1] === "" && match[2] === "")) return { kind: "none" };
+ let start;
+ let end;
+ if (match[1] === "") {
+ const suffix = Number(match[2]);
+ if (!Number.isSafeInteger(suffix) || suffix === 0) return { kind: "unsatisfiable" };
+ start = Math.max(0, size - suffix);
+ end = size - 1;
+ } else {
+ start = Number(match[1]);
+ end = match[2] === "" ? size - 1 : Math.min(Number(match[2]), size - 1);
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end)) return { kind: "none" };
+ if (start >= size || end < start) return { kind: "unsatisfiable" };
+ }
+ if (size === 0) return { kind: "unsatisfiable" };
+ return { kind: "range", start, end };
+}
+
async function handleApi(req, res, entry, url) {
const path = url.pathname;
if (req.method === "GET" && path === "/api/bootstrap") {
@@ -714,21 +811,279 @@ async function handleApi(req, res, entry, url) {
});
}
+ const issueMatch = path.match(/^\/api\/issues\/(\d+)$/);
+ if (req.method === "GET" && issueMatch) {
+ const repository = normalizeRepository(url.searchParams.get("repo"));
+ const { auth } = await resolveAuth(repository);
+ const issue = await getIssue(auth.activeToken, repository, issueMatch[1]);
+ return sendJson(res, 200, { repository, issue, account: auth.active });
+ }
+
+ if (req.method === "POST" && path === "/api/runs/rerun-context") {
+ const body = await readJsonBody(req);
+ const repository = normalizeRepository(body.repository);
+ const { auth } = await resolveAuth(repository);
+ try {
+ const context = await prepareRerun(auth.activeToken, {
+ repository,
+ runId: body.runId,
+ artifactId: body.artifactId ?? null,
+ pullNumber: body.pullNumber ?? null,
+ });
+ return sendJson(res, 200, { context, account: auth.active });
+ } catch (error) {
+ if (error instanceof RerunError) throw new HttpError(error.status, error.message, error.extra);
+ throw error;
+ }
+ }
+
+ if (req.method === "POST" && path === "/api/runs/rerun") {
+ const body = await readJsonBody(req);
+ if (body.confirmed !== true) throw new HttpError(400, "A re-run requires explicit confirmation.");
+ const repository = normalizeRepository(body.repository);
+ const { auth } = await resolveAuth(repository);
+ if (!body.accountId || body.accountId !== auth.active?.id) {
+ throw new HttpError(409, "The selected GitHub account changed. Review the re-run and confirm again.", { code: "account_changed" });
+ }
+ try {
+ const result = await requestRerun(auth.activeToken, { ...body, repository });
+ pullRequestCache.clear();
+ progressivePullRequestCache.clear();
+ return sendJson(res, 201, { rerun: result, account: auth.active });
+ } catch (error) {
+ if (error instanceof RerunError) {
+ const { context, ...extra } = error.extra ?? {};
+ throw new HttpError(error.status, error.message, { ...extra, ...(context ? { context } : {}) });
+ }
+ throw error;
+ }
+ }
+
+ if (req.method === "POST" && path === "/api/targets/parse") {
+ const body = await readJsonBody(req);
+ const prefs = await loadPrefs();
+ try {
+ const target = parseTarget(body.input, { defaultRepository: body.repository || prefs.repository });
+ return sendJson(res, 200, { target, route: routeFor(target) });
+ } catch (error) {
+ if (error instanceof TargetError) throw new HttpError(400, error.message, { code: error.code });
+ throw error;
+ }
+ }
+
+ if (req.method === "POST" && path === "/api/share") {
+ const body = await readJsonBody(req);
+ try {
+ const target = parseTarget(body.input, { defaultRepository: body.repository });
+ return sendJson(res, 200, { share: describeShare(target) });
+ } catch (error) {
+ if (error instanceof TargetError) throw new HttpError(400, error.message, { code: error.code });
+ throw error;
+ }
+ }
+
+ if (req.method === "POST" && path === "/api/capture") {
+ const body = await readJsonBody(req);
+ try {
+ const result = await captureScreenshot({
+ url: (await themedOwnedUrl(body.url, body.theme)) ?? body.url,
+ preset: body.preset,
+ width: body.width,
+ height: body.height,
+ deviceScaleFactor: body.deviceScaleFactor,
+ orientation: body.orientation,
+ colorScheme: body.theme,
+ fullPage: body.fullPage === true,
+ });
+ return sendJson(res, 200, { capture: result });
+ } catch (error) {
+ if (error instanceof CaptureError) {
+ throw new HttpError(error.code === "invalid_viewport" || error.code === "invalid_url" ? 400 : 424, error.message, { code: error.code });
+ }
+ throw error;
+ }
+ }
+
+ 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 === "theme") {
+ const result = await entry.controlled.setColorScheme(body.theme);
+ const current = entry.controlled.currentUrl;
+ const rebuilt = current ? await themedOwnedUrl(current, body.theme) : null;
+ if (rebuilt && rebuilt !== current) {
+ return sendJson(res, 200, { controlled: { ...result, ...(await entry.controlled.navigate(rebuilt, viewport)) } });
+ }
+ return sendJson(res, 200, { controlled: result });
+ }
+ if (action === "navigate") {
+ await entry.controlled.setColorScheme(body.theme);
+ const target = (await themedOwnedUrl(body.url, body.theme)) ?? body.url;
+ return sendJson(res, 200, { controlled: await entry.controlled.navigate(target, 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) });
+ }
+ if (action === "viewport") return sendJson(res, 200, { controlled: await entry.controlled.setViewport(viewport) });
+ if (action === "frame") {
+ const png = await entry.controlled.frame();
+ res.writeHead(200, { "content-type": "image/png", "content-length": png.length, "cache-control": "no-store", "x-content-type-options": "nosniff" });
+ res.end(png);
+ return;
+ }
+ if (action === "screenshot") return sendJson(res, 200, { capture: await entry.controlled.screenshot({ fullPage: body.fullPage === true }) });
+ const closed = await entry.controlled.close();
+ return sendJson(res, 200, { closed: true, clean: closed.clean });
+ } catch (error) {
+ if (error instanceof CaptureError) {
+ throw new HttpError(["invalid_viewport", "invalid_url", "not_started"].includes(error.code) ? 400 : 424, error.message, { code: error.code });
+ }
+ throw error;
+ }
+ }
+
+ const shotMatch = path.match(/^\/api\/screenshots\/(screenshot-[0-9]+-[0-9a-f]{8}\.png)$/);
+ if (req.method === "GET" && shotMatch) {
+ let file = join(getScreenshotRoot(), shotMatch[1]);
+ let info = await stat(file).catch(() => null);
+ if (!info?.isFile()) {
+ file = join(SCREENSHOT_ROOT, shotMatch[1]);
+ info = await stat(file).catch(() => null);
+ }
+ if (!info?.isFile()) throw new HttpError(404, "Screenshot not found.");
+ res.writeHead(200, {
+ "content-type": "image/png",
+ "content-length": info.size,
+ "cache-control": "no-store",
+ "x-content-type-options": "nosniff",
+ });
+ await pipeline(createReadStream(file), res);
+ return;
+ }
+
+ if (req.method === "POST" && path === "/api/probe") {
+ const body = await readJsonBody(req);
+ let target;
+ try {
+ target = new URL(String(body.url ?? ""));
+ if (target.protocol !== "http:" && target.protocol !== "https:") throw new Error("scheme");
+ } catch {
+ throw new HttpError(400, "Enter an http(s) URL.", { code: "invalid_url" });
+ }
+ try {
+ const response = await fetch(target, {
+ method: "GET",
+ redirect: "follow",
+ signal: AbortSignal.timeout(6_000),
+ });
+ await response.body?.cancel();
+ const xfo = response.headers.get("x-frame-options");
+ const csp = response.headers.get("content-security-policy") ?? "";
+ const frameAncestors = /frame-ancestors\s+([^;]+)/i.exec(csp)?.[1]?.trim() ?? null;
+ const blockedByXfo = Boolean(xfo && /deny|sameorigin/i.test(xfo));
+ const blockedByCsp = Boolean(frameAncestors && !/\*/.test(frameAncestors));
+ return sendJson(res, 200, {
+ reachable: true,
+ status: response.status,
+ finalUrl: response.url,
+ embeddable: !blockedByXfo && !blockedByCsp,
+ reason: blockedByXfo
+ ? `The site sends X-Frame-Options: ${xfo}.`
+ : blockedByCsp
+ ? `The site sends CSP frame-ancestors ${frameAncestors}.`
+ : null,
+ });
+ } catch (error) {
+ return sendJson(res, 200, {
+ reachable: false,
+ embeddable: false,
+ reason: `Could not reach the URL (${error?.cause?.code ?? error?.name ?? "error"}).`,
+ });
+ }
+ }
+
+ if (req.method === "POST" && path === "/api/browser/state") {
+ const body = await readJsonBody(req);
+ entry.browserState = {
+ url: typeof body.url === "string" ? body.url.slice(0, 2048) : null,
+ preset: typeof body.preset === "string" ? body.preset : null,
+ width: Number.isFinite(body.width) ? body.width : null,
+ height: Number.isFinite(body.height) ? body.height : null,
+ zoom: Number.isFinite(body.zoom) ? body.zoom : null,
+ };
+ return sendJson(res, 200, { ok: true });
+ }
+
+ if (req.method === "POST" && path === "/api/restart") {
+ broadcast(entry, "restart", { reason: "user" });
+ const instanceId = [...servers.entries()].find(([, value]) => value === entry)?.[0];
+ const jobsPreserved = jobRegistry.activeJobs().length;
+ sendJson(res, 200, { restarted: true, jobsPreserved });
+ if (instanceId) {
+ setTimeout(() => {
+ restartInstance(instanceId, entry.log).catch((error) =>
+ entry.log?.(`Restart failed: ${error.message}`),
+ );
+ }, 100);
+ }
+ return;
+ }
+
if (req.method === "POST" && path === "/api/artifacts/inspect") {
const body = await readJsonBody(req);
const repository = normalizeRepository(body.repository);
const { auth } = await resolveAuth(repository);
- const metadata = await inspectArtifact(
- auth.activeToken,
- repository,
- body.artifactId,
- {
- onProgress: (progress) =>
- broadcast(entry, "artifact-progress", { repository, ...progress }),
- },
- );
- broadcast(entry, "cache", await getCacheSummary());
- return sendJson(res, 200, metadata);
+ const job = await startArtifactJob(auth.activeToken, repository, body.artifactId, {
+ account: auth.active?.id ?? null,
+ });
+ return sendJson(res, 202, { job: jobRegistry.snapshot(job) });
+ }
+
+ if (req.method === "GET" && path === "/api/jobs") {
+ return sendJson(res, 200, {
+ jobs: jobRegistry.list(),
+ limits: limitPolicy.snapshot(),
+ persistError: limitPolicy.persistError ?? jobRegistry.persistError,
+ warnings: [limitPolicy.loadWarning, jobRegistry.loadWarning].filter(Boolean),
+ });
+ }
+
+ const jobMatch = path.match(/^\/api\/jobs\/([0-9a-f-]{36})(?:\/(cancel))?$/);
+ if (jobMatch) {
+ const job = jobRegistry.get(jobMatch[1]);
+ if (!job) throw new HttpError(404, "Job was not found.");
+ if (req.method === "GET" && !jobMatch[2]) {
+ return sendJson(res, 200, { job: jobRegistry.snapshot(job) });
+ }
+ if (req.method === "POST" && jobMatch[2] === "cancel") {
+ jobRegistry.cancel(job.id);
+ return sendJson(res, 200, { job: jobRegistry.snapshot(job) });
+ }
+ }
+
+ if (req.method === "PUT" && path === "/api/limits") {
+ const body = await readJsonBody(req);
+ try {
+ const result = limitPolicy.submit(body.kind, body.value);
+ return sendJson(res, 200, { ...result, limits: limitPolicy.snapshot() });
+ } catch (error) {
+ if (error instanceof LimitError) {
+ return sendJson(res, error.code === "below_current" ? 409 : 400, {
+ error: error.message,
+ code: error.code,
+ kind: error.kind,
+ minimum: error.minimum,
+ requested: error.requested,
+ status: error.code === "below_current" ? 409 : 400,
+ });
+ }
+ throw error;
+ }
}
const artifactMatch = parseArtifactRoute(path);
@@ -758,6 +1113,49 @@ 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);
+ target = (await themedOwnedUrl(target, body.theme)) ?? target;
+ 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");
@@ -791,15 +1189,19 @@ async function handleApi(req, res, entry, url) {
const cacheMatch = path.match(/^\/api\/cache\/(\d+)$/);
if (req.method === "DELETE" && cacheMatch) {
+ const cancelJobs = url.searchParams.get("cancelJobs") === "1";
+ if (!cancelJobs) assertNoActiveJobs(cacheMatch[1]);
await stopStaticPreviewsForArtifact(cacheMatch[1]);
- const result = await deleteCachedArtifact(cacheMatch[1]);
+ const result = await deleteCachedArtifact(cacheMatch[1], { cancelJobs });
broadcast(entry, "cache", await getCacheSummary());
return sendJson(res, 200, result);
}
if (req.method === "DELETE" && path === "/api/cache") {
+ const cancelJobs = url.searchParams.get("cancelJobs") === "1";
+ if (!cancelJobs) assertNoActiveJobs();
await stopAllStaticPreviews();
- const result = await clearArtifactCache();
+ const result = await clearArtifactCache({ cancelJobs });
broadcast(entry, "cache", await getCacheSummary());
return sendJson(res, 200, result);
}
@@ -811,6 +1213,14 @@ async function handleApi(req, res, entry, url) {
"Content-Type": "text/event-stream",
});
res.write(": connected\n\n");
+ res.write(
+ `event: snapshot\ndata: ${JSON.stringify({
+ jobs: jobRegistry.list(),
+ limits: limitPolicy.snapshot(),
+ warnings: [limitPolicy.loadWarning, jobRegistry.loadWarning].filter(Boolean),
+ persistError: limitPolicy.persistError ?? jobRegistry.persistError,
+ })}\n\n`,
+ );
entry.sseClients.add(res);
req.on("close", () => entry.sseClients.delete(res));
return;
@@ -855,7 +1265,10 @@ async function handleRequest(req, res, entry) {
["/assets/asciinema-player-worker.min.js", ["asciinema-player-worker.min.js", "text/javascript; charset=utf-8"]],
["/assets/app.css", ["app.css", "text/css; charset=utf-8"]],
["/assets/trx-preview.js", ["trx-preview.js", "text/javascript; charset=utf-8"]],
+ ["/assets/junit-preview.js", ["junit-preview.js", "text/javascript; charset=utf-8"]],
+ ["/assets/markdown-preview.js", ["markdown-preview.js", "text/javascript; charset=utf-8"]],
["/assets/app.js", ["app.js", "text/javascript; charset=utf-8"]],
+ ["/assets/browser.js", ["browser.js", "text/javascript; charset=utf-8"]],
]);
if (req.method === "GET" && assetTypes.has(url.pathname)) {
const [name, type] = assetTypes.get(url.pathname);
@@ -870,6 +1283,17 @@ async function handleRequest(req, res, entry) {
);
}
+ const docsMatch = url.pathname.match(/^\/assets\/docs\/([a-z0-9][a-z0-9-]{0,63})\.(png|webp|jpg|svg)$/);
+ if (req.method === "GET" && docsMatch) {
+ const types = { png: "image/png", webp: "image/webp", jpg: "image/jpeg", svg: "image/svg+xml" };
+ try {
+ return await sendFile(res, join(ASSET_ROOT, "docs", `${docsMatch[1]}.${docsMatch[2]}`), types[docsMatch[2]]);
+ } catch (error) {
+ if (error?.code === "ENOENT") throw new HttpError(404, "Asset not found.");
+ throw error;
+ }
+ }
+
if (await handleContentRequest(req, res, url.pathname, url.searchParams)) return;
if (url.pathname.startsWith("/api/") || url.pathname === "/events") {
return await handleApi(req, res, entry, url);
@@ -888,13 +1312,16 @@ async function handleRequest(req, res, entry) {
? error.status
: 500;
sendJson(res, status, {
+ ...(error instanceof HttpError ? error.extra : {}),
error: error instanceof Error ? error.message : String(error),
status,
});
}
}
-export async function startInstance(instanceId, initialInput, log) {
+export async function startInstance(instanceId, initialInput, log, reuse = null) {
+ await Promise.all([limitPolicy.load(), jobRegistry.load()]).catch((error) => log?.(`Limits or job history could not be loaded: ${error.message}`));
+ ensureJobBroadcast();
let entry = servers.get(instanceId);
if (entry) {
entry.initialInput = initialInput ?? entry.initialInput;
@@ -902,13 +1329,15 @@ export async function startInstance(instanceId, initialInput, log) {
}
entry = {
+ browserState: reuse?.browserState ?? null,
+ controlled: reuse?.controlled ?? null,
host: null,
initialInput: initialInput ?? null,
log,
origin: null,
server: null,
sseClients: new Set(),
- token: createCapabilityToken(),
+ token: reuse?.token ?? createCapabilityToken(),
url: null,
};
const server = createServer((req, res) => {
@@ -916,7 +1345,7 @@ export async function startInstance(instanceId, initialInput, log) {
});
await new Promise((resolve, reject) => {
server.once("error", reject);
- server.listen(0, "127.0.0.1", resolve);
+ server.listen(reuse?.port ?? 0, "127.0.0.1", resolve);
});
const address = server.address();
if (!address || typeof address === "string") {
@@ -931,11 +1360,12 @@ export async function startInstance(instanceId, initialInput, log) {
return entry;
}
-export async function stopInstance(instanceId) {
+export async function stopInstance(instanceId, { keepControlled = false } = {}) {
const entry = servers.get(instanceId);
if (!entry) return;
servers.delete(instanceId);
for (const response of entry.sseClients) response.end();
+ if (!keepControlled) await entry.controlled?.close();
if (entry.url) {
await stopStaticPreviewsForOrigin(new URL(entry.url).origin);
}
@@ -949,6 +1379,57 @@ export function navigateInstance(instanceId, route) {
return true;
}
+export function sendBrowserCommand(instanceId, command) {
+ const entry = servers.get(instanceId);
+ if (!entry) return false;
+ broadcast(entry, "browser-command", command);
+ return true;
+}
+
+export function getControlledBrowser(instanceId) {
+ const entry = servers.get(instanceId);
+ if (!entry) return null;
+ entry.controlled ??= new ControlledBrowser();
+ return entry.controlled;
+}
+
+export function getBrowserState(instanceId) {
+ return servers.get(instanceId)?.browserState ?? null;
+}
+
+// Full server restart that keeps the same port and capability token so the open
+// panel reconnects in place. Jobs live at module level and are never touched.
+export async function restartInstance(instanceId, log) {
+ const entry = servers.get(instanceId);
+ if (!entry) return null;
+ const reuse = {
+ port: Number(entry.host.split(":").pop()),
+ token: entry.token,
+ browserState: entry.browserState,
+ controlled: entry.controlled,
+ };
+ const initialInput = entry.initialInput;
+ await stopInstance(instanceId, { keepControlled: true });
+ let lastError;
+ for (let attempt = 0; attempt < 10; attempt += 1) {
+ try {
+ const next = await startInstance(instanceId, initialInput, log, reuse);
+ return { entry: next, samePort: true };
+ } catch (error) {
+ lastError = error;
+ if (error?.code !== "EADDRINUSE") throw error;
+ await new Promise((resolve) => setTimeout(resolve, 150));
+ }
+ }
+ log?.(`Could not rebind the previous port (${lastError?.code}); using a new port.`);
+ const next = await startInstance(instanceId, initialInput, log, {
+ token: reuse.token,
+ browserState: reuse.browserState,
+ controlled: reuse.controlled,
+ });
+ return { entry: next, samePort: false };
+}
+
export async function broadcastCache({ refresh = false } = {}) {
const summary = await getCacheSummary();
for (const entry of servers.values()) {
diff --git a/extensions/pr-artifact-explorer/server.test.mjs b/extensions/pr-artifact-explorer/server.test.mjs
new file mode 100644
index 0000000000..98ef394451
--- /dev/null
+++ b/extensions/pr-artifact-explorer/server.test.mjs
@@ -0,0 +1,116 @@
+import assert from "node:assert/strict";
+import { after, before, test } from "node:test";
+import { createServer } from "node:http";
+import { startInstance, stopInstance } from "./server.mjs";
+
+const ID = "server-test";
+let entry;
+let target;
+let origin;
+
+before(async () => {
+ target = createServer((req, res) => {
+ if (req.url === "/blocked") res.setHeader("x-frame-options", "DENY");
+ res.end("ok");
+ });
+ await new Promise((resolve) => target.listen(0, "127.0.0.1", resolve));
+ origin = `http://127.0.0.1:${target.address().port}`;
+ entry = await startInstance(ID, null, () => {});
+});
+
+after(async () => {
+ await stopInstance(ID);
+ await new Promise((resolve) => target.close(resolve));
+});
+
+const call = (path, { method = "GET", body, token = entry.token } = {}) =>
+ fetch(`${entry.origin}${path}`, {
+ method,
+ headers: { ...(token ? { "x-pr-artifact-explorer-token": token } : {}), "content-type": "application/json" },
+ body: body === undefined ? undefined : JSON.stringify(body),
+ });
+
+test("API requires the capability token", async () => {
+ const response = await call("/api/jobs", { token: null });
+ assert.ok(response.status === 401 || response.status === 403);
+ assert.equal((await call("/api/jobs")).status, 200);
+});
+
+test("targets/parse and share agree on routes", async () => {
+ const parsed = await (await call("/api/targets/parse", { method: "POST", body: { input: "github/awesome-copilot#1" } })).json();
+ assert.ok(parsed.route.startsWith("#/"));
+ const share = await (await call("/api/share", { method: "POST", body: { input: "github/awesome-copilot#1" } })).json();
+ assert.equal(share.share.route, parsed.route);
+ const bad = await call("/api/targets/parse", { method: "POST", body: { input: "" } });
+ assert.equal(bad.status, 400);
+});
+
+test("probe reports embedding barriers and unreachable hosts", async () => {
+ const ok = await (await call("/api/probe", { method: "POST", body: { url: origin } })).json();
+ assert.equal(ok.embeddable, true);
+ const blocked = await (await call("/api/probe", { method: "POST", body: { url: `${origin}/blocked` } })).json();
+ assert.equal(blocked.embeddable, false);
+ assert.match(blocked.reason, /X-Frame-Options/);
+ const invalid = await call("/api/probe", { method: "POST", body: { url: "file:///etc/passwd" } });
+ assert.equal(invalid.status, 400);
+});
+
+test("capture rejects invalid input before launching a browser", async () => {
+ const badUrl = await call("/api/capture", { method: "POST", body: { url: "ftp://x" } });
+ assert.equal(badUrl.status, 400);
+ const badSize = await call("/api/capture", { method: "POST", body: { url: origin, preset: "custom", width: 1, height: 1 } });
+ assert.equal(badSize.status, 400);
+});
+
+test("limits PUT validates kind and applies values", async () => {
+ const ok = await call("/api/limits", { method: "PUT", body: { kind: "zipEntries", value: 5000 } });
+ assert.equal(ok.status, 200);
+ const bad = await call("/api/limits", { method: "PUT", body: { kind: "nope", value: 5 } });
+ assert.ok(bad.status >= 400 && bad.status < 500);
+});
+
+test("restart keeps port and token", async () => {
+ const before = { origin: entry.origin, token: entry.token };
+ const response = await call("/api/restart", { method: "POST", body: {} });
+ assert.equal((await response.json()).restarted, true);
+ await new Promise((resolve) => setTimeout(resolve, 800));
+ const after = await fetch(`${before.origin}/api/jobs`, { headers: { "x-pr-artifact-explorer-token": before.token } });
+ assert.equal(after.status, 200);
+});
+
+test("targets/parse returns commands without a route and parses real artifact permalinks", async () => {
+ const command = await (await call("/api/targets/parse", { method: "POST", body: { input: "go back" } })).json();
+ assert.equal(command.target.kind, "command");
+ assert.equal(command.route, null);
+ const nl = await (await call("/api/targets/parse", { method: "POST", body: { input: "show localhost:3000 on mobile" } })).json();
+ assert.equal(nl.target.kind, "url");
+ assert.equal(nl.target.view.preset, "mobile");
+ const link = await (await call("/api/targets/parse", { method: "POST", body: { input: "https://github.com/github/awesome-copilot/actions/runs/123/artifacts/456" } })).json();
+ assert.equal(link.target.kind, "artifact");
+ assert.equal(link.target.artifactId, 456);
+});
+
+test("controlled routes require the token and validate input before launching Chromium", async () => {
+ const noToken = await call("/api/controlled/navigate", { method: "POST", body: { url: origin }, token: null });
+ assert.ok(noToken.status === 401 || noToken.status === 403);
+ const badUrl = await call("/api/controlled/navigate", { method: "POST", body: { url: "file:///x" } });
+ assert.equal(badUrl.status, 400);
+ const notStarted = await call("/api/controlled/history", { method: "POST", body: { direction: "back" } });
+ assert.equal(notStarted.status, 400);
+});
+
+test("docs image route only serves strictly named image files", async () => {
+ for (const path of ["/assets/docs/missing-shot.png", "/assets/docs/..%2Fserver.png", "/assets/docs/a.mjs", "/assets/docs/UPPER.png", "/assets/docs/x/y.png"]) {
+ const res = await call(path);
+ assert.equal(res.status, 404, path);
+ }
+});
+
+test("global Escape handler treats absent filter panels as closed", async () => {
+ const { readFile } = await import("node:fs/promises");
+ const source = await readFile(new URL("./assets/app.js", import.meta.url), "utf8");
+ for (const id of ["pull-author-panel", "pull-state-panel"]) {
+ assert.ok(source.includes(`getElementById("${id}")?.hidden === false`), id);
+ assert.ok(!source.includes(`getElementById("${id}")?.hidden)`), id);
+ }
+});
diff --git a/extensions/pr-artifact-explorer/targets.mjs b/extensions/pr-artifact-explorer/targets.mjs
new file mode 100644
index 0000000000..9ae406c8ec
--- /dev/null
+++ b/extensions/pr-artifact-explorer/targets.mjs
@@ -0,0 +1,354 @@
+// Single source of truth for turning links, routes and plain-language requests into explorer targets.
+const NAME = "[A-Za-z0-9_.-]+";
+const REPO = `(${NAME})\\/(${NAME})`;
+const GITHUB_HOSTS = new Set(["github.com", "www.github.com"]);
+const VIEW_VERSION = 1;
+
+export class TargetError extends Error {
+ constructor(message) {
+ super(message);
+ this.name = "TargetError";
+ }
+}
+
+function validRepository(owner, name) {
+ const repository = `${owner}/${name}`;
+ if (owner === "." || owner === ".." || name === "." || name === "..") return null;
+ return repository;
+}
+
+function positive(value) {
+ const number = Number(value);
+ return Number.isSafeInteger(number) && number > 0 ? number : null;
+}
+
+export function pullUrl(repository, number) {
+ return `https://github.com/${repository}/pull/${number}`;
+}
+
+export function issueUrl(repository, number) {
+ return `https://github.com/${repository}/issues/${number}`;
+}
+
+export function artifactUrl(repository, artifactId, runId = null) {
+ return runId
+ ? `https://github.com/${repository}/actions/runs/${runId}/artifacts/${artifactId}`
+ : `https://github.com/${repository}/actions/artifacts/${artifactId}`;
+}
+
+export function canonicalUrl(target) {
+ switch (target?.kind) {
+ case "pull":
+ return pullUrl(target.repository, target.number);
+ case "issue":
+ return issueUrl(target.repository, target.number);
+ case "artifact":
+ return artifactUrl(target.repository, target.artifactId, target.runId);
+ case "repository":
+ return `https://github.com/${target.repository}`;
+ case "url":
+ return target.url;
+ default:
+ return null;
+ }
+}
+
+function fromGitHubUrl(url) {
+ const parts = url.pathname.split("/").filter(Boolean);
+ if (parts.length < 2) return null;
+ const repository = validRepository(parts[0], parts[1].replace(/\.git$/, ""));
+ if (!repository || !new RegExp(`^${REPO}$`).test(repository)) return null;
+ if (parts.length === 2) return { kind: "repository", repository };
+ if (parts[2] === "pull" && positive(parts[3])) {
+ return { kind: "pull", repository, number: positive(parts[3]) };
+ }
+ if ((parts[2] === "issues" || parts[2] === "issue") && positive(parts[3])) {
+ return { kind: "issue", repository, number: positive(parts[3]) };
+ }
+ if (parts[2] === "actions" && parts[3] === "runs" && positive(parts[4]) && parts[5] === "artifacts" && positive(parts[6])) {
+ return { kind: "artifact", repository, artifactId: positive(parts[6]), runId: positive(parts[4]) };
+ }
+ if (parts[2] === "actions" && parts[3] === "artifacts" && positive(parts[4])) {
+ return { kind: "artifact", repository, artifactId: positive(parts[4]) };
+ }
+ return { kind: "repository", repository };
+}
+
+function fromExplorerRoute(input) {
+ const full = input.replace(/^#/, "");
+ const queryIndex = full.indexOf("?");
+ const route = queryIndex >= 0 ? full.slice(0, queryIndex) : full;
+ const query = new URLSearchParams(queryIndex >= 0 ? full.slice(queryIndex + 1) : "");
+ const target = fromExplorerPath(route, query);
+ if (target && query.has("v")) {
+ const view = decodeView(query.get("v"));
+ if (view) target.view = view;
+ }
+ return target;
+}
+
+function safeRepository(raw) {
+ let repository;
+ try {
+ repository = decodeURIComponent(raw);
+ } catch {
+ throw new TargetError("That explorer route has an invalid repository.");
+ }
+ const match = repository.match(new RegExp(`^${REPO}$`));
+ const valid = match ? validRepository(match[1], match[2]) : null;
+ if (!valid) throw new TargetError("That explorer route has an invalid repository.");
+ return valid;
+}
+
+function safeId(raw, { allowZero = false } = {}) {
+ const value = Number(raw);
+ if (!Number.isSafeInteger(value) || value < (allowZero ? 0 : 1)) {
+ throw new TargetError("That explorer route has an invalid number.");
+ }
+ return value;
+}
+
+function fromExplorerPath(route, query) {
+ if (route === "/browser") {
+ const view = decodeView(query.get("v"));
+ return view ? { kind: "url", url: view.url ?? null, view } : null;
+ }
+ let match = route.match(/^\/pull\/([^/]+)\/(\d+)$/);
+ if (match) return { kind: "pull", repository: safeRepository(match[1]), number: safeId(match[2]) };
+ match = route.match(/^\/issue\/([^/]+)\/(\d+)$/);
+ if (match) return { kind: "issue", repository: safeRepository(match[1]), number: safeId(match[2]) };
+ match = route.match(/^\/artifact\/([^/]+)\/(\d+)\/(\d+)$/);
+ if (match) {
+ const target = { kind: "artifact", repository: safeRepository(match[1]), artifactId: safeId(match[3]) };
+ const pullNumber = safeId(match[2], { allowZero: true });
+ if (pullNumber > 0) target.pullNumber = pullNumber;
+ if (query.get("file")) {
+ target.file = query.get("file");
+ if (query.get("view") === "raw") target.mode = "raw";
+ }
+ return target;
+ }
+ match = route.match(/^\/pulls\/([^/]+)$/);
+ if (match) return { kind: "repository", repository: safeRepository(match[1]) };
+ if (route === "/cache") return { kind: "cache" };
+ if (route === "/docs" || route.startsWith("/docs/")) return { kind: "docs", section: route.slice(6) || null };
+ return null;
+}
+
+function webUrl(input) {
+ let url;
+ try {
+ url = new URL(input);
+ } catch {
+ return null;
+ }
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
+ if (url.username || url.password) throw new TargetError("URLs with embedded credentials are not supported.");
+ return url;
+}
+
+function bareHost(input) {
+ const match = input.match(/^(localhost|127\.0\.0\.1|\[::1\])(:\d{1,5})?(\/\S*)?$/i);
+ return match ? `http://${input}` : null;
+}
+
+const PRESETS = "mobile|tablet|desktop|native";
+const ZOOM_MIN = 0.25;
+const ZOOM_MAX = 5;
+
+function presetView(token) {
+ const size = token.match(/^(\d{3,4})\s*[x×]\s*(\d{3,4})$/);
+ if (size) {
+ const width = Number(size[1]);
+ const height = Number(size[2]);
+ if (width < 200 || height < 200 || width > 4000 || height > 4000) return null;
+ return { preset: "custom", width, height };
+ }
+ return { preset: token };
+}
+
+// Bounded browser vocabulary only: anything else falls through to target parsing.
+function browserCommand(text) {
+ const lower = text.toLowerCase().replace(/[?!.]+$/, "").trim().replace(/^please\s+/, "");
+ let match = lower.match(/^(?:go\s+)?(back|forward)(?:\s+a\s+page)?$/);
+ if (match) return { kind: "command", command: match[1] };
+ if (/^(?:refresh|reload)(?:\s+the)?(?:\s+page)?$/.test(lower)) return { kind: "command", command: "refresh" };
+ match = lower.match(/^zoom(?:\s+to)?\s+(\d{2,3})\s*%$/);
+ if (match) {
+ const zoom = Number(match[1]) / 100;
+ if (zoom < ZOOM_MIN || zoom > ZOOM_MAX) throw new TargetError("Zoom must be between 25% and 500%.");
+ return { kind: "command", command: "zoom", zoom };
+ }
+ if (/^zoom\s+(?:to\s+)?fit$|^fit(?:\s+to\s+window)?$/.test(lower)) return { kind: "command", command: "zoom", fit: true };
+ match = lower.match(/^zoom\s+(in|out)$/);
+ if (match) return { kind: "command", command: "zoom", step: match[1] === "in" ? 1 : -1 };
+ if (/^(?:take\s+a\s+)?screenshot(?:\s+of\s+(?:the\s+)?page)?$/.test(lower)) return { kind: "command", command: "screenshot" };
+ if (/^rotate(?:\s+the)?(?:\s+(?:screen|device|view))?$/.test(lower)) return { kind: "command", command: "rotate" };
+ match = lower.match(new RegExp(`^(?:switch\\s+to\\s+|use\\s+|set\\s+)?(${PRESETS}|\\d{3,4}\\s*[x×]\\s*\\d{3,4})(?:\\s+(?:view|size|preset|viewport))?$`));
+ if (match) {
+ const view = presetView(match[1]);
+ if (!view) throw new TargetError("Custom sizes must be 200–4000 pixels.");
+ return { kind: "command", command: "viewport", ...view };
+ }
+ match = text.trim().match(new RegExp(`^(?:please\\s+)?(?:(?:show|open|preview|view|browse|go\\s+to)\\s+)?(\\S+?)\\s+(?:on|in|as|at)\\s+(?:a\\s+|an\\s+)?(${PRESETS}|\\d{3,4}\\s*[x×]\\s*\\d{3,4})(?:\\s+(?:view|size|preset|viewport))?(?:\\s+(?:at|with)\\s+(\\d{2,3})\\s*%(?:\\s+zoom)?)?$`, "i"));
+ if (match) {
+ const url = webUrl(match[1]) ?? webUrl(bareHost(match[1]) ?? "");
+ if (!url || GITHUB_HOSTS.has(url.hostname.toLowerCase())) return null;
+ const view = presetView(match[2].toLowerCase());
+ if (!view) throw new TargetError("Custom sizes must be 200–4000 pixels.");
+ if (match[3]) {
+ const zoom = Number(match[3]) / 100;
+ if (zoom < ZOOM_MIN || zoom > ZOOM_MAX) throw new TargetError("Zoom must be between 25% and 500%.");
+ view.zoom = zoom;
+ }
+ return { kind: "url", url: url.href, view };
+ }
+ return null;
+}
+
+function naturalLanguage(text) {
+ const lower = text.toLowerCase().replace(/[?!.]+$/, "").trim();
+ if (/^(?:please )?(?:show|open|go to|take) ?(?:me )?(?:to )?(?:the )?(?:docs|documentation|help|how to use)(?: tab| page)?$|^(?:docs|documentation|help)$/.test(lower)) {
+ return { kind: "docs", section: null };
+ }
+ if (/^(?:please )?(?:(?:show|open|go to|list) ?(?:me )?(?:the )?)?(?:artifact )?cache(?: tab| page)?$/.test(lower)) {
+ return { kind: "cache" };
+ }
+ const repoMatch = text.match(new RegExp(`(?:^|[\\s(])(${NAME}\\/${NAME})(?=$|[\\s,.;)#])`));
+ const [repoOwner, repoName] = repoMatch ? repoMatch[1].split("/") : [];
+ const repository = repoMatch && validRepository(repoOwner, repoName) ? repoMatch[1] : null;
+ const kindMatch = lower.match(/\b(pull request|pull|pr|issue|artifact)\b\s*(?:number\s*|no\.?\s*|id\s*)?#?(\d+)/);
+ if (kindMatch) {
+ const number = positive(kindMatch[2]);
+ if (kindMatch[1] === "artifact") return { kind: "artifact", repository, artifactId: number };
+ return { kind: kindMatch[1] === "issue" ? "issue" : "pull", repository, number };
+ }
+ if (repository && /\b(?:open|show|go to|browse|view|pulls?)\b/.test(lower)) {
+ const hash = text.match(/#(\d+)/);
+ if (hash) return { kind: "pull", repository, number: positive(hash[1]) };
+ return { kind: "repository", repository };
+ }
+ return null;
+}
+
+/**
+ * Resolves any supported input. Throws TargetError when nothing matches.
+ * Missing repository (for "pr 12") is left null so callers can apply the current context.
+ */
+export function parseTarget(value, { defaultRepository = null } = {}) {
+ const input = String(value ?? "").trim();
+ if (!input) throw new TargetError("Enter a link, repository, pull request, artifact or URL.");
+ if (input.startsWith("#/") || input.startsWith("/")) {
+ const target = fromExplorerRoute(input);
+ if (target) return target;
+ }
+ const url = webUrl(input) ?? webUrl(bareHost(input) ?? "");
+ if (url) {
+ if (GITHUB_HOSTS.has(url.hostname.toLowerCase())) {
+ const target = fromGitHubUrl(url);
+ if (target) return target;
+ throw new TargetError("That GitHub link is not a repository, pull request, issue or artifact.");
+ }
+ return { kind: "url", url: url.href };
+ }
+ let match = input.match(new RegExp(`^${REPO}#(\\d+)$`));
+ if (match && validRepository(match[1], match[2])) {
+ return { kind: "pull", repository: `${match[1]}/${match[2]}`, number: Number(match[3]), ambiguous: true };
+ }
+ match = input.match(new RegExp(`^${REPO}$`));
+ if (match && validRepository(match[1], match[2])) {
+ return { kind: "repository", repository: `${match[1]}/${match[2]}` };
+ }
+ match = input.match(new RegExp(`^${REPO}(?:\\s+|:)artifact[:\\s#]*(\\d+)$`, "i"));
+ if (match && validRepository(match[1], match[2])) {
+ return { kind: "artifact", repository: `${match[1]}/${match[2]}`, artifactId: Number(match[3]) };
+ }
+ match = input.match(/^#(\d+)$/);
+ if (match && defaultRepository) {
+ return { kind: "pull", repository: defaultRepository, number: Number(match[1]), ambiguous: true };
+ }
+ const browser = browserCommand(input);
+ if (browser) return browser;
+ const nl = naturalLanguage(input);
+ if (nl) {
+ const repository = nl.repository ?? defaultRepository;
+ if (nl.kind !== "docs" && nl.kind !== "cache" && !repository) {
+ throw new TargetError("Name a repository (owner/name) so I know where to look.");
+ }
+ return { ...nl, repository };
+ }
+ throw new TargetError(`I could not understand "${input.slice(0, 80)}".`);
+}
+
+export function routeFor(target) {
+ switch (target?.kind) {
+ case "repository":
+ return `#/pulls/${encodeURIComponent(target.repository)}`;
+ case "pull":
+ return `#/pull/${encodeURIComponent(target.repository)}/${target.number}`;
+ case "issue":
+ return `#/issue/${encodeURIComponent(target.repository)}/${target.number}`;
+ case "artifact":
+ return `#/artifact/${encodeURIComponent(target.repository)}/${target.pullNumber ?? 0}/${target.artifactId}${target.file ? `?file=${encodeURIComponent(target.file)}${target.mode === "raw" ? "&view=raw" : ""}` : ""}`;
+ case "url":
+ return target.url ? `#/browser?v=${encodeView({ ...target.view, url: target.url })}` : null;
+ case "cache":
+ return "#/cache";
+ case "docs":
+ return target.section ? `#/docs/${target.section}` : "#/docs";
+ default:
+ return null;
+ }
+}
+
+// Shareable view: never includes tokens, ports or local paths.
+export function encodeView(view) {
+ const clean = {
+ v: VIEW_VERSION,
+ ...(view.url ? { url: view.url } : {}),
+ ...(view.preset ? { preset: view.preset } : {}),
+ ...(view.width ? { w: view.width } : {}),
+ ...(view.height ? { h: view.height } : {}),
+ ...(view.zoom ? { z: view.zoom } : {}),
+ ...(view.orientation ? { o: view.orientation } : {}),
+ };
+ return Buffer.from(JSON.stringify(clean)).toString("base64url");
+}
+
+export function decodeView(fragment) {
+ try {
+ const value = JSON.parse(Buffer.from(String(fragment), "base64url").toString("utf8"));
+ if (value?.v !== VIEW_VERSION) return null;
+ const url = value.url ? webUrl(value.url)?.href : undefined;
+ return {
+ url,
+ preset: typeof value.preset === "string" ? value.preset : undefined,
+ width: positive(value.w) ?? undefined,
+ height: positive(value.h) ?? undefined,
+ zoom: Number.isFinite(value.z) ? value.z : undefined,
+ orientation: value.o === "landscape" || value.o === "portrait" ? value.o : undefined,
+ };
+ } catch {
+ return null;
+ }
+}
+
+export function describeShare(target, view = null) {
+ const merged = view ?? target?.view ?? null;
+ const hasView = merged && Object.values(merged).some((value) => value !== undefined && value !== null);
+ let route;
+ if (target?.kind === "url") {
+ route = target.url ? `#/browser?v=${encodeView({ ...merged, url: target.url })}` : null;
+ } else {
+ route = routeFor(target);
+ if (route && hasView) route += `${route.includes("?") ? "&" : "?"}v=${encodeView(merged)}`;
+ }
+ const url = target?.kind === "artifact" && !target.runId ? null : target?.kind === "url" && hasView ? null : canonicalUrl(target);
+ return {
+ target,
+ url,
+ route,
+ view: hasView ? encodeView(merged) : null,
+ text: url ?? route,
+ };
+}
diff --git a/extensions/pr-artifact-explorer/targets.test.mjs b/extensions/pr-artifact-explorer/targets.test.mjs
new file mode 100644
index 0000000000..2fdce7687c
--- /dev/null
+++ b/extensions/pr-artifact-explorer/targets.test.mjs
@@ -0,0 +1,150 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { TargetError, canonicalUrl, decodeView, describeShare, encodeView, parseTarget, routeFor } from "./targets.mjs";
+
+const R = "github/awesome-copilot";
+
+test("GitHub URLs resolve to pull, issue, artifact and repository targets", () => {
+ assert.deepEqual(parseTarget(`https://github.com/${R}/pull/12/files`), { kind: "pull", repository: R, number: 12 });
+ assert.deepEqual(parseTarget(`https://github.com/${R}/issues/7`), { kind: "issue", repository: R, number: 7 });
+ assert.deepEqual(parseTarget(`https://github.com/${R}/actions/artifacts/99`), { kind: "artifact", repository: R, artifactId: 99 });
+ assert.deepEqual(parseTarget(`https://github.com/${R}`), { kind: "repository", repository: R });
+});
+
+test("shorthand, repo plus artifact and explorer routes resolve", () => {
+ assert.equal(parseTarget(`${R}#5`).number, 5);
+ assert.equal(parseTarget(R).kind, "repository");
+ assert.equal(parseTarget(`${R} artifact 42`).artifactId, 42);
+ assert.deepEqual(parseTarget("#/artifact/github%2Fawesome-copilot/0/8"), { kind: "artifact", repository: R, artifactId: 8 });
+ assert.equal(parseTarget("#/cache").kind, "cache");
+ assert.equal(parseTarget("#/docs").kind, "docs");
+});
+
+test("natural language uses the current repository context", () => {
+ assert.deepEqual(parseTarget("open PR 33", { defaultRepository: R }), { kind: "pull", repository: R, number: 33 });
+ assert.equal(parseTarget(`show artifact #77 in ${R}`).artifactId, 77);
+ assert.equal(parseTarget("show me the docs").kind, "docs");
+ assert.equal(parseTarget("go to the cache").kind, "cache");
+ assert.throws(() => parseTarget("open issue 4"), TargetError);
+});
+
+test("localhost and HTTP(S) URLs are previewable, other schemes are rejected", () => {
+ assert.equal(parseTarget("localhost:5173/app").url, "http://localhost:5173/app");
+ assert.equal(parseTarget("https://example.com/a?b=1").kind, "url");
+ assert.throws(() => parseTarget("javascript:alert(1)"), TargetError);
+ assert.throws(() => parseTarget("file:///etc/passwd"), TargetError);
+ assert.throws(() => parseTarget("https://user:pw@example.com/"), /credentials/);
+});
+
+test("dot segments and garbage are rejected", () => {
+ for (const bad of [
+ "#/pull/o%2/5",
+ "#/pull/%E0%A4%A/5",
+ "#/pull/nope/5",
+ "#/pull/o%2Fr/0",
+ "#/pull/o%2Fr/9007199254740993",
+ "#/issue/o%2Fr/0",
+ "#/artifact/o%2Fr/0/0",
+ "#/artifact/%zz/1/2",
+ "#/pull/.%2Frepo/5",
+ "#/pull/..%2Frepo/5",
+ "#/pull/owner%2F./5",
+ "#/pull/owner%2F../5",
+ "#/issue/.%2F../5",
+ "#/artifact/..%2Frepo/0/5",
+ "#/artifact/owner%2F../0/5",
+ "./repo#5",
+ "../repo#5",
+ "owner/.#5",
+ "owner/.. artifact 5",
+ "../repo artifact 5",
+ ]) {
+ assert.throws(() => parseTarget(bad), TargetError, bad);
+ }
+ for (const text of ["show pr 5 in ./repo", "open ../repo", "owner/.. issue 4"]) {
+ let target = null;
+ try {
+ target = parseTarget(text, { defaultRepository: "github/awesome-copilot" });
+ } catch (error) {
+ assert.ok(error instanceof TargetError, text);
+ }
+ assert.ok(!target?.repository || !target.repository.split("/").some((s) => s === "." || s === ".."), text);
+ }
+ assert.equal(parseTarget("#/pull/my.org%2F.repo/5").repository, "my.org/.repo");
+ assert.equal(parseTarget("#/pull/o%2Fr/5").number, 5);
+ assert.throws(() => parseTarget("../x"), TargetError);
+ assert.throws(() => parseTarget("hello there"), TargetError);
+ assert.throws(() => parseTarget(""), TargetError);
+});
+
+test("canonical URLs, routes and share views contain no secrets or ports", () => {
+ const target = { kind: "pull", repository: R, number: 1 };
+ assert.equal(canonicalUrl(target), `https://github.com/${R}/pull/1`);
+ assert.equal(routeFor(target), "#/pull/github%2Fawesome-copilot/1");
+ const encoded = encodeView({ url: "https://example.com/", preset: "mobile", zoom: 0.5, orientation: undefined });
+ assert.deepEqual(decodeView(encoded), { url: "https://example.com/", preset: "mobile", width: undefined, height: undefined, zoom: 0.5, orientation: undefined });
+ assert.equal(decodeView("garbage"), null);
+ assert.equal(decodeView(Buffer.from(JSON.stringify({ v: 1, url: "javascript:1" })).toString("base64url")).url, undefined);
+ assert.ok(describeShare(target).text.startsWith("https://github.com/"));
+});
+
+test("real artifact permalinks and generated shares round-trip", () => {
+ const permalink = "https://github.com/github/awesome-copilot/actions/runs/123/artifacts/456";
+ const artifact = parseTarget(permalink);
+ assert.deepEqual(artifact, { kind: "artifact", repository: "github/awesome-copilot", artifactId: 456, runId: 123 });
+ assert.equal(canonicalUrl(artifact), permalink);
+
+ const withFile = { kind: "artifact", repository: "github/awesome-copilot", pullNumber: 7, artifactId: 456, file: "index.html" };
+ const route = routeFor(withFile);
+ assert.equal(route, "#/artifact/github%2Fawesome-copilot/7/456?file=index.html");
+ assert.deepEqual(parseTarget(route), withFile);
+ assert.deepEqual(parseTarget("#/artifact/github%2Fawesome-copilot/0/456?file=index.html"), { kind: "artifact", repository: "github/awesome-copilot", artifactId: 456, file: "index.html" });
+
+ for (const target of [parseTarget("github/awesome-copilot#12"), artifact, withFile]) {
+ const share = describeShare(target, { preset: "mobile", zoom: 1.5 });
+ assert.deepEqual(parseTarget(share.route).view, { url: undefined, preset: "mobile", width: undefined, height: undefined, zoom: 1.5, orientation: undefined });
+ assert.match(share.route, /v=/);
+ assert.doesNotMatch(share.text, /127\.0\.0\.1|:\d{4,5}\b/);
+ }
+});
+
+test("browser shares round-trip through the browser route", () => {
+ const view = { url: "http://localhost:3000/", preset: "custom", width: 500, height: 700, zoom: 0.5, orientation: "landscape" };
+ const target = parseTarget(`#/browser?v=${encodeView(view)}`);
+ assert.equal(target.kind, "url");
+ assert.deepEqual(target.view, { ...view });
+ assert.deepEqual(parseTarget(routeFor(target)), target);
+ const share = describeShare(target, { preset: "tablet" });
+ assert.equal(parseTarget(share.route).view.preset, "tablet");
+});
+
+test("bounded natural-language browser commands", () => {
+ assert.deepEqual(parseTarget("go back"), { kind: "command", command: "back" });
+ assert.deepEqual(parseTarget("Forward"), { kind: "command", command: "forward" });
+ assert.deepEqual(parseTarget("reload the page"), { kind: "command", command: "refresh" });
+ assert.deepEqual(parseTarget("zoom 150%"), { kind: "command", command: "zoom", zoom: 1.5 });
+ assert.deepEqual(parseTarget("zoom out"), { kind: "command", command: "zoom", step: -1 });
+ assert.deepEqual(parseTarget("tablet"), { kind: "command", command: "viewport", preset: "tablet" });
+ assert.deepEqual(parseTarget("800x600"), { kind: "command", command: "viewport", preset: "custom", width: 800, height: 600 });
+ assert.deepEqual(parseTarget("take a screenshot"), { kind: "command", command: "screenshot" });
+ const shown = parseTarget("show localhost:3000 on mobile");
+ assert.equal(shown.kind, "url");
+ assert.match(shown.url, /^http:\/\/localhost:3000\//);
+ assert.deepEqual(shown.view, { preset: "mobile" });
+ assert.equal(parseTarget("open http://localhost:5173 on 1024x768 at 50% zoom").view.zoom, 0.5);
+ assert.throws(() => parseTarget("zoom 9000%"), TargetError);
+ assert.throws(() => parseTarget("100x100"), TargetError);
+});
+test("Raw preview mode round-trips through parse, route and share", () => {
+ const r = "#/artifact/example%2Fproject/0/999997?file=compact.json&view=raw";
+ const target = parseTarget(r);
+ assert.equal(target.mode, "raw");
+ assert.equal(target.file, "compact.json");
+ assert.equal(routeFor(target), r);
+ const share = describeShare(target);
+ assert.ok(share.route.includes("view=raw"));
+ assert.equal(parseTarget(share.route).mode, "raw");
+ assert.equal(parseTarget(r.replace("&view=raw", "")).mode, undefined);
+ assert.equal(parseTarget("#/artifact/example%2Fproject/0/999997?view=raw").mode, undefined);
+ assert.equal(parseTarget(r.replace("raw", "bogus")).mode, undefined);
+});
diff --git a/extensions/pr-artifact-explorer/video-http.test.mjs b/extensions/pr-artifact-explorer/video-http.test.mjs
new file mode 100644
index 0000000000..b277c8d529
--- /dev/null
+++ b/extensions/pr-artifact-explorer/video-http.test.mjs
@@ -0,0 +1,85 @@
+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";
+
+const u16 = (v) => Buffer.from(Uint16Array.of(v).buffer);
+const u32 = (v) => Buffer.from(Uint32Array.of(v).buffer);
+
+function zipOf(name, raw, deflate) {
+ const data = deflate ? deflateRawSync(raw) : raw;
+ const nameBuf = Buffer.from(name);
+ const method = deflate ? 8 : 0;
+ const sum = crc32(raw);
+ const local = Buffer.concat([
+ u32(0x04034b50), u16(20), u16(0), u16(method), u16(0), u16(0x21),
+ u32(sum), u32(data.length), u32(raw.length), u16(nameBuf.length), u16(0), nameBuf, data,
+ ]);
+ const central = Buffer.concat([
+ u32(0x02014b50), u16(20), u16(20), u16(0), u16(method), u16(0), u16(0x21),
+ u32(sum), 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),
+ ]);
+}
+
+test("cached video entries serve 200/206/416 with byte ranges even with legacy binary metadata", async (t) => {
+ const home = await mkdtemp(join(tmpdir(), "pae-video-http-"));
+ process.env.COPILOT_HOME = home;
+ const { startInstance, stopInstance } = await import("./server.mjs");
+ const instanceId = `video-http-${Date.now()}`;
+ const entry = 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 content = Buffer.from(Array.from({ length: 6000 }, (_, i) => (i * 7) % 251));
+ for (const [id, deflate] of [["901", false], ["902", true]]) {
+ await writeFile(join(cache, `${id}.zip`), zipOf("artifacts/video.webm", content, deflate));
+ await writeFile(
+ join(cache, `${id}.json`),
+ JSON.stringify({
+ repository: "o/r",
+ artifact: { id: Number(id), name: "a" },
+ entries: [{ name: "artifacts/video.webm", kind: "binary" }],
+ }),
+ );
+ }
+
+ for (const id of ["901", "902"]) {
+ const url = `${entry.origin}/content/${id}/artifacts/video.webm?token=${entry.token}`;
+ const full = await fetch(url);
+ assert.equal(full.status, 200);
+ assert.equal(full.headers.get("content-type"), "video/webm");
+ assert.equal(full.headers.get("accept-ranges"), "bytes");
+ assert.deepEqual(Buffer.from(await full.arrayBuffer()), content);
+
+ const part = await fetch(url, { headers: { Range: "bytes=1000-1999" } });
+ assert.equal(part.status, 206);
+ assert.equal(part.headers.get("content-range"), "bytes 1000-1999/6000");
+ assert.deepEqual(Buffer.from(await part.arrayBuffer()), content.subarray(1000, 2000));
+
+ const tail = await fetch(url, { headers: { Range: "bytes=-100" } });
+ assert.equal(tail.status, 206);
+ assert.deepEqual(Buffer.from(await tail.arrayBuffer()), content.subarray(5900));
+
+ const bad = await fetch(url, { headers: { Range: "bytes=7000-" } });
+ assert.equal(bad.status, 416);
+ assert.equal(bad.headers.get("content-range"), "bytes */6000");
+
+ const head = await fetch(url, { method: "HEAD" });
+ assert.equal(head.status, 200);
+ assert.equal(head.headers.get("content-length"), "6000");
+ }
+
+ const noToken = await fetch(`${entry.origin}/content/901/artifacts/video.webm`);
+ assert.equal(noToken.status, 403);
+});
diff --git a/extensions/pr-artifact-explorer/zip.mjs b/extensions/pr-artifact-explorer/zip.mjs
index 2aec144dcc..66aea0021c 100644
--- a/extensions/pr-artifact-explorer/zip.mjs
+++ b/extensions/pr-artifact-explorer/zip.mjs
@@ -2,14 +2,11 @@ import { createReadStream } from "node:fs";
import { open, stat } from "node:fs/promises";
import { posix } from "node:path";
import { pipeline } from "node:stream/promises";
-import { Transform } from "node:stream";
+import { Transform, Writable } from "node:stream";
import { createInflateRaw } from "node:zlib";
-import {
- MAX_CENTRAL_DIRECTORY_BYTES,
- MAX_STREAMED_ENTRY_BYTES,
- MAX_ZIP_ENTRIES,
-} from "./constants.mjs";
+const ZIP64_EOCD_SIGNATURE = 0x06064b50;
+const ZIP64_LOCATOR_SIGNATURE = 0x07064b50;
const EOCD_SIGNATURE = 0x06054b50;
const CENTRAL_SIGNATURE = 0x02014b50;
const LOCAL_SIGNATURE = 0x04034b50;
@@ -85,136 +82,223 @@ function decodeName(buffer, utf8) {
return buffer.toString(utf8 ? "utf8" : "latin1");
}
-export async function readZipIndex(zipPath) {
- const archive = await stat(zipPath);
- if (!archive.isFile() || archive.size < 22) {
- throw new Error("Artifact is not a valid ZIP archive.");
+function safeNumber(value, label) {
+ if (value > BigInt(Number.MAX_SAFE_INTEGER)) {
+ throw new Error(`ZIP64 ${label} exceeds the supported range.`);
}
+ return Number(value);
+}
- const handle = await open(zipPath, "r");
- try {
- const tailLength = Math.min(archive.size, 65_557);
- const tailOffset = archive.size - tailLength;
- const tail = await readExactly(handle, tailLength, tailOffset);
- let eocdOffset = -1;
- for (let index = tail.length - 22; index >= 0; index--) {
- if (tail.readUInt32LE(index) === EOCD_SIGNATURE) {
- if (index + 22 <= tail.length) {
- const commentLength = tail.readUInt16LE(index + 20);
- if (index + 22 + commentLength === tail.length) {
- eocdOffset = index;
- break;
- }
+function applyZip64Extra(extra, fields) {
+ let cursor = 0;
+ while (cursor + 4 <= extra.length) {
+ const id = extra.readUInt16LE(cursor);
+ const size = extra.readUInt16LE(cursor + 2);
+ const end = cursor + 4 + size;
+ if (end > extra.length) throw new Error("ZIP extra field is truncated.");
+ if (id === 0x0001) {
+ let position = cursor + 4;
+ const take = (label) => {
+ if (position + 8 > end) {
+ throw new Error(`ZIP64 extra field is missing the ${label}.`);
}
- }
+ const value = safeNumber(extra.readBigUInt64LE(position), label);
+ position += 8;
+ return value;
+ };
+ if (fields.uncompressedSize === ZIP64_U32) fields.uncompressedSize = take("uncompressed size");
+ if (fields.compressedSize === ZIP64_U32) fields.compressedSize = take("compressed size");
+ if (fields.localHeaderOffset === ZIP64_U32) fields.localHeaderOffset = take("local header offset");
+ return;
}
- if (eocdOffset < 0) {
- throw new Error("ZIP end-of-directory record was not found.");
+ cursor = end;
+ }
+ if (
+ fields.uncompressedSize === ZIP64_U32 ||
+ fields.compressedSize === ZIP64_U32 ||
+ fields.localHeaderOffset === ZIP64_U32
+ ) {
+ throw new Error("ZIP64 entry is missing its extended information.");
+ }
+}
+
+async function locateDirectory(handle, archiveSize) {
+ const tailLength = Math.min(archiveSize, 65_557);
+ const tailOffset = archiveSize - tailLength;
+ const tail = await readExactly(handle, tailLength, tailOffset);
+ let eocdOffset = -1;
+ for (let index = tail.length - 22; index >= 0; index--) {
+ if (
+ tail.readUInt32LE(index) === EOCD_SIGNATURE &&
+ index + 22 + tail.readUInt16LE(index + 20) === tail.length
+ ) {
+ eocdOffset = index;
+ break;
}
+ }
+ if (eocdOffset < 0) throw new Error("ZIP end-of-directory record was not found.");
- const disk = tail.readUInt16LE(eocdOffset + 4);
- const centralDisk = tail.readUInt16LE(eocdOffset + 6);
- const entriesOnDisk = tail.readUInt16LE(eocdOffset + 8);
- const entryCount = tail.readUInt16LE(eocdOffset + 10);
- const centralSize = tail.readUInt32LE(eocdOffset + 12);
- const centralOffset = tail.readUInt32LE(eocdOffset + 16);
- if (disk !== 0 || centralDisk !== 0 || entriesOnDisk !== entryCount) {
+ const disk = tail.readUInt16LE(eocdOffset + 4);
+ const centralDisk = tail.readUInt16LE(eocdOffset + 6);
+ const entriesOnDisk = tail.readUInt16LE(eocdOffset + 8);
+ let entryCount = tail.readUInt16LE(eocdOffset + 10);
+ let centralSize = tail.readUInt32LE(eocdOffset + 12);
+ let centralOffset = tail.readUInt32LE(eocdOffset + 16);
+ if (disk !== 0 || centralDisk !== 0 || entriesOnDisk !== entryCount) {
+ throw new Error("Multi-disk ZIP artifacts are not supported.");
+ }
+ let zip64 = false;
+ if (entryCount === ZIP64_U16 || centralSize === ZIP64_U32 || centralOffset === ZIP64_U32) {
+ zip64 = true;
+ const locatorOffset = tailOffset + eocdOffset - 20;
+ if (locatorOffset < 0) throw new Error("ZIP64 locator was not found.");
+ const locator = await readExactly(handle, 20, locatorOffset);
+ if (locator.readUInt32LE(0) !== ZIP64_LOCATOR_SIGNATURE) {
+ throw new Error("ZIP64 locator was not found.");
+ }
+ if (locator.readUInt32LE(4) !== 0 || locator.readUInt32LE(16) !== 1) {
throw new Error("Multi-disk ZIP artifacts are not supported.");
}
- if (
- entryCount === ZIP64_U16 ||
- centralSize === ZIP64_U32 ||
- centralOffset === ZIP64_U32
- ) {
- throw new Error("ZIP64 artifacts larger than 4 GiB are not supported.");
+ const recordOffset = safeNumber(locator.readBigUInt64LE(8), "directory offset");
+ if (recordOffset + 56 > archiveSize) {
+ throw new Error("ZIP64 end-of-directory record points outside the archive.");
}
- if (entryCount > MAX_ZIP_ENTRIES) {
- throw new Error(`ZIP contains too many entries (${entryCount.toLocaleString()}).`);
+ const record = await readExactly(handle, 56, recordOffset);
+ if (record.readUInt32LE(0) !== ZIP64_EOCD_SIGNATURE) {
+ throw new Error("ZIP64 end-of-directory record is malformed.");
}
- if (centralSize > MAX_CENTRAL_DIRECTORY_BYTES) {
- throw new Error("ZIP central directory exceeds the 64 MiB safety limit.");
+ if (record.readUInt32LE(16) !== 0 || record.readUInt32LE(20) !== 0) {
+ throw new Error("Multi-disk ZIP artifacts are not supported.");
}
- if (centralOffset + centralSize > archive.size) {
- throw new Error("ZIP central directory points outside the archive.");
+ const onDisk = safeNumber(record.readBigUInt64LE(24), "entry count");
+ entryCount = safeNumber(record.readBigUInt64LE(32), "entry count");
+ if (onDisk !== entryCount) throw new Error("Multi-disk ZIP artifacts are not supported.");
+ centralSize = safeNumber(record.readBigUInt64LE(40), "directory size");
+ centralOffset = safeNumber(record.readBigUInt64LE(48), "directory offset");
+ }
+ if (centralOffset + centralSize > archiveSize) {
+ throw new Error("ZIP central directory points outside the archive.");
+ }
+ return { entryCount, centralSize, centralOffset, zip64 };
+}
+
+/**
+ * Reads the central directory in bounded chunks. Entry-count and directory-size
+ * thresholds are soft: crossing one reports through `onSoftLimit` and keeps
+ * indexing. Only absolute memory guards and malformed data are fatal.
+ */
+export async function readZipIndex(zipPath, { onSoftLimit = null, softLimits = {}, onProgress = null } = {}) {
+ const archive = await stat(zipPath);
+ if (!archive.isFile() || archive.size < 22) {
+ throw new Error("Artifact is not a valid ZIP archive.");
+ }
+ const handle = await open(zipPath, "r");
+ try {
+ const { entryCount, centralSize, centralOffset, zip64 } = await locateDirectory(handle, archive.size);
+ // Every central record is at least 46 bytes, so the declared count must fit the
+ // declared directory; this is an integrity check, not a size ceiling.
+ if (entryCount * 46 > centralSize) {
+ throw new Error(`ZIP entry count mismatch. ${entryCount} entries cannot fit in ${centralSize} directory bytes.`);
}
+ const warned = new Set();
+ const softCheck = (kind, current) => {
+ const limit = typeof softLimits[kind] === "function" ? softLimits[kind]() : softLimits[kind];
+ if (limit != null && current > limit && !warned.has(kind)) {
+ warned.add(kind);
+ onSoftLimit?.(kind, current);
+ } else if (limit != null && current > limit) {
+ onSoftLimit?.(kind, current, { repeat: true });
+ }
+ };
+ softCheck("zipEntries", entryCount);
+ softCheck("centralDirectoryBytes", centralSize);
- const central = await readExactly(handle, centralSize, centralOffset);
const entries = [];
- let cursor = 0;
- while (cursor < central.length) {
- if (central.length - cursor < 46 || central.readUInt32LE(cursor) !== CENTRAL_SIGNATURE) {
- throw new Error("ZIP central directory is malformed.");
- }
- const flags = central.readUInt16LE(cursor + 8);
- const method = central.readUInt16LE(cursor + 10);
- const modifiedTime = central.readUInt16LE(cursor + 12);
- const modifiedDate = central.readUInt16LE(cursor + 14);
- const expectedCrc32 = central.readUInt32LE(cursor + 16);
- const compressedSize = central.readUInt32LE(cursor + 20);
- const uncompressedSize = central.readUInt32LE(cursor + 24);
- const nameLength = central.readUInt16LE(cursor + 28);
- const extraLength = central.readUInt16LE(cursor + 30);
- const commentLength = central.readUInt16LE(cursor + 32);
- const diskStart = central.readUInt16LE(cursor + 34);
- const externalAttributes = central.readUInt32LE(cursor + 38);
- const localHeaderOffset = central.readUInt32LE(cursor + 42);
- const recordLength = 46 + nameLength + extraLength + commentLength;
- if (cursor + recordLength > central.length) {
- throw new Error("ZIP central directory entry is truncated.");
- }
- if (
- compressedSize === ZIP64_U32 ||
- uncompressedSize === ZIP64_U32 ||
- localHeaderOffset === ZIP64_U32 ||
- diskStart === ZIP64_U16
- ) {
- throw new Error("ZIP64 entries are not supported.");
+ let totalUncompressedBytes = 0;
+ let pending = Buffer.alloc(0);
+ let consumed = 0;
+ let trailing = false;
+ while (true) {
+ while (pending.length >= 46) {
+ if (entries.length >= entryCount) {
+ trailing = true;
+ break;
+ }
+ if (pending.readUInt32LE(0) !== CENTRAL_SIGNATURE) {
+ throw new Error("ZIP central directory is malformed.");
+ }
+ const nameLength = pending.readUInt16LE(28);
+ const extraLength = pending.readUInt16LE(30);
+ const commentLength = pending.readUInt16LE(32);
+ const recordLength = 46 + nameLength + extraLength + commentLength;
+ if (pending.length < recordLength) break;
+ const entry = parseCentralRecord(pending.subarray(0, recordLength), nameLength, extraLength);
+ entries.push(entry);
+ if (!entry.directory) totalUncompressedBytes += entry.uncompressedSize;
+ pending = pending.subarray(recordLength);
+ if (entries.length % 5_000 === 0) {
+ softCheck("zipEntries", entryCount);
+ onProgress?.({ entries: entries.length, entryCount });
+ }
}
-
- const rawName = decodeName(
- central.subarray(cursor + 46, cursor + 46 + nameLength),
- Boolean(flags & 0x0800),
- );
- const name = safeEntryPath(rawName);
- const unixMode = externalAttributes >>> 16;
- const directory = name.endsWith("/");
- const symlink = (unixMode & 0o170000) === 0o120000;
- const encrypted = Boolean(flags & 0x0001);
- entries.push({
- name,
- directory,
- symlink,
- encrypted,
- supported: !directory && !symlink && !encrypted && (method === 0 || method === 8),
- method,
- flags,
- crc32: expectedCrc32,
- compressedSize,
- uncompressedSize,
- localHeaderOffset,
- modifiedAt: dosDateTime(modifiedDate, modifiedTime),
- });
- cursor += recordLength;
+ if (trailing) break;
+ if (consumed >= centralSize) break;
+ const length = Math.min(1024 * 1024, centralSize - consumed);
+ const chunk = await readExactly(handle, length, centralOffset + consumed);
+ consumed += length;
+ pending = pending.length ? Buffer.concat([pending, chunk]) : chunk;
}
- if (entries.length !== entryCount) {
+ if (trailing || pending.length > 0 || entries.length !== entryCount) {
throw new Error(
- `ZIP entry count mismatch. Expected ${entryCount}, found ${entries.length}.`,
+ `ZIP entry count mismatch. Expected ${entryCount}, found ${entries.length}${trailing || pending.length ? " with trailing directory data" : ""}.`,
);
}
- return {
- path: zipPath,
- size: archive.size,
- entries,
- totalUncompressedBytes: entries.reduce(
- (total, entry) => total + (entry.directory ? 0 : entry.uncompressedSize),
- 0,
- ),
- };
+ return { path: zipPath, size: archive.size, entries, totalUncompressedBytes, zip64 };
} finally {
await handle.close();
}
}
+function parseCentralRecord(record, nameLength, extraLength) {
+ const flags = record.readUInt16LE(8);
+ const method = record.readUInt16LE(10);
+ const modifiedTime = record.readUInt16LE(12);
+ const modifiedDate = record.readUInt16LE(14);
+ const fields = {
+ compressedSize: record.readUInt32LE(20),
+ uncompressedSize: record.readUInt32LE(24),
+ localHeaderOffset: record.readUInt32LE(42),
+ };
+ const diskStart = record.readUInt16LE(34);
+ const externalAttributes = record.readUInt32LE(38);
+ if (diskStart !== 0 && diskStart !== ZIP64_U16) {
+ throw new Error("Multi-disk ZIP artifacts are not supported.");
+ }
+ const extraStart = 46 + nameLength;
+ applyZip64Extra(record.subarray(extraStart, extraStart + extraLength), fields);
+ const name = safeEntryPath(
+ decodeName(record.subarray(46, 46 + nameLength), Boolean(flags & 0x0800)),
+ );
+ const unixMode = externalAttributes >>> 16;
+ const directory = name.endsWith("/");
+ const symlink = (unixMode & 0o170000) === 0o120000;
+ const encrypted = Boolean(flags & 0x0001);
+ return {
+ name,
+ directory,
+ symlink,
+ encrypted,
+ supported: !directory && !symlink && !encrypted && (method === 0 || method === 8),
+ method,
+ flags,
+ crc32: record.readUInt32LE(16),
+ compressedSize: fields.compressedSize,
+ uncompressedSize: fields.uncompressedSize,
+ localHeaderOffset: fields.localHeaderOffset,
+ modifiedAt: dosDateTime(modifiedDate, modifiedTime),
+ };
+}
+
async function entryDataRange(zipPath, entry) {
const handle = await open(zipPath, "r");
try {
@@ -250,6 +334,11 @@ export async function readZipEntry(zipPath, entry, maxBytes) {
`ZIP entry is too large for an inline preview (${entry.uncompressedSize.toLocaleString()} bytes).`,
);
}
+ // Deflate never inflates data by more than a small fraction, so a compressed
+ // size far above the inline limit is inconsistent with the declared size.
+ if (entry.compressedSize > maxBytes + 1024 * 1024) {
+ throw new Error(`ZIP entry compressed size is inconsistent with its declared size: ${entry.name}`);
+ }
const { start } = await entryDataRange(zipPath, entry);
const handle = await open(zipPath, "r");
let compressed;
@@ -289,8 +378,8 @@ export async function readZipEntry(zipPath, entry, maxBytes) {
return output;
}
-function createVerifyTransform(entry) {
- const cap = Math.min(entry.uncompressedSize, MAX_STREAMED_ENTRY_BYTES);
+function createVerifyTransform(entry, onBytes = null) {
+ const cap = entry.uncompressedSize;
let total = 0;
let crcValue = 0xffffffff;
return new Transform({
@@ -300,6 +389,7 @@ function createVerifyTransform(entry) {
callback(new Error(`ZIP entry decompressed past declared size: ${entry.name}`));
return;
}
+ onBytes?.(total);
for (const byte of chunk) {
crcValue = crcTable[(crcValue ^ byte) & 0xff] ^ (crcValue >>> 8);
}
@@ -320,13 +410,26 @@ function createVerifyTransform(entry) {
});
}
-export async function streamZipEntry(zipPath, entry, writable, transform = null) {
+/**
+ * Streams one entry. `onProgress(bytes)` reports live decompressed bytes per
+ * chunk, and `onSoftLimit` fires (never aborting the stream) whenever the live
+ * count is above the current soft limit. `signal` is the only way to stop early.
+ */
+export async function streamZipEntry(
+ zipPath,
+ entry,
+ writable,
+ transform = null,
+ { onSoftLimit = null, softLimit = null, onProgress = null, signal = null } = {},
+) {
if (!entry?.supported) {
throw new Error(`ZIP entry cannot be served: ${entry?.name ?? "unknown"}`);
}
- if (entry.uncompressedSize > MAX_STREAMED_ENTRY_BYTES) {
- throw new Error("ZIP entry exceeds the 512 MiB streaming safety limit.");
- }
+ const onBytes = (bytes) => {
+ onProgress?.(bytes);
+ const limit = typeof softLimit === "function" ? softLimit() : softLimit;
+ if (limit != null && bytes > limit) onSoftLimit?.("streamedEntryBytes", bytes, entry.name);
+ };
if (entry.compressedSize === 0) {
writable.end();
return;
@@ -335,10 +438,121 @@ export async function streamZipEntry(zipPath, entry, writable, transform = null)
const source = createReadStream(zipPath, { start, end: endExclusive - 1 });
const streams = [source];
if (entry.method !== 0) streams.push(createInflateRaw());
- streams.push(createVerifyTransform(entry));
+ streams.push(createVerifyTransform(entry, onBytes));
if (transform) streams.push(transform);
streams.push(writable);
- await pipeline(...streams);
+ await pipeline(...streams, signal ? { signal } : {});
+}
+
+/**
+ * Verifies the whole entry (declared size and CRC-32) with bounded streaming and no
+ * buffering. Throws on any mismatch. Soft limits report but never stop the stream.
+ */
+export async function verifyZipEntry(
+ zipPath,
+ entry,
+ { onSoftLimit = null, softLimit = null, onProgress = null, signal = null } = {},
+) {
+ if (!entry?.supported) {
+ throw new Error(`ZIP entry cannot be served: ${entry?.name ?? "unknown"}`);
+ }
+ const onBytes = (bytes) => {
+ onProgress?.(bytes);
+ const limit = typeof softLimit === "function" ? softLimit() : softLimit;
+ if (limit != null && bytes > limit) onSoftLimit?.("streamedEntryBytes", bytes, entry.name);
+ };
+ if (entry.compressedSize === 0) {
+ if (entry.uncompressedSize !== 0 || entry.crc32 !== 0) {
+ throw new Error(`ZIP entry size check failed: ${entry.name}`);
+ }
+ return;
+ }
+ const { start, endExclusive } = await entryDataRange(zipPath, entry);
+ const streams = [createReadStream(zipPath, { start, end: endExclusive - 1 })];
+ if (entry.method !== 0) streams.push(createInflateRaw());
+ streams.push(createVerifyTransform(entry, onBytes));
+ streams.push(new Writable({ write(_chunk, _encoding, done) { done(); } }));
+ await pipeline(...streams, signal ? { signal } : {});
+}
+
+/**
+ * Streams the inclusive byte range [start, end] of an entry's decompressed content
+ * (for HTTP Range / media seeking). Stored entries seek directly inside the ZIP;
+ * deflated entries inflate and discard the leading bytes. A partial read cannot check the
+ * whole-entry CRC itself, so callers must run `verifyZipEntry` once first (the cache layer
+ * does, and remembers the result); path, host and token checks happen before this is called.
+ */
+export async function streamZipEntryRange(
+ zipPath,
+ entry,
+ writable,
+ { start, end },
+ { onProgress = null, onSoftLimit = null, softLimit = null, signal = null } = {},
+) {
+ if (!entry?.supported) {
+ throw new Error(`ZIP entry cannot be served: ${entry?.name ?? "unknown"}`);
+ }
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || end >= entry.uncompressedSize) {
+ throw new Error(`Invalid byte range for ${entry.name}.`);
+ }
+ const { start: dataStart, endExclusive } = await entryDataRange(zipPath, entry);
+ const stored = entry.method === 0;
+ const source = stored
+ ? createReadStream(zipPath, { start: dataStart + start, end: dataStart + end })
+ : createReadStream(zipPath, { start: dataStart, end: endExclusive - 1 });
+ const inflate = stored ? null : createInflateRaw();
+ let failure = null;
+ source.once("error", (error) => {
+ failure = error;
+ inflate?.destroy(error);
+ });
+ if (inflate) source.pipe(inflate);
+ const readable = inflate ?? source;
+ const onAbort = () => {
+ failure = signal.reason ?? new Error("Aborted");
+ readable.destroy(failure);
+ source.destroy();
+ };
+ signal?.addEventListener("abort", onAbort, { once: true });
+ let position = stored ? start : 0;
+ let closed = false;
+ writable.once("close", () => {
+ closed = true;
+ readable.destroy();
+ source.destroy();
+ });
+ try {
+ for await (const chunk of readable) {
+ if (closed) break;
+ const chunkStart = position;
+ position += chunk.length;
+ const limit = typeof softLimit === "function" ? softLimit() : softLimit;
+ onProgress?.(Math.min(position, end + 1));
+ if (limit != null && position > limit) onSoftLimit?.("streamedEntryBytes", position, entry.name);
+ if (position - 1 < start) continue;
+ const slice = chunk.subarray(Math.max(start, chunkStart) - chunkStart, Math.min(end + 1, position) - chunkStart);
+ if (slice.length && !writable.write(slice)) {
+ await new Promise((resolve) => {
+ const done = () => {
+ writable.off("drain", done);
+ writable.off("close", done);
+ resolve();
+ };
+ writable.once("drain", done);
+ writable.once("close", done);
+ });
+ }
+ if (position - 1 >= end) break;
+ }
+ } catch (error) {
+ if (!closed) throw failure ?? error;
+ } finally {
+ signal?.removeEventListener("abort", onAbort);
+ readable.destroy();
+ source.destroy();
+ }
+ if (failure && !closed) throw failure;
+ if (!closed) writable.end();
}
export async function readEntryPrefix(zipPath, entry, maxBytes) {
@@ -354,48 +568,31 @@ export async function readEntryPrefix(zipPath, entry, maxBytes) {
await handle.close();
}
}
- const handle = await open(zipPath, "r");
- let compressed;
- try {
- compressed = await readExactly(handle, entry.compressedSize, start);
- } finally {
- await handle.close();
- }
+ // Stream the compressed bytes so memory stays bounded regardless of entry size.
return new Promise((resolve, reject) => {
+ const source = createReadStream(zipPath, { start, end: start + entry.compressedSize - 1 });
const inflate = createInflateRaw();
const chunks = [];
let total = 0;
let done = false;
+ const finish = (error) => {
+ if (done) return;
+ done = true;
+ source.destroy();
+ inflate.destroy();
+ if (error) reject(error);
+ else resolve(Buffer.concat(chunks));
+ };
inflate.on("data", (chunk) => {
if (done) return;
- const remaining = maxBytes - total;
- if (remaining <= 0) {
- done = true;
- inflate.destroy();
- resolve(Buffer.concat(chunks));
- return;
- }
- const slice = remaining < chunk.length ? chunk.subarray(0, remaining) : chunk;
+ const slice = maxBytes - total < chunk.length ? chunk.subarray(0, maxBytes - total) : chunk;
chunks.push(slice);
total += slice.length;
- if (total >= maxBytes) {
- done = true;
- inflate.destroy();
- resolve(Buffer.concat(chunks));
- }
+ if (total >= maxBytes) finish();
});
- inflate.once("end", () => {
- if (!done) {
- done = true;
- resolve(Buffer.concat(chunks));
- }
- });
- inflate.once("error", (err) => {
- if (!done) {
- done = true;
- reject(err);
- }
- });
- inflate.end(compressed);
+ inflate.once("end", () => finish());
+ inflate.once("error", finish);
+ source.once("error", finish);
+ source.pipe(inflate);
});
-}
+}
\ No newline at end of file
diff --git a/extensions/pr-artifact-explorer/zip64.test.mjs b/extensions/pr-artifact-explorer/zip64.test.mjs
new file mode 100644
index 0000000000..ce64c69ef2
--- /dev/null
+++ b/extensions/pr-artifact-explorer/zip64.test.mjs
@@ -0,0 +1,93 @@
+import assert from "node:assert/strict";
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import test from "node:test";
+import { crc32 } from "node:zlib";
+import { readZipEntry, readZipIndex } from "./zip.mjs";
+
+function u16(v) { const b = Buffer.alloc(2); b.writeUInt16LE(v); return b; }
+function u32(v) { const b = Buffer.alloc(4); b.writeUInt32LE(v); return b; }
+function u64(v) { const b = Buffer.alloc(8); b.writeBigUInt64LE(BigInt(v)); return b; }
+
+// Builds a stored single-entry archive; zip64 uses sentinel fields plus ZIP64 extras and EOCD64.
+function buildZip({ name = "index.html", content = "hi ", zip64 = false, crc, dirOffset } = {}) {
+ const data = Buffer.from(content);
+ const nameBuf = Buffer.from(name);
+ const checksum = crc ?? crc32(data);
+ const local = Buffer.concat([
+ u32(0x04034b50), u16(zip64 ? 45 : 20), u16(0), u16(0), u16(0), u16(0x21),
+ u32(checksum), u32(data.length), u32(data.length), u16(nameBuf.length), u16(0), nameBuf, data,
+ ]);
+ const extra = zip64 ? Buffer.concat([u16(1), u16(24), u64(data.length), u64(data.length), u64(0)]) : Buffer.alloc(0);
+ const central = Buffer.concat([
+ u32(0x02014b50), u16(45), u16(zip64 ? 45 : 20), u16(0), u16(0), u16(0), u16(0x21),
+ u32(checksum), u32(zip64 ? 0xffffffff : data.length), u32(zip64 ? 0xffffffff : data.length),
+ u16(nameBuf.length), u16(extra.length), u16(0), u16(0), u16(0), u32(0),
+ u32(zip64 ? 0xffffffff : 0), nameBuf, extra,
+ ]);
+ const cdOffset = dirOffset ?? local.length;
+ if (!zip64) {
+ return Buffer.concat([local, central, u32(0x06054b50), u16(0), u16(0), u16(1), u16(1), u32(central.length), u32(cdOffset), u16(0)]);
+ }
+ const eocd64Offset = local.length + central.length;
+ const eocd64 = Buffer.concat([
+ u32(0x06064b50), u64(44), u16(45), u16(45), u32(0), u32(0), u64(1), u64(1), u64(central.length), u64(cdOffset),
+ ]);
+ const locator = Buffer.concat([u32(0x07064b50), u32(0), u64(eocd64Offset), u32(1)]);
+ return Buffer.concat([
+ local, central, eocd64, locator,
+ u32(0x06054b50), u16(0), u16(0), u16(0xffff), u16(0xffff), u32(0xffffffff), u32(0xffffffff), u16(0),
+ ]);
+}
+
+async function withZip(options, fn) {
+ const dir = await mkdtemp(join(tmpdir(), "zip64-"));
+ try {
+ const path = join(dir, "a.zip");
+ await writeFile(path, buildZip(options));
+ return await fn(path);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+}
+
+for (const zip64 of [false, true]) {
+ test(`reads a valid ${zip64 ? "ZIP64" : "ZIP32"} archive`, () =>
+ withZip({ zip64 }, async (path) => {
+ const index = await readZipIndex(path);
+ assert.equal(index.zip64, zip64);
+ assert.equal(index.entries.length, 1);
+ assert.equal((await readZipEntry(path, index.entries[0])).toString(), "hi ");
+ }));
+}
+
+test("CRC mismatch is a hard integrity failure", () =>
+ withZip({ zip64: true, crc: 1234 }, async (path) => {
+ const index = await readZipIndex(path);
+ await assert.rejects(readZipEntry(path, index.entries[0]), /checksum/);
+ }));
+
+test("path traversal is rejected", () =>
+ withZip({ zip64: true, name: "../escape.txt" }, async (path) => {
+ await assert.rejects(readZipIndex(path), /unsafe path/);
+ }));
+
+test("a central directory pointing outside the archive is rejected", () =>
+ withZip({ dirOffset: 99999 }, async (path) => {
+ await assert.rejects(readZipIndex(path), /outside the archive|central directory/i);
+ }));
+
+test("soft entry limit warns while indexing continues", () =>
+ withZip({ zip64: true }, async (path) => {
+ const warnings = [];
+ const index = await readZipIndex(path, {
+ softLimits: { zipEntries: 0 },
+ onSoftLimit: (kind, current) => warnings.push([kind, current]),
+ });
+ assert.equal(index.entries.length, 1);
+ assert.equal(warnings.length, 1);
+ assert.equal(warnings[0][0], "zipEntries");
+ assert.equal(warnings[0][1], index.entries.length);
+ assert.ok(warnings[0][1] > 0);
+ }));
diff --git a/plugins/pr-artifact-explorer/plugin.json b/plugins/pr-artifact-explorer/plugin.json
index 2841734f7b..bedb8d39de 100644
--- a/plugins/pr-artifact-explorer/plugin.json
+++ b/plugins/pr-artifact-explorer/plugin.json
@@ -20,6 +20,9 @@
"logo": "assets/preview.png"
},
"com.github.awesome-copilot": {
+ "skills": [
+ "./skills/pr-artifact-explorer/"
+ ],
"extensions": [
"./extensions/pr-artifact-explorer"
]
diff --git a/skills/pr-artifact-explorer/SKILL.md b/skills/pr-artifact-explorer/SKILL.md
new file mode 100644
index 0000000000..a0865df83c
--- /dev/null
+++ b/skills/pr-artifact-explorer/SKILL.md
@@ -0,0 +1,55 @@
+---
+name: pr-artifact-explorer
+description: '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.'
+---
+
+# Artifact Explorer
+
+Use the `pr-artifact-explorer` canvas to show a user pull requests, issues, Actions artifacts, and web pages.
+
+## How to drive the canvas
+
+The action names below (`open_target`, `share`, ...) are **canvas actions, not standalone tools**. Always:
+
+1. Call `list_canvas_capabilities { canvasId: "pr-artifact-explorer" }` to discover the actions and their schemas.
+2. Call `open_canvas { canvasId: "pr-artifact-explorer", instanceId: "" }` to open or focus the panel.
+3. Call `invoke_canvas_action { instanceId, actionName, input }` with that same `instanceId`.
+
+## Open a target
+
+`invoke_canvas_action` with `open_target { input }` (or the open input `repository`, `pullNumber`, `artifactId`, `url`, `route`, plus `preset`/`width`/`height`/`zoom`). `input` accepts:
+
+- GitHub PR/issue URLs or `owner/repo#123`; issues display details only, artifacts are PR-specific
+- real artifact permalinks (`https://github.com///actions/runs//artifacts/`), an artifact ID with a repository, or a shared explorer link/route such as `#/docs`, `#/artifact/owner%2Frepo/0/456?file=index.html`, `#/browser?v=...`
+- `http(s)://` and `localhost` URLs (browser preview)
+- bounded natural language: `open PR 123 in owner/repo`, `show localhost:3000 on mobile`, `go back`, `zoom 150%`, `tablet`, `1280x720`, `screenshot`
+
+Use `share { input, preset?, zoom?, width?, height? }` to produce a link, route, and text to give the user. Artifact shares keep repository/artifact/file identity (not preview ports); the recipient pastes it into the canvas or into `open_target`.
+
+## Artifacts
+
+`inspect_artifact { repository, artifactId }` starts a background job. Downloads continue past soft limits; when one is reached the user is prompted for a larger value (`set_limit { kind, value }`; the value must be at least the live progress). Check `job_status`; use `retry_job` after an `interrupted` or `failed` job (a completed ZIP is reused) and `cancel_job` only when asked. Never execute artifact programs or build scripts: artifacts are only inspected or previewed as static sites and files.
+
+Re-running the workflow that produced an artifact is a UI-only, user-confirmed action (the Re-run button on an artifact row). Never attempt it yourself or call its API; if the user wants a fresh artifact, tell them to use that button. It reuses the original commit, ref and actor, is limited to 30 days and 50 re-runs, and only requests a run, not a ready artifact.
+
+## Browser preview
+
+- `navigate_browser { url, preset?, zoom? }` opens an http(s) URL, including localhost.
+- `set_viewport { preset: native | mobile | tablet | desktop | custom, width?, height?, orientation? }` sets the emulated page size.
+- `set_zoom { zoom, fit? }` scales the view without changing the viewport.
+- `screenshot { mode?: "fresh" | "controlled", url?, preset?, width?, height?, fullPage? }` takes a real Chromium PNG saved under the session `files/artifact-explorer-screenshots` folder. `fresh` (default) renders the URL anew and is labeled `fresh-render`; `controlled` captures the live isolated Chromium page (`controlled-current`).
+- `controlled_navigate { url, preset?, ... }`, `controlled_back`, `controlled_forward`, `controlled_refresh`, `controlled_close` drive an isolated Chromium (in-memory profile, no login state) with real history; use it for sites that block iframes. Navigation failures are returned as errors, never as blank pages. If Playwright or Chromium is missing, relay the returned setup instructions to the user (including a `playwright_config_unsupported` error for the retired `PR_ARTIFACT_EXPLORER_PLAYWRIGHT` variable); never install silently.
+- Pages that forbid framing (X-Frame-Options / CSP) are reported and not bypassed.
+
+## Previewing files
+
+- 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 `` with range seeking (the whole entry is CRC/size verified once, then reused). Codec support is browser-dependent (WebM VP8/VP9 is most reliable; H.264 MP4/MOV may not play), so a failure shows an explanation and a download fallback.
+- A compact **Preview / Raw** toggle sits beside Download for every file (`&view=raw` in routes). Raw shows exact source for text and a bounded, labelled-partial hex+ASCII view for binary; unsafe/encrypted entries explain why and keep the ZIP download.
+- Browser chrome and owned artifact HTML follow the inherited canvas theme (initial and live changes); controlled Chromium and captures follow the current color scheme. External sites keep their own theme.
+- **Open in default browser** (Sharing and help menu) launches the current http(s) target in the OS browser. Owned artifact sites are served by this canvas's loopback server and stop when it closes.
+- JUnit XML (`testsuites`/`testsuite` roots) renders a native report with totals, status filters, search and failure/error/skipped/stdout/stderr details; the XML source and download stay available. Malformed reports fall back to source with an error; other XML uses the generic viewer.
+
+## Recovery
+
+If the canvas misbehaves, call `restart_canvas`: it keeps the port, token, and running jobs. Use `docs` to open the built-in Docs tab, which also lists keyboard shortcuts (press `?`).