chore: publish from main
@@ -67,4 +67,4 @@ ignore-words-list = numer,wit,aks,edn,ser,ois,gir,rouge,categor,aline,ative,afte
|
||||
# Skip certain files and directories
|
||||
# *.tm7 - MTM DataContract exports; embedded base64 icon blobs trigger false positives
|
||||
|
||||
skip = .git,node_modules,package-lock.json,*.lock,website/build,website/.docusaurus,.all-contributorrc,./skills/geofeed-tuner/assets/*.json,./skills/geofeed-tuner/references/*.txt,./plugins/fastah-ip-geo-tools/skills/geofeed-tuner/assets/*.json,./plugins/fastah-ip-geo-tools/skills/geofeed-tuner/references/*.txt,./extensions/arcade-canvas/game/phaser.min.js,*.tm7
|
||||
skip = .git,node_modules,package-lock.json,*.lock,website/build,website/.docusaurus,.all-contributorrc,./skills/geofeed-tuner/assets/*.json,./skills/geofeed-tuner/references/*.txt,./plugins/fastah-ip-geo-tools/skills/geofeed-tuner/assets/*.json,./plugins/fastah-ip-geo-tools/skills/geofeed-tuner/references/*.txt,./extensions/arcade-canvas/game/phaser.min.js,./extensions/pr-artifact-explorer/assets/asciinema-player.min.js,./extensions/pr-artifact-explorer/assets/asciinema-player-worker.min.js,./extensions/pr-artifact-explorer/assets/primer-*.css,*.tm7
|
||||
|
||||
@@ -1031,6 +1031,12 @@
|
||||
"description": "Complete toolkit for developing Power Platform custom connectors with Model Context Protocol integration for Microsoft Copilot Studio",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
{
|
||||
"name": "pr-artifact-explorer",
|
||||
"source": "extensions/pr-artifact-explorer",
|
||||
"description": "Navigate pull requests and securely explore GitHub Actions artifacts, including test results, static sites, terminal recordings, and source files.",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
{
|
||||
"name": "project-documenter",
|
||||
"source": "plugins/project-documenter",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "pr-artifact-explorer",
|
||||
"description": "Navigate pull requests and securely explore GitHub Actions artifacts, including test results, static sites, terminal recordings, and source files.",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "David Pine",
|
||||
"url": "https://github.com/IEvangelist"
|
||||
},
|
||||
"keywords": [
|
||||
"actions-artifacts",
|
||||
"artifact-browser",
|
||||
"canvas",
|
||||
"copilot-extension",
|
||||
"github-actions",
|
||||
"pull-requests",
|
||||
"test-results"
|
||||
],
|
||||
"logo": "assets/preview.png",
|
||||
"extensions": "extensions"
|
||||
}
|
||||
|
After Width: | Height: | Size: 82 KiB |
@@ -0,0 +1,66 @@
|
||||
# Artifact Explorer
|
||||
|
||||
> **GitHub, the missing parts:** GitHub Actions makes artifacts easy to produce, but pull-request reviewers still have to download opaque ZIP files and inspect them out of context. Artifact Explorer turns those artifacts into a fast, GitHub-native, navigable experience inside Copilot.
|
||||
|
||||
Artifact Explorer is a community-built Copilot canvas extension with the internal extension ID `pr-artifact-explorer`. It is not an official GitHub product or endorsed feature.
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="assets/preview-dark.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="assets/preview.png">
|
||||
<img alt="Artifact Explorer showing pull request workflow artifacts in a responsive GitHub-style interface" src="assets/preview.png">
|
||||
</picture>
|
||||
|
||||
## What it does
|
||||
|
||||
- Navigates repositories, pull requests, artifacts, and files with persistent breadcrumbs.
|
||||
- Loads pull requests progressively, caches result pages, and supports query filters for fast review.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## Security and local data
|
||||
|
||||
Artifact Explorer inherits eligible GitHub accounts from the Copilot app, GitHub CLI, or process environment. Tokens remain inside the extension process: the canvas receives only sanitized account metadata, while authenticated GitHub API requests are made by the local extension server.
|
||||
|
||||
Artifact ZIPs and preferences are stored under:
|
||||
|
||||
```text
|
||||
$COPILOT_HOME/extensions/pr-artifact-explorer/artifacts/
|
||||
```
|
||||
|
||||
When `COPILOT_HOME` is unset, it defaults to `~/.copilot`. Cached archives remain local until removed through the per-artifact or clear-all controls.
|
||||
|
||||
The canvas server validates its canonical loopback host and requires a per-instance capability token for API, artifact-content, and event requests. Static-site previews bind to a separate ephemeral `127.0.0.1` port, validate their loopback host, apply a restrictive content security policy, prevent path traversal, and stream files from the ZIP. A preview requires `index.html` at the artifact root. Static content is untrusted, remains sandboxed by the canvas, and has no access to GitHub tokens.
|
||||
|
||||
## Install
|
||||
|
||||
Ask Copilot to install the committed extension:
|
||||
|
||||
```text
|
||||
Install this extension: https://github.com/github/awesome-copilot/tree/main/extensions/pr-artifact-explorer
|
||||
```
|
||||
|
||||
You can also copy the folder to one of the supported extension locations:
|
||||
|
||||
- `~/.copilot/extensions/pr-artifact-explorer/` for user scope
|
||||
- `.github/extensions/pr-artifact-explorer/` for project scope
|
||||
|
||||
Reload extensions, then ask Copilot to open the `pr-artifact-explorer` canvas. You can optionally provide a repository (`owner/name`), pull request number, or GitHub pull request URL when opening it.
|
||||
|
||||
## 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.
|
||||
- `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.
|
||||
- `set_account { id }` - select the GitHub account used for repository and artifact requests.
|
||||
|
||||
## Included third-party components
|
||||
|
||||
The extension bundles Primer CSS, Octicons, and the asciinema player for offline rendering. Their notices are preserved in `third-party-licenses/`.
|
||||
@@ -0,0 +1,329 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { GITHUB_API, USER_AGENT } from "./constants.mjs";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const RESOLUTION_CACHE_LIMIT = 20;
|
||||
const SOURCE_PRIORITY = Object.freeze({ copilot: 30, gh: 20, env: 10 });
|
||||
const SOURCE_LABEL = Object.freeze({
|
||||
copilot: "Copilot app",
|
||||
gh: "GitHub CLI",
|
||||
env: "Environment",
|
||||
});
|
||||
|
||||
const cachedResolutions = new Map();
|
||||
|
||||
function cleanGhEnvironment() {
|
||||
const env = { ...process.env };
|
||||
delete env.GH_TOKEN;
|
||||
delete env.GITHUB_TOKEN;
|
||||
return env;
|
||||
}
|
||||
|
||||
function tokenHash(token) {
|
||||
return createHash("sha256").update(token).digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
function decodeCopilotLabel(variableName) {
|
||||
let encoded = variableName.replace(/^COPILOT_GH_ACCOUNT_/, "");
|
||||
encoded = encoded.replace(/_([0-9A-Fa-f]{2})_/g, (_, hex) =>
|
||||
String.fromCharCode(Number.parseInt(hex, 16)));
|
||||
return encoded.replace(/^github\.com_/, "") || null;
|
||||
}
|
||||
|
||||
async function listGhAccounts() {
|
||||
let output = "";
|
||||
try {
|
||||
const result = await execFileAsync("gh", ["auth", "status"], {
|
||||
timeout: 8_000,
|
||||
env: cleanGhEnvironment(),
|
||||
windowsHide: true,
|
||||
});
|
||||
output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") return [];
|
||||
output = `${error?.stdout ?? ""}\n${error?.stderr ?? ""}`;
|
||||
if (!output.trim()) return [];
|
||||
}
|
||||
|
||||
const accounts = [];
|
||||
const seen = new Set();
|
||||
const pattern = /Logged in to (\S+) account (\S+)/g;
|
||||
let match;
|
||||
while ((match = pattern.exec(output)) !== null) {
|
||||
const key = `${match[1].toLowerCase()}/${match[2].toLowerCase()}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
accounts.push({ host: match[1], login: match[2] });
|
||||
}
|
||||
}
|
||||
return accounts;
|
||||
}
|
||||
|
||||
async function readGhToken(host, login) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"gh",
|
||||
["auth", "token", "--hostname", host, "--user", login],
|
||||
{
|
||||
timeout: 8_000,
|
||||
env: cleanGhEnvironment(),
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
return stdout.trim() || null;
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") return null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function detectCandidates() {
|
||||
const candidates = [];
|
||||
const seenTokens = new Set();
|
||||
const add = (source, token, hintedLogin) => {
|
||||
if (!token) return;
|
||||
const hash = tokenHash(token);
|
||||
if (seenTokens.has(hash)) return;
|
||||
seenTokens.add(hash);
|
||||
candidates.push({ source, token, hash, hintedLogin: hintedLogin ?? null });
|
||||
};
|
||||
|
||||
for (const [name, value] of Object.entries(process.env)) {
|
||||
if (name.startsWith("COPILOT_GH_ACCOUNT_") && value) {
|
||||
add("copilot", value, decodeCopilotLabel(name));
|
||||
}
|
||||
}
|
||||
add("env", process.env.GH_TOKEN, null);
|
||||
add("env", process.env.GITHUB_TOKEN, null);
|
||||
|
||||
for (const account of await listGhAccounts()) {
|
||||
if (account.host.toLowerCase() !== "github.com") continue;
|
||||
add("gh", await readGhToken(account.host, account.login), account.login);
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
async function apiProbe(token, path) {
|
||||
const response = await fetch(`${GITHUB_API}${path}`, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"User-Agent": USER_AGENT,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
signal: AbortSignal.timeout(12_000),
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
return { response, payload };
|
||||
}
|
||||
|
||||
async function probeCandidate(candidate, repository) {
|
||||
const safe = {
|
||||
source: candidate.source,
|
||||
hash: candidate.hash,
|
||||
hintedLogin: candidate.hintedLogin,
|
||||
};
|
||||
|
||||
try {
|
||||
const viewer = await apiProbe(candidate.token, "/user");
|
||||
if (!viewer.response.ok || !viewer.payload?.login) {
|
||||
return {
|
||||
...safe,
|
||||
token: candidate.token,
|
||||
login: candidate.hintedLogin ?? "unknown",
|
||||
avatarUrl: null,
|
||||
status: "failed",
|
||||
repositoryAccess: false,
|
||||
reason: viewer.payload?.message ?? `Authentication failed (${viewer.response.status}).`,
|
||||
scopes: [],
|
||||
};
|
||||
}
|
||||
|
||||
let repositoryAccess = null;
|
||||
let repositoryReason = null;
|
||||
if (repository) {
|
||||
const access = await apiProbe(
|
||||
candidate.token,
|
||||
`/repos/${repository.split("/").map(encodeURIComponent).join("/")}`,
|
||||
);
|
||||
repositoryAccess = access.response.ok;
|
||||
if (!repositoryAccess) {
|
||||
repositoryReason = access.payload?.message ?? `Repository check failed (${access.response.status}).`;
|
||||
}
|
||||
}
|
||||
|
||||
const scopesHeader = viewer.response.headers.get("x-oauth-scopes") ?? "";
|
||||
return {
|
||||
...safe,
|
||||
token: candidate.token,
|
||||
login: viewer.payload.login,
|
||||
avatarUrl: viewer.payload.avatar_url ?? null,
|
||||
name: viewer.payload.name ?? null,
|
||||
profileUrl: viewer.payload.html_url ?? null,
|
||||
type: viewer.payload.type ?? null,
|
||||
company: viewer.payload.company ?? null,
|
||||
status: repositoryAccess === false ? "limited" : "ok",
|
||||
repositoryAccess,
|
||||
reason: repositoryReason,
|
||||
scopes: scopesHeader.split(",").map((scope) => scope.trim()).filter(Boolean),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...safe,
|
||||
token: candidate.token,
|
||||
login: candidate.hintedLogin ?? "unknown",
|
||||
avatarUrl: null,
|
||||
name: null,
|
||||
profileUrl: null,
|
||||
type: null,
|
||||
company: null,
|
||||
status: "failed",
|
||||
repositoryAccess: false,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
scopes: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function accountId(login) {
|
||||
return `acct:${String(login).toLowerCase()}`;
|
||||
}
|
||||
|
||||
function candidateScore(candidate) {
|
||||
let score = SOURCE_PRIORITY[candidate.source] ?? 0;
|
||||
if (candidate.status === "ok") score += 1_000;
|
||||
if (candidate.repositoryAccess === true) score += 10_000;
|
||||
if (candidate.status === "failed") score -= 100_000;
|
||||
return score;
|
||||
}
|
||||
|
||||
function publicAccount(account) {
|
||||
const { token, ...safe } = account;
|
||||
return safe;
|
||||
}
|
||||
|
||||
export function invalidateAccounts() {
|
||||
cachedResolutions.clear();
|
||||
}
|
||||
|
||||
async function resolveAccountsUncached(preferredId, repository) {
|
||||
const probes = await Promise.all(
|
||||
(await detectCandidates()).map((candidate) => probeCandidate(candidate, repository)),
|
||||
);
|
||||
const groups = new Map();
|
||||
for (const probe of probes) {
|
||||
const keyForLogin = String(probe.login).toLowerCase();
|
||||
if (!groups.has(keyForLogin)) groups.set(keyForLogin, []);
|
||||
groups.get(keyForLogin).push(probe);
|
||||
}
|
||||
|
||||
const accounts = [];
|
||||
for (const [loginKey, candidates] of groups) {
|
||||
candidates.sort((left, right) => candidateScore(right) - candidateScore(left));
|
||||
const best = candidates[0];
|
||||
accounts.push({
|
||||
id: accountId(loginKey),
|
||||
login: best.login,
|
||||
avatarUrl: best.avatarUrl ?? candidates.find((item) => item.avatarUrl)?.avatarUrl ?? null,
|
||||
name: best.name ?? candidates.find((item) => item.name)?.name ?? null,
|
||||
profileUrl:
|
||||
best.profileUrl ?? candidates.find((item) => item.profileUrl)?.profileUrl ?? null,
|
||||
type: best.type ?? candidates.find((item) => item.type)?.type ?? null,
|
||||
company: best.company ?? candidates.find((item) => item.company)?.company ?? null,
|
||||
status: best.status,
|
||||
repositoryAccess: best.repositoryAccess,
|
||||
reason: best.reason ?? null,
|
||||
scopes: best.scopes,
|
||||
sourceKinds: [...new Set(candidates.map((item) => item.source))],
|
||||
sources: candidates.map((item, index) => ({
|
||||
label: SOURCE_LABEL[item.source] ?? item.source,
|
||||
source: item.source,
|
||||
hash: item.hash,
|
||||
status: item.status,
|
||||
repositoryAccess: item.repositoryAccess,
|
||||
reason: item.reason ?? null,
|
||||
scopes: item.scopes,
|
||||
chosen: index === 0,
|
||||
})),
|
||||
token: best.token,
|
||||
});
|
||||
}
|
||||
accounts.sort((left, right) => {
|
||||
const leftAccess = left.repositoryAccess === true ? 1 : 0;
|
||||
const rightAccess = right.repositoryAccess === true ? 1 : 0;
|
||||
if (leftAccess !== rightAccess) return rightAccess - leftAccess;
|
||||
if (left.status !== right.status) return left.status === "ok" ? -1 : 1;
|
||||
return left.login.localeCompare(right.login);
|
||||
});
|
||||
|
||||
let active = preferredId
|
||||
? accounts.find((account) => account.id === preferredId && account.status !== "failed")
|
||||
: null;
|
||||
active ??= accounts.find((account) => account.repositoryAccess === true);
|
||||
active ??= accounts.find((account) => account.status === "ok");
|
||||
active ??= null;
|
||||
|
||||
const value = {
|
||||
active: active ? publicAccount(active) : null,
|
||||
activeToken: active?.token ?? null,
|
||||
accounts: accounts.map(publicAccount),
|
||||
};
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function resolveAccounts({
|
||||
preferredId = null,
|
||||
repository = null,
|
||||
force = false,
|
||||
} = {}) {
|
||||
const key = `${preferredId ?? ""}|${repository ?? ""}`;
|
||||
let entry = cachedResolutions.get(key);
|
||||
if (!force && entry?.value && Date.now() - entry.at < CACHE_TTL_MS) {
|
||||
cachedResolutions.delete(key);
|
||||
cachedResolutions.set(key, entry);
|
||||
return entry.value;
|
||||
}
|
||||
if (entry?.promise && (!force || entry.forceRefresh)) return entry.promise;
|
||||
|
||||
entry ??= {
|
||||
at: 0,
|
||||
forceRefresh: false,
|
||||
promise: null,
|
||||
value: null,
|
||||
};
|
||||
const promise = resolveAccountsUncached(preferredId, repository).then(
|
||||
(value) => {
|
||||
if (cachedResolutions.get(key) === entry && entry.promise === promise) {
|
||||
entry.at = Date.now();
|
||||
entry.forceRefresh = false;
|
||||
entry.promise = null;
|
||||
entry.value = value;
|
||||
cachedResolutions.delete(key);
|
||||
cachedResolutions.set(key, entry);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
(error) => {
|
||||
if (cachedResolutions.get(key) === entry && entry.promise === promise) {
|
||||
entry.forceRefresh = false;
|
||||
entry.promise = null;
|
||||
if (!entry.value || Date.now() - entry.at >= CACHE_TTL_MS) {
|
||||
cachedResolutions.delete(key);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
entry.forceRefresh = force;
|
||||
entry.promise = promise;
|
||||
cachedResolutions.set(key, entry);
|
||||
while (cachedResolutions.size > RESOLUTION_CACHE_LIMIT) {
|
||||
const candidate = [...cachedResolutions].find(([, value]) => !value.promise);
|
||||
if (!candidate) break;
|
||||
cachedResolutions.delete(candidate[0]);
|
||||
}
|
||||
return promise;
|
||||
}
|
||||
@@ -0,0 +1,762 @@
|
||||
.ap-default-term-ff {
|
||||
--term-font-family: "Cascadia Code", "Source Code Pro", Menlo, Consolas, "DejaVu Sans Mono", monospace, "Symbols Nerd Font";
|
||||
}
|
||||
div.ap-wrapper {
|
||||
outline: none;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
div.ap-wrapper .title-bar {
|
||||
display: none;
|
||||
top: -78px;
|
||||
transition: top 0.15s linear;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
box-sizing: content-box;
|
||||
font-size: 20px;
|
||||
line-height: 1em;
|
||||
padding: 15px;
|
||||
font-family: sans-serif;
|
||||
color: white;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
div.ap-wrapper .title-bar img {
|
||||
vertical-align: middle;
|
||||
height: 48px;
|
||||
margin-right: 16px;
|
||||
}
|
||||
div.ap-wrapper .title-bar a {
|
||||
color: white;
|
||||
text-decoration: underline;
|
||||
}
|
||||
div.ap-wrapper .title-bar a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
div.ap-wrapper:fullscreen {
|
||||
background-color: #000;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
div.ap-wrapper:fullscreen .title-bar {
|
||||
display: initial;
|
||||
}
|
||||
div.ap-wrapper:fullscreen.hud .title-bar {
|
||||
top: 0;
|
||||
}
|
||||
div.ap-wrapper div.ap-player {
|
||||
text-align: left;
|
||||
display: inline-block;
|
||||
padding: 0px;
|
||||
position: relative;
|
||||
box-sizing: content-box;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
border-radius: 4px;
|
||||
font-size: 15px;
|
||||
background-color: var(--term-color-background);
|
||||
}
|
||||
.ap-player {
|
||||
--term-color-foreground: #ffffff;
|
||||
--term-color-background: #000000;
|
||||
--term-color-0: var(--term-color-foreground);
|
||||
--term-color-1: var(--term-color-foreground);
|
||||
--term-color-2: var(--term-color-foreground);
|
||||
--term-color-3: var(--term-color-foreground);
|
||||
--term-color-4: var(--term-color-foreground);
|
||||
--term-color-5: var(--term-color-foreground);
|
||||
--term-color-6: var(--term-color-foreground);
|
||||
--term-color-7: var(--term-color-foreground);
|
||||
--term-color-8: var(--term-color-0);
|
||||
--term-color-9: var(--term-color-1);
|
||||
--term-color-10: var(--term-color-2);
|
||||
--term-color-11: var(--term-color-3);
|
||||
--term-color-12: var(--term-color-4);
|
||||
--term-color-13: var(--term-color-5);
|
||||
--term-color-14: var(--term-color-6);
|
||||
--term-color-15: var(--term-color-7);
|
||||
}
|
||||
div.ap-term {
|
||||
position: relative;
|
||||
font-family: var(--term-font-family);
|
||||
border-width: 0.75em;
|
||||
border-radius: 0;
|
||||
border-style: solid;
|
||||
border-color: var(--term-color-background);
|
||||
box-sizing: content-box;
|
||||
}
|
||||
div.ap-term canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
div.ap-term svg.ap-term-symbols {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
div.ap-term svg.ap-term-symbols use {
|
||||
color: var(--term-color-foreground);
|
||||
}
|
||||
div.ap-term svg.ap-term-symbols:not(.ap-blink) .ap-blink {
|
||||
opacity: 0;
|
||||
}
|
||||
div.ap-term pre.ap-term-text {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
box-sizing: content-box;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
margin: 0px;
|
||||
display: block;
|
||||
white-space: pre;
|
||||
word-wrap: normal;
|
||||
word-break: normal;
|
||||
cursor: text;
|
||||
color: var(--term-color-foreground);
|
||||
outline: none;
|
||||
line-height: var(--term-line-height);
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
font-variant-ligatures: none;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
pre.ap-term-text .ap-line {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: var(--term-line-height);
|
||||
position: absolute;
|
||||
top: calc(100% * var(--row) / var(--term-rows));
|
||||
letter-spacing: normal;
|
||||
overflow: hidden;
|
||||
}
|
||||
pre.ap-term-text .ap-line span {
|
||||
position: absolute;
|
||||
left: calc(100% * var(--offset) / var(--term-cols));
|
||||
padding: 0;
|
||||
display: inline-block;
|
||||
height: 100%;
|
||||
}
|
||||
pre.ap-term-text:not(.ap-blink) .ap-line .ap-blink {
|
||||
color: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
pre.ap-term-text .ap-bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
pre.ap-term-text .ap-faint {
|
||||
opacity: 0.5;
|
||||
}
|
||||
pre.ap-term-text .ap-underline {
|
||||
text-decoration: underline;
|
||||
}
|
||||
pre.ap-term-text .ap-italic {
|
||||
font-style: italic;
|
||||
}
|
||||
pre.ap-term-text .ap-strike {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.ap-line span {
|
||||
color: var(--term-color-foreground);
|
||||
}
|
||||
div.ap-player div.ap-control-bar {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: stretch;
|
||||
color: var(--term-color-foreground);
|
||||
box-sizing: content-box;
|
||||
line-height: 1;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s linear;
|
||||
user-select: none;
|
||||
border-top: 2px solid color-mix(in oklab, var(--term-color-background) 80%, var(--term-color-foreground));
|
||||
z-index: 30;
|
||||
}
|
||||
div.ap-player div.ap-control-bar * {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
div.ap-control-bar svg.ap-icon path {
|
||||
fill: var(--term-color-foreground);
|
||||
}
|
||||
div.ap-control-bar button.ap-button {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
div.ap-control-bar button.ap-playback-button {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
padding: 10px;
|
||||
margin: 0 0 0 2px;
|
||||
}
|
||||
div.ap-control-bar button.ap-playback-button svg {
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
}
|
||||
div.ap-control-bar span.ap-timer {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
min-width: 50px;
|
||||
margin: 0 10px;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
line-height: 100%;
|
||||
cursor: default;
|
||||
}
|
||||
div.ap-control-bar span.ap-timer span {
|
||||
font-family: var(--term-font-family);
|
||||
font-size: inherit;
|
||||
font-weight: 600;
|
||||
margin: auto;
|
||||
}
|
||||
div.ap-control-bar span.ap-timer .ap-time-remaining {
|
||||
display: none;
|
||||
}
|
||||
div.ap-control-bar span.ap-timer:hover .ap-time-elapsed {
|
||||
display: none;
|
||||
}
|
||||
div.ap-control-bar span.ap-timer:hover .ap-time-remaining {
|
||||
display: flex;
|
||||
}
|
||||
div.ap-control-bar .ap-progressbar {
|
||||
display: block;
|
||||
flex: 1 1 auto;
|
||||
height: 100%;
|
||||
padding: 0 10px;
|
||||
}
|
||||
div.ap-control-bar .ap-progressbar .ap-bar {
|
||||
display: block;
|
||||
position: relative;
|
||||
cursor: default;
|
||||
height: 100%;
|
||||
font-size: 0;
|
||||
}
|
||||
div.ap-control-bar .ap-progressbar .ap-bar .ap-gutter {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
}
|
||||
div.ap-control-bar .ap-progressbar .ap-bar .ap-gutter-empty {
|
||||
background-color: color-mix(in oklab, var(--term-color-foreground) 20%, var(--term-color-background));
|
||||
}
|
||||
div.ap-control-bar .ap-progressbar .ap-bar .ap-gutter-full {
|
||||
width: 100%;
|
||||
transform-origin: left center;
|
||||
background-color: var(--term-color-foreground);
|
||||
border-radius: 3px;
|
||||
}
|
||||
div.ap-control-bar.ap-seekable .ap-progressbar .ap-bar {
|
||||
cursor: pointer;
|
||||
}
|
||||
div.ap-control-bar button.ap-fullscreen-button {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
padding: 9px;
|
||||
margin: 0 2px 0 4px;
|
||||
}
|
||||
div.ap-control-bar button.ap-fullscreen-button svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
div.ap-control-bar button.ap-fullscreen-button svg.ap-icon-fullscreen-on {
|
||||
display: inline;
|
||||
}
|
||||
div.ap-control-bar button.ap-fullscreen-button svg.ap-icon-fullscreen-off {
|
||||
display: none;
|
||||
}
|
||||
div.ap-control-bar button.ap-fullscreen-button .ap-tooltip {
|
||||
right: 5px;
|
||||
left: initial;
|
||||
transform: none;
|
||||
}
|
||||
div.ap-control-bar button.ap-kbd-button {
|
||||
height: 14px;
|
||||
padding: 9px;
|
||||
margin: 0 0 0 4px;
|
||||
}
|
||||
div.ap-control-bar button.ap-kbd-button svg {
|
||||
width: 26px;
|
||||
height: 14px;
|
||||
}
|
||||
div.ap-control-bar button.ap-kbd-button .ap-tooltip {
|
||||
right: 5px;
|
||||
left: initial;
|
||||
transform: none;
|
||||
}
|
||||
div.ap-control-bar button.ap-speaker-button {
|
||||
width: 19px;
|
||||
padding: 6px 9px;
|
||||
margin: 0 0 0 4px;
|
||||
position: relative;
|
||||
}
|
||||
div.ap-control-bar button.ap-speaker-button svg {
|
||||
width: 19px;
|
||||
}
|
||||
div.ap-control-bar button.ap-speaker-button .ap-tooltip {
|
||||
left: -50%;
|
||||
transform: none;
|
||||
}
|
||||
div.ap-wrapper.ap-hud .ap-control-bar {
|
||||
opacity: 1;
|
||||
}
|
||||
div.ap-wrapper:fullscreen button.ap-fullscreen-button svg.ap-icon-fullscreen-on {
|
||||
display: none;
|
||||
}
|
||||
div.ap-wrapper:fullscreen button.ap-fullscreen-button svg.ap-icon-fullscreen-off {
|
||||
display: inline;
|
||||
}
|
||||
span.ap-progressbar span.ap-marker-container {
|
||||
display: block;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 21px;
|
||||
position: absolute;
|
||||
margin-left: -10px;
|
||||
}
|
||||
span.ap-marker-container span.ap-marker {
|
||||
display: block;
|
||||
top: 13px;
|
||||
bottom: 12px;
|
||||
left: 7px;
|
||||
right: 7px;
|
||||
background-color: color-mix(in oklab, var(--term-color-foreground) 33%, var(--term-color-background));
|
||||
position: absolute;
|
||||
transition: top 0.1s, bottom 0.1s, left 0.1s, right 0.1s, background-color 0.1s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
span.ap-marker-container span.ap-marker.ap-marker-past {
|
||||
background-color: var(--term-color-foreground);
|
||||
}
|
||||
span.ap-marker-container span.ap-marker:hover,
|
||||
span.ap-marker-container:hover span.ap-marker {
|
||||
background-color: var(--term-color-foreground);
|
||||
top: 11px;
|
||||
bottom: 10px;
|
||||
left: 5px;
|
||||
right: 5px;
|
||||
}
|
||||
.ap-tooltip-container span.ap-tooltip {
|
||||
visibility: hidden;
|
||||
background-color: var(--term-color-foreground);
|
||||
color: var(--term-color-background);
|
||||
font-family: var(--term-font-family);
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
padding: 0 0.5em;
|
||||
border-radius: 4px;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
white-space: nowrap;
|
||||
/* Prevents the text from wrapping and makes sure the tooltip width adapts to the text length */
|
||||
font-size: 13px;
|
||||
line-height: 2em;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.ap-tooltip-container:hover span.ap-tooltip {
|
||||
visibility: visible;
|
||||
}
|
||||
.ap-player .ap-overlay {
|
||||
z-index: 10;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.ap-player .ap-overlay-start {
|
||||
cursor: pointer;
|
||||
}
|
||||
.ap-player .ap-overlay-start .ap-play-button {
|
||||
font-size: 0px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
text-align: center;
|
||||
color: white;
|
||||
height: 80px;
|
||||
max-height: 66%;
|
||||
margin: auto;
|
||||
}
|
||||
.ap-player .ap-overlay-start .ap-play-button div {
|
||||
height: 100%;
|
||||
}
|
||||
.ap-player .ap-overlay-start .ap-play-button div span {
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
.ap-player .ap-overlay-start .ap-play-button div span svg {
|
||||
height: 100%;
|
||||
display: inline-block;
|
||||
}
|
||||
.ap-player .ap-overlay-start .ap-play-button svg {
|
||||
filter: drop-shadow(0px 0px 5px rgba(0, 0, 0, 0.4));
|
||||
}
|
||||
.ap-player .ap-overlay-loading .ap-loader {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
border: 10px solid;
|
||||
border-color: rgba(255, 255, 255, 0.3) rgba(255, 255, 255, 0.5) rgba(255, 255, 255, 0.7) #ffffff;
|
||||
border-color: color-mix(in srgb, var(--term-color-foreground) 30%, var(--term-color-background)) color-mix(in srgb, var(--term-color-foreground) 50%, var(--term-color-background)) color-mix(in srgb, var(--term-color-foreground) 70%, var(--term-color-background)) color-mix(in srgb, var(--term-color-foreground) 100%, var(--term-color-background));
|
||||
box-sizing: border-box;
|
||||
animation: ap-loader-rotation 1s linear infinite;
|
||||
}
|
||||
.ap-player .ap-overlay-info {
|
||||
background-color: var(--term-color-background);
|
||||
}
|
||||
.ap-player .ap-overlay-info span {
|
||||
font-family: var(--term-font-family);
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
color: var(--term-color-background);
|
||||
background-color: var(--term-color-foreground);
|
||||
padding: 0.5em 0.75em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.ap-player .ap-overlay-keystrokes {
|
||||
position: absolute;
|
||||
top: auto;
|
||||
left: auto;
|
||||
right: 20px;
|
||||
bottom: var(--ap-keystrokes-bottom);
|
||||
z-index: 20;
|
||||
width: auto;
|
||||
height: auto;
|
||||
font-size: 18px;
|
||||
text-align: right;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 0.35em;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ap-player .ap-overlay-keystrokes .ap-keystroke-pill {
|
||||
font-family: var(--term-font-family);
|
||||
font-size: inherit;
|
||||
opacity: 1;
|
||||
transition: opacity 80ms ease;
|
||||
}
|
||||
.ap-player .ap-overlay-keystrokes .ap-keystroke-pill kbd {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
min-width: 1.2em;
|
||||
color: var(--term-color-background);
|
||||
background-color: var(--term-color-foreground);
|
||||
padding: 0.3em 0.5em;
|
||||
border-radius: 0.2em;
|
||||
font-family: inherit;
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
margin: 0;
|
||||
}
|
||||
.ap-player .ap-overlay-keystrokes .ap-keystroke-pill.fading {
|
||||
opacity: 0;
|
||||
transition: opacity 700ms ease;
|
||||
}
|
||||
.ap-player .ap-overlay-help {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
container-type: inline-size;
|
||||
}
|
||||
.ap-player .ap-overlay-help > div {
|
||||
font-family: var(--term-font-family);
|
||||
max-width: 85%;
|
||||
max-height: 85%;
|
||||
font-size: 18px;
|
||||
color: var(--term-color-foreground);
|
||||
box-sizing: border-box;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.ap-player .ap-overlay-help > div div {
|
||||
padding: calc(min(4cqw, 40px));
|
||||
font-size: calc(min(1.9cqw, 18px));
|
||||
background-color: var(--term-color-background);
|
||||
border: 1px solid color-mix(in oklab, var(--term-color-background) 90%, var(--term-color-foreground));
|
||||
border-radius: 6px;
|
||||
}
|
||||
.ap-player .ap-overlay-help > div div p {
|
||||
font-weight: bold;
|
||||
margin: 0 0 2em 0;
|
||||
}
|
||||
.ap-player .ap-overlay-help > div div ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
.ap-player .ap-overlay-help > div div ul li {
|
||||
margin: 0 0 0.75em 0;
|
||||
}
|
||||
.ap-player .ap-overlay-help > div div kbd {
|
||||
color: var(--term-color-background);
|
||||
background-color: var(--term-color-foreground);
|
||||
padding: 0.2em 0.5em;
|
||||
border-radius: 0.2em;
|
||||
font-family: inherit;
|
||||
font-size: 0.85em;
|
||||
border: none;
|
||||
margin: 0;
|
||||
}
|
||||
.ap-player .ap-overlay-error span {
|
||||
font-size: 8em;
|
||||
}
|
||||
.ap-player .slide-enter-active {
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.ap-player .slide-enter-active.ap-was-playing {
|
||||
transition: top 0.2s ease-out, opacity 0.2s;
|
||||
}
|
||||
.ap-player .slide-exit-active {
|
||||
transition: top 0.2s ease-in, opacity 0.2s;
|
||||
}
|
||||
.ap-player .slide-enter {
|
||||
top: -50%;
|
||||
opacity: 0;
|
||||
}
|
||||
.ap-player .slide-enter-to {
|
||||
top: 0%;
|
||||
}
|
||||
.ap-player .slide-enter,
|
||||
.ap-player .slide-enter-to,
|
||||
.ap-player .slide-exit,
|
||||
.ap-player .slide-exit-to {
|
||||
bottom: auto;
|
||||
height: 100%;
|
||||
}
|
||||
.ap-player .slide-exit {
|
||||
top: 0%;
|
||||
}
|
||||
.ap-player .slide-exit-to {
|
||||
top: -50%;
|
||||
opacity: 0;
|
||||
}
|
||||
@keyframes ap-loader-rotation {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.asciinema-player-theme-asciinema {
|
||||
--term-color-foreground: #cccccc;
|
||||
--term-color-background: #121314;
|
||||
--term-color-0: #000000;
|
||||
--term-color-1: #dd3c69;
|
||||
--term-color-2: #4ebf22;
|
||||
--term-color-3: #ddaf3c;
|
||||
--term-color-4: #26b0d7;
|
||||
--term-color-5: #b954e1;
|
||||
--term-color-6: #54e1b9;
|
||||
--term-color-7: #d9d9d9;
|
||||
--term-color-8: #4d4d4d;
|
||||
--term-color-9: #dd3c69;
|
||||
--term-color-10: #4ebf22;
|
||||
--term-color-11: #ddaf3c;
|
||||
--term-color-12: #26b0d7;
|
||||
--term-color-13: #b954e1;
|
||||
--term-color-14: #54e1b9;
|
||||
--term-color-15: #ffffff;
|
||||
}
|
||||
/*
|
||||
Based on Dracula: https://draculatheme.com
|
||||
*/
|
||||
.asciinema-player-theme-dracula {
|
||||
--term-color-foreground: #f8f8f2;
|
||||
--term-color-background: #282a36;
|
||||
--term-color-0: #21222c;
|
||||
--term-color-1: #ff5555;
|
||||
--term-color-2: #50fa7b;
|
||||
--term-color-3: #f1fa8c;
|
||||
--term-color-4: #bd93f9;
|
||||
--term-color-5: #ff79c6;
|
||||
--term-color-6: #8be9fd;
|
||||
--term-color-7: #f8f8f2;
|
||||
--term-color-8: #6272a4;
|
||||
--term-color-9: #ff6e6e;
|
||||
--term-color-10: #69ff94;
|
||||
--term-color-11: #ffffa5;
|
||||
--term-color-12: #d6acff;
|
||||
--term-color-13: #ff92df;
|
||||
--term-color-14: #a4ffff;
|
||||
--term-color-15: #ffffff;
|
||||
}
|
||||
/* Based on Monokai from base16 collection - https://github.com/chriskempson/base16 */
|
||||
.asciinema-player-theme-monokai {
|
||||
--term-color-foreground: #f8f8f2;
|
||||
--term-color-background: #272822;
|
||||
--term-color-0: #272822;
|
||||
--term-color-1: #f92672;
|
||||
--term-color-2: #a6e22e;
|
||||
--term-color-3: #f4bf75;
|
||||
--term-color-4: #66d9ef;
|
||||
--term-color-5: #ae81ff;
|
||||
--term-color-6: #a1efe4;
|
||||
--term-color-7: #f8f8f2;
|
||||
--term-color-8: #75715e;
|
||||
--term-color-15: #f9f8f5;
|
||||
}
|
||||
/*
|
||||
Based on Nord: https://github.com/arcticicestudio/nord
|
||||
Via: https://github.com/neilotoole/asciinema-theme-nord
|
||||
*/
|
||||
.asciinema-player-theme-nord {
|
||||
--term-color-foreground: #eceff4;
|
||||
--term-color-background: #2e3440;
|
||||
--term-color-0: #3b4252;
|
||||
--term-color-1: #bf616a;
|
||||
--term-color-2: #a3be8c;
|
||||
--term-color-3: #ebcb8b;
|
||||
--term-color-4: #81a1c1;
|
||||
--term-color-5: #b48ead;
|
||||
--term-color-6: #88c0d0;
|
||||
--term-color-7: #eceff4;
|
||||
}
|
||||
.asciinema-player-theme-seti {
|
||||
--term-color-foreground: #cacecd;
|
||||
--term-color-background: #111213;
|
||||
--term-color-0: #323232;
|
||||
--term-color-1: #c22832;
|
||||
--term-color-2: #8ec43d;
|
||||
--term-color-3: #e0c64f;
|
||||
--term-color-4: #43a5d5;
|
||||
--term-color-5: #8b57b5;
|
||||
--term-color-6: #8ec43d;
|
||||
--term-color-7: #eeeeee;
|
||||
--term-color-15: #ffffff;
|
||||
}
|
||||
/*
|
||||
Based on Solarized Dark: https://ethanschoonover.com/solarized/
|
||||
*/
|
||||
.asciinema-player-theme-solarized-dark {
|
||||
--term-color-foreground: #839496;
|
||||
--term-color-background: #002b36;
|
||||
--term-color-0: #073642;
|
||||
--term-color-1: #dc322f;
|
||||
--term-color-2: #859900;
|
||||
--term-color-3: #b58900;
|
||||
--term-color-4: #268bd2;
|
||||
--term-color-5: #d33682;
|
||||
--term-color-6: #2aa198;
|
||||
--term-color-7: #eee8d5;
|
||||
--term-color-8: #002b36;
|
||||
--term-color-9: #cb4b16;
|
||||
--term-color-10: #586e75;
|
||||
--term-color-11: #657b83;
|
||||
--term-color-12: #839496;
|
||||
--term-color-13: #6c71c4;
|
||||
--term-color-14: #93a1a1;
|
||||
--term-color-15: #fdf6e3;
|
||||
}
|
||||
/*
|
||||
Based on Solarized Light: https://ethanschoonover.com/solarized/
|
||||
*/
|
||||
.asciinema-player-theme-solarized-light {
|
||||
--term-color-foreground: #657b83;
|
||||
--term-color-background: #fdf6e3;
|
||||
--term-color-0: #073642;
|
||||
--term-color-1: #dc322f;
|
||||
--term-color-2: #859900;
|
||||
--term-color-3: #b58900;
|
||||
--term-color-4: #268bd2;
|
||||
--term-color-5: #d33682;
|
||||
--term-color-6: #2aa198;
|
||||
--term-color-7: #eee8d5;
|
||||
--term-color-8: #002b36;
|
||||
--term-color-9: #cb4b16;
|
||||
--term-color-10: #586e75;
|
||||
--term-color-11: #657c83;
|
||||
--term-color-12: #839496;
|
||||
--term-color-13: #6c71c4;
|
||||
--term-color-14: #93a1a1;
|
||||
--term-color-15: #fdf6e3;
|
||||
}
|
||||
.asciinema-player-theme-solarized-light .ap-overlay-start .ap-play-button svg .ap-play-btn-fill {
|
||||
fill: var(--term-color-1);
|
||||
}
|
||||
.asciinema-player-theme-solarized-light .ap-overlay-start .ap-play-button svg .ap-play-btn-stroke {
|
||||
stroke: var(--term-color-1);
|
||||
}
|
||||
/*
|
||||
Based on Tango: https://en.wikipedia.org/wiki/Tango_Desktop_Project
|
||||
*/
|
||||
.asciinema-player-theme-tango {
|
||||
--term-color-foreground: #cccccc;
|
||||
--term-color-background: #121314;
|
||||
--term-color-0: #000000;
|
||||
--term-color-1: #cc0000;
|
||||
--term-color-2: #4e9a06;
|
||||
--term-color-3: #c4a000;
|
||||
--term-color-4: #3465a4;
|
||||
--term-color-5: #75507b;
|
||||
--term-color-6: #06989a;
|
||||
--term-color-7: #d3d7cf;
|
||||
--term-color-8: #555753;
|
||||
--term-color-9: #ef2929;
|
||||
--term-color-10: #8ae234;
|
||||
--term-color-11: #fce94f;
|
||||
--term-color-12: #729fcf;
|
||||
--term-color-13: #ad7fa8;
|
||||
--term-color-14: #34e2e2;
|
||||
--term-color-15: #eeeeec;
|
||||
}
|
||||
/*
|
||||
Based on gruvbox: https://github.com/morhetz/gruvbox
|
||||
*/
|
||||
.asciinema-player-theme-gruvbox-dark {
|
||||
--term-color-foreground: #fbf1c7;
|
||||
--term-color-background: #282828;
|
||||
--term-color-0: #282828;
|
||||
--term-color-1: #cc241d;
|
||||
--term-color-2: #98971a;
|
||||
--term-color-3: #d79921;
|
||||
--term-color-4: #458588;
|
||||
--term-color-5: #b16286;
|
||||
--term-color-6: #689d6a;
|
||||
--term-color-7: #a89984;
|
||||
--term-color-8: #7c6f65;
|
||||
--term-color-9: #fb4934;
|
||||
--term-color-10: #b8bb26;
|
||||
--term-color-11: #fabd2f;
|
||||
--term-color-12: #83a598;
|
||||
--term-color-13: #d3869b;
|
||||
--term-color-14: #8ec07c;
|
||||
--term-color-15: #fbf1c7;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M0 2.75C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75v1.5A1.75 1.75 0 0 1 14.25 6H1.75A1.75 1.75 0 0 1 0 4.25ZM1.75 7a.75.75 0 0 1 .75.75v5.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-5.5a.75.75 0 0 1 1.5 0v5.5A1.75 1.75 0 0 1 13.25 15H2.75A1.75 1.75 0 0 1 1 13.25v-5.5A.75.75 0 0 1 1.75 7Zm0-4.5a.25.25 0 0 0-.25.25v1.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-1.5a.25.25 0 0 0-.25-.25ZM6.25 8h3.5a.75.75 0 0 1 0 1.5h-3.5a.75.75 0 0 1 0-1.5Z"/></svg>
|
||||
|
After Width: | Height: | Size: 560 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M0 2.75C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 14.25 15H1.75A1.75 1.75 0 0 1 0 13.25ZM14.5 6h-13v7.25c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25Zm-6-3.5v2h6V2.75a.25.25 0 0 0-.25-.25ZM5 2.5v2h2v-2Zm-3.25 0a.25.25 0 0 0-.25.25V4.5h2v-2Z"/></svg>
|
||||
|
After Width: | Height: | Size: 373 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm1.5 0a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm10.28-1.72-4.5 4.5a.75.75 0 0 1-1.06 0l-2-2a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018l1.47 1.47 3.97-3.97a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Z"/></svg>
|
||||
|
After Width: | Height: | Size: 346 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"/></svg>
|
||||
|
After Width: | Height: | Size: 264 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M12.78 5.22a.749.749 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.06 0L3.22 6.28a.749.749 0 1 1 1.06-1.06L8 8.939l3.72-3.719a.749.749 0 0 1 1.06 0Z"/></svg>
|
||||
|
After Width: | Height: | Size: 244 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M9.78 12.78a.75.75 0 0 1-1.06 0L4.47 8.53a.75.75 0 0 1 0-1.06l4.25-4.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042L6.06 8l3.72 3.72a.75.75 0 0 1 0 1.06Z"/></svg>
|
||||
|
After Width: | Height: | Size: 260 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M6.22 3.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L9.94 8 6.22 4.28a.75.75 0 0 1 0-1.06Z"/></svg>
|
||||
|
After Width: | Height: | Size: 261 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm7-3.25v2.992l2.028.812a.75.75 0 0 1-.557 1.392l-2.5-1A.751.751 0 0 1 7 8.25v-3.5a.75.75 0 0 1 1.5 0Z"/></svg>
|
||||
|
After Width: | Height: | Size: 281 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="m11.28 3.22 4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L13.94 8l-3.72-3.72a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215Zm-6.56 0a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042L2.06 8l3.72 3.72a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L.47 8.53a.75.75 0 0 1 0-1.06Z"/></svg>
|
||||
|
After Width: | Height: | Size: 427 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 12H9.06l-2.573 2.573A1.458 1.458 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"/></svg>
|
||||
|
After Width: | Height: | Size: 435 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14Z"/><path d="M7.25 7.689V2a.75.75 0 0 1 1.5 0v5.689l1.97-1.969a.749.749 0 1 1 1.06 1.06l-3.25 3.25a.749.749 0 0 1-1.06 0L4.22 6.78a.749.749 0 1 1 1.06-1.06l1.97 1.969Z"/></svg>
|
||||
|
After Width: | Height: | Size: 427 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8 2c1.981 0 3.671.992 4.933 2.078 1.27 1.091 2.187 2.345 2.637 3.023a1.62 1.62 0 0 1 0 1.798c-.45.678-1.367 1.932-2.637 3.023C11.67 13.008 9.981 14 8 14c-1.981 0-3.671-.992-4.933-2.078C1.797 10.83.88 9.576.43 8.898a1.62 1.62 0 0 1 0-1.798c.45-.677 1.367-1.931 2.637-3.022C4.33 2.992 6.019 2 8 2ZM1.679 7.932a.12.12 0 0 0 0 .136c.411.622 1.241 1.75 2.366 2.717C5.176 11.758 6.527 12.5 8 12.5c1.473 0 2.825-.742 3.955-1.715 1.124-.967 1.954-2.096 2.366-2.717a.12.12 0 0 0 0-.136c-.412-.621-1.242-1.75-2.366-2.717C10.824 4.242 9.473 3.5 8 3.5c-1.473 0-2.825.742-3.955 1.715-1.124.967-1.954 2.096-2.366 2.717ZM8 10a2 2 0 1 1-.001-3.999A2 2 0 0 1 8 10Z"/></svg>
|
||||
|
After Width: | Height: | Size: 749 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M4 1.75C4 .784 4.784 0 5.75 0h5.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v8.586A1.75 1.75 0 0 1 14.25 15h-9a.75.75 0 0 1 0-1.5h9a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 10 4.25V1.5H5.75a.25.25 0 0 0-.25.25v2a.75.75 0 0 1-1.5 0Zm-4 6C0 6.784.784 6 1.75 6h1.5C4.216 6 5 6.784 5 7.75v2.5A1.75 1.75 0 0 1 3.25 12h-1.5A1.75 1.75 0 0 1 0 10.25ZM6.75 6h1.5a.75.75 0 0 1 .75.75v3.75h.75a.75.75 0 0 1 0 1.5h-3a.75.75 0 0 1 0-1.5h.75v-3h-.75a.75.75 0 0 1 0-1.5Zm-5 1.5a.25.25 0 0 0-.25.25v2.5c0 .138.112.25.25.25h1.5a.25.25 0 0 0 .25-.25v-2.5a.25.25 0 0 0-.25-.25Zm9.75-5.938V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z"/></svg>
|
||||
|
After Width: | Height: | Size: 763 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M4 1.75C4 .784 4.784 0 5.75 0h5.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v8.586A1.75 1.75 0 0 1 14.25 15h-9a.75.75 0 0 1 0-1.5h9a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 10 4.25V1.5H5.75a.25.25 0 0 0-.25.25v2.5a.75.75 0 0 1-1.5 0Zm1.72 4.97a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1 0 1.06l-2 2a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l1.47-1.47-1.47-1.47a.75.75 0 0 1 0-1.06ZM3.28 7.78 1.81 9.25l1.47 1.47a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-2-2a.75.75 0 0 1 0-1.06l2-2a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Zm8.22-6.218V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z"/></svg>
|
||||
|
After Width: | Height: | Size: 748 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M0 2.75C0 1.784.784 1 1.75 1H5c.55 0 1.07.26 1.4.7l.9 1.2a.25.25 0 0 0 .2.1h6.75c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 15H1.75A1.75 1.75 0 0 1 0 13.25Zm1.75-.25a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25H7.5c-.55 0-1.07-.26-1.4-.7l-.9-1.2a.25.25 0 0 0-.2-.1Z"/></svg>
|
||||
|
After Width: | Height: | Size: 427 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M16 13.25A1.75 1.75 0 0 1 14.25 15H1.75A1.75 1.75 0 0 1 0 13.25V2.75C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75ZM1.75 2.5a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h.94l.03-.03 6.077-6.078a1.75 1.75 0 0 1 2.412-.06L14.5 10.31V2.75a.25.25 0 0 0-.25-.25Zm12.5 11a.25.25 0 0 0 .25-.25v-.917l-4.298-3.889a.25.25 0 0 0-.344.009L4.81 13.5ZM7 6a2 2 0 1 1-3.999.001A2 2 0 0 1 7 6ZM5.5 6a.5.5 0 1 0-1 0 .5.5 0 0 0 1 0Z"/></svg>
|
||||
|
After Width: | Height: | Size: 521 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M3.5 1.75v11.5c0 .09.048.173.126.217a.75.75 0 0 1-.752 1.298A1.748 1.748 0 0 1 2 13.25V1.75C2 .784 2.784 0 3.75 0h5.586c.464 0 .909.185 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v8.586A1.75 1.75 0 0 1 12.25 15h-.5a.75.75 0 0 1 0-1.5h.5a.25.25 0 0 0 .25-.25V4.664a.25.25 0 0 0-.073-.177L9.513 1.573a.25.25 0 0 0-.177-.073H7.25a.75.75 0 0 1 0 1.5h-.5a.75.75 0 0 1 0-1.5h-3a.25.25 0 0 0-.25.25Zm3.75 8.75h.5c.966 0 1.75.784 1.75 1.75v3a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1-.75-.75v-3c0-.966.784-1.75 1.75-1.75ZM6 5.25a.75.75 0 0 1 .75-.75h.5a.75.75 0 0 1 0 1.5h-.5A.75.75 0 0 1 6 5.25Zm.75 2.25h.5a.75.75 0 0 1 0 1.5h-.5a.75.75 0 0 1 0-1.5ZM8 6.75A.75.75 0 0 1 8.75 6h.5a.75.75 0 0 1 0 1.5h-.5A.75.75 0 0 1 8 6.75ZM8.75 3h.5a.75.75 0 0 1 0 1.5h-.5a.75.75 0 0 1 0-1.5ZM8 9.75A.75.75 0 0 1 8.75 9h.5a.75.75 0 0 1 0 1.5h-.5A.75.75 0 0 1 8 9.75Zm-1 2.5v2.25h1v-2.25a.25.25 0 0 0-.25-.25h-.5a.25.25 0 0 0-.25.25Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1012 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z"/></svg>
|
||||
|
After Width: | Height: | Size: 451 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M.75 3h14.5a.75.75 0 0 1 0 1.5H.75a.75.75 0 0 1 0-1.5ZM3 7.75A.75.75 0 0 1 3.75 7h8.5a.75.75 0 0 1 0 1.5h-8.5A.75.75 0 0 1 3 7.75Zm3 4a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z"/></svg>
|
||||
|
After Width: | Height: | Size: 307 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8 0a8.2 8.2 0 0 1 .701.031C9.444.095 9.99.645 10.16 1.29l.288 1.107c.018.066.079.158.212.224.231.114.454.243.668.386.123.082.233.09.299.071l1.103-.303c.644-.176 1.392.021 1.82.63.27.385.506.792.704 1.218.315.675.111 1.422-.364 1.891l-.814.806c-.049.048-.098.147-.088.294.016.257.016.515 0 .772-.01.147.038.246.088.294l.814.806c.475.469.679 1.216.364 1.891a7.977 7.977 0 0 1-.704 1.217c-.428.61-1.176.807-1.82.63l-1.102-.302c-.067-.019-.177-.011-.3.071a5.909 5.909 0 0 1-.668.386c-.133.066-.194.158-.211.224l-.29 1.106c-.168.646-.715 1.196-1.458 1.26a8.006 8.006 0 0 1-1.402 0c-.743-.064-1.289-.614-1.458-1.26l-.289-1.106c-.018-.066-.079-.158-.212-.224a5.738 5.738 0 0 1-.668-.386c-.123-.082-.233-.09-.299-.071l-1.103.303c-.644.176-1.392-.021-1.82-.63a8.12 8.12 0 0 1-.704-1.218c-.315-.675-.111-1.422.363-1.891l.815-.806c.05-.048.098-.147.088-.294a6.214 6.214 0 0 1 0-.772c.01-.147-.038-.246-.088-.294l-.815-.806C.635 6.045.431 5.298.746 4.623a7.92 7.92 0 0 1 .704-1.217c.428-.61 1.176-.807 1.82-.63l1.102.302c.067.019.177.011.3-.071.214-.143.437-.272.668-.386.133-.066.194-.158.211-.224l.29-1.106C6.009.645 6.556.095 7.299.03 7.53.01 7.764 0 8 0Zm-.571 1.525c-.036.003-.108.036-.137.146l-.289 1.105c-.147.561-.549.967-.998 1.189-.173.086-.34.183-.5.29-.417.278-.97.423-1.529.27l-1.103-.303c-.109-.03-.175.016-.195.045-.22.312-.412.644-.573.99-.014.031-.021.11.059.19l.815.806c.411.406.562.957.53 1.456a4.709 4.709 0 0 0 0 .582c.032.499-.119 1.05-.53 1.456l-.815.806c-.081.08-.073.159-.059.19.162.346.353.677.573.989.02.03.085.076.195.046l1.102-.303c.56-.153 1.113-.008 1.53.27.161.107.328.204.501.29.447.222.85.629.997 1.189l.289 1.105c.029.109.101.143.137.146a6.6 6.6 0 0 0 1.142 0c.036-.003.108-.036.137-.146l.289-1.105c.147-.561.549-.967.998-1.189.173-.086.34-.183.5-.29.417-.278.97-.423 1.529-.27l1.103.303c.109.029.175-.016.195-.045.22-.313.411-.644.573-.99.014-.031.021-.11-.059-.19l-.815-.806c-.411-.406-.562-.957-.53-1.456a4.709 4.709 0 0 0 0-.582c-.032-.499.119-1.05.53-1.456l.815-.806c.081-.08.073-.159.059-.19a6.464 6.464 0 0 0-.573-.989c-.02-.03-.085-.076-.195-.046l-1.102.303c-.56.153-1.113.008-1.53-.27a4.44 4.44 0 0 0-.501-.29c-.447-.222-.85-.629-.997-1.189l-.289-1.105c-.029-.11-.101-.143-.137-.146a6.6 6.6 0 0 0-1.142 0ZM11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM9.5 8a1.5 1.5 0 1 0-3.001.001A1.5 1.5 0 0 0 9.5 8Z"/></svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z"/></svg>
|
||||
|
After Width: | Height: | Size: 513 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8 9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM1.5 9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Zm13 0a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"/></svg>
|
||||
|
After Width: | Height: | Size: 227 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M3.75 2h3.5a.75.75 0 0 1 0 1.5h-3.5a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-3.5a.75.75 0 0 1 1.5 0v3.5A1.75 1.75 0 0 1 12.25 14h-8.5A1.75 1.75 0 0 1 2 12.25v-8.5C2 2.784 2.784 2 3.75 2Zm6.854-1h4.146a.25.25 0 0 1 .25.25v4.146a.25.25 0 0 1-.427.177L13.03 4.03 9.28 7.78a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l3.75-3.75-1.543-1.543A.25.25 0 0 1 10.604 1Z"/></svg>
|
||||
|
After Width: | Height: | Size: 495 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M6.766 11.328c-2.063-.25-3.516-1.734-3.516-3.656 0-.781.281-1.625.75-2.188-.203-.515-.172-1.609.063-2.062.625-.078 1.468.25 1.968.703.594-.187 1.219-.281 1.985-.281.765 0 1.39.094 1.953.265.484-.437 1.344-.765 1.969-.687.218.422.25 1.515.046 2.047.5.593.766 1.39.766 2.203 0 1.922-1.453 3.375-3.547 3.64.531.344.89 1.094.89 1.954v1.625c0 .468.391.734.86.547C13.781 14.359 16 11.53 16 8.03 16 3.61 12.406 0 7.984 0 3.563 0 0 3.61 0 8.031a7.88 7.88 0 0 0 5.172 7.422c.422.156.828-.125.828-.547v-1.25c-.219.094-.5.156-.75.156-1.031 0-1.64-.562-2.078-1.609-.172-.422-.36-.672-.719-.719-.187-.015-.25-.093-.25-.187 0-.188.313-.328.625-.328.453 0 .844.281 1.25.86.313.452.64.655 1.031.655s.641-.14 1-.5c.266-.265.47-.5.657-.656"/></svg>
|
||||
|
After Width: | Height: | Size: 822 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M14.85 3c.63 0 1.15.52 1.14 1.15v7.7c0 .63-.51 1.15-1.15 1.15H1.15C.52 13 0 12.48 0 11.84V4.15C0 3.52.52 3 1.15 3ZM9 11V5H7L5.5 7 4 5H2v6h2V8l1.5 1.92L7 8v3Zm2.99.5L14.5 8H13V5h-2v3H9.5Z"/></svg>
|
||||
|
After Width: | Height: | Size: 287 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="m8.878.392 5.25 3.045c.54.314.872.89.872 1.514v6.098a1.75 1.75 0 0 1-.872 1.514l-5.25 3.045a1.75 1.75 0 0 1-1.756 0l-5.25-3.045A1.75 1.75 0 0 1 1 11.049V4.951c0-.624.332-1.201.872-1.514L7.122.392a1.75 1.75 0 0 1 1.756 0ZM7.875 1.69l-4.63 2.685L8 7.133l4.755-2.758-4.63-2.685a.248.248 0 0 0-.25 0ZM2.5 5.677v5.372c0 .09.047.171.125.216l4.625 2.683V8.432Zm6.25 8.271 4.625-2.683a.25.25 0 0 0 .125-.216V5.677L8.75 8.432Z"/></svg>
|
||||
|
After Width: | Height: | Size: 518 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M10.561 8.073a6.005 6.005 0 0 1 3.432 5.142.75.75 0 1 1-1.498.07 4.5 4.5 0 0 0-8.99 0 .75.75 0 0 1-1.498-.07 6.004 6.004 0 0 1 3.431-5.142 3.999 3.999 0 1 1 5.123 0ZM10.5 5a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"/></svg>
|
||||
|
After Width: | Height: | Size: 310 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="m1.655.595 13.75 13.75q.22.219.22.53 0 .311-.22.53-.219.22-.53.22-.311 0-.53-.22L.595 1.655q-.22-.219-.22-.53 0-.311.22-.53.219-.22.53-.22.311 0 .53.22ZM.72 14.22l4.5-4.5q.219-.22.53-.22.311 0 .53.22.22.219.22.53 0 .311-.22.53l-4.5 4.5q-.219.22-.53.22-.311 0-.53-.22-.22-.219-.22-.53 0-.311.22-.53Z"/><path d="m5.424 6.146-1.759.419q-.143.034-.183.175-.04.141.064.245l5.469 5.469q.104.104.245.064.141-.04.175-.183l.359-1.509q.072-.302.337-.465.264-.163.567-.091.302.072.465.337.162.264.09.567l-.359 1.509q-.238.999-1.226 1.278-.988.28-1.714-.446L2.485 8.046q-.726-.726-.446-1.714.279-.988 1.278-1.226l1.759-.419q.303-.072.567.091.265.163.337.465.072.302-.091.567-.163.264-.465.336ZM7.47 3.47q.155-.156.247-.355l.751-1.627Q8.851.659 9.75.498q.899-.16 1.544.486l3.722 3.722q.646.645.486 1.544-.161.899-.99 1.282l-1.627.751q-.199.092-.355.247-.219.22-.53.22-.311 0-.53-.22-.22-.219-.22-.53 0-.311.22-.53.344-.345.787-.549l1.627-.751q.118-.055.141-.183.023-.128-.069-.221l-3.722-3.722q-.092-.092-.221-.069-.128.023-.183.141l-.751 1.627q-.204.443-.549.787-.219.22-.53.22-.311 0-.53-.22-.22-.219-.22-.53 0-.311.22-.53Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="m11.294.984 3.722 3.722a1.75 1.75 0 0 1-.504 2.826l-1.327.613a3.089 3.089 0 0 0-1.707 2.084l-.584 2.454c-.317 1.332-1.972 1.8-2.94.832L5.75 11.311 1.78 15.28a.749.749 0 1 1-1.06-1.06l3.969-3.97-2.204-2.204c-.968-.968-.5-2.623.832-2.94l2.454-.584a3.08 3.08 0 0 0 2.084-1.707l.613-1.327a1.75 1.75 0 0 1 2.826-.504ZM6.283 9.723l2.732 2.731a.25.25 0 0 0 .42-.119l.584-2.454a4.586 4.586 0 0 1 2.537-3.098l1.328-.613a.25.25 0 0 0 .072-.404l-3.722-3.722a.25.25 0 0 0-.404.072l-.613 1.328a4.584 4.584 0 0 1-3.098 2.537l-2.454.584a.25.25 0 0 0-.119.42l2.731 2.732Z"/></svg>
|
||||
|
After Width: | Height: | Size: 657 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm4.879-2.773 4.264 2.559a.25.25 0 0 1 0 .428l-4.264 2.559A.25.25 0 0 1 6 10.559V5.442a.25.25 0 0 1 .379-.215Z"/></svg>
|
||||
|
After Width: | Height: | Size: 289 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8ZM5 12.25a.25.25 0 0 1 .25-.25h3.5a.25.25 0 0 1 .25.25v3.25a.25.25 0 0 1-.4.2l-1.45-1.087a.249.249 0 0 0-.3 0L5.4 15.7a.25.25 0 0 1-.4-.2Z"/></svg>
|
||||
|
After Width: | Height: | Size: 474 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/></svg>
|
||||
|
After Width: | Height: | Size: 277 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M6.823 7.823a.25.25 0 0 1 0 .354l-2.396 2.396A.25.25 0 0 1 4 10.396V5.604a.25.25 0 0 1 .427-.177Z"/><path d="M1.75 0h12.5C15.216 0 16 .784 16 1.75v12.5A1.75 1.75 0 0 1 14.25 16H1.75A1.75 1.75 0 0 1 0 14.25V1.75C0 .784.784 0 1.75 0ZM1.5 1.75v12.5c0 .138.112.25.25.25H9.5v-13H1.75a.25.25 0 0 0-.25.25ZM11 14.5h3.25a.25.25 0 0 0 .25-.25V1.75a.25.25 0 0 0-.25-.25H11Z"/></svg>
|
||||
|
After Width: | Height: | Size: 465 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="m4.177 7.823 2.396-2.396A.25.25 0 0 1 7 5.604v4.792a.25.25 0 0 1-.427.177L4.177 8.177a.25.25 0 0 1 0-.354Z"/><path d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v12.5A1.75 1.75 0 0 1 14.25 16H1.75A1.75 1.75 0 0 1 0 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25H9.5v-13Zm12.5 13a.25.25 0 0 0 .25-.25V1.75a.25.25 0 0 0-.25-.25H11v13Z"/></svg>
|
||||
|
After Width: | Height: | Size: 462 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Z"/></svg>
|
||||
|
After Width: | Height: | Size: 350 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Zm0 2.445L6.615 5.5a.75.75 0 0 1-.564.41l-3.097.45 2.24 2.184a.75.75 0 0 1 .216.664l-.528 3.084 2.769-1.456a.75.75 0 0 1 .698 0l2.77 1.456-.53-3.084a.75.75 0 0 1 .216-.664l2.24-2.183-3.096-.45a.75.75 0 0 1-.564-.41L8 2.694Z"/></svg>
|
||||
|
After Width: | Height: | Size: 572 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M1.705 8.005a.75.75 0 0 1 .834.656 5.5 5.5 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7.002 7.002 0 0 1 1.05 8.84a.75.75 0 0 1 .656-.834ZM8 2.5a5.487 5.487 0 0 0-4.131 1.869l1.204 1.204A.25.25 0 0 1 4.896 6H1.25A.25.25 0 0 1 1 5.75V2.104a.25.25 0 0 1 .427-.177l1.38 1.38A7.002 7.002 0 0 1 14.95 7.16a.75.75 0 0 1-1.49.178A5.5 5.5 0 0 0 8 2.5Z"/></svg>
|
||||
|
After Width: | Height: | Size: 518 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M0 2.75C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 14.25 15H1.75A1.75 1.75 0 0 1 0 13.25Zm1.75-.25a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25V2.75a.25.25 0 0 0-.25-.25ZM7.25 8a.749.749 0 0 1-.22.53l-2.25 2.25a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L5.44 8 3.72 6.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.25 2.25c.141.14.22.331.22.53Zm1.5 1.5h3a.75.75 0 0 1 0 1.5h-3a.75.75 0 0 1 0-1.5Z"/></svg>
|
||||
|
After Width: | Height: | Size: 567 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M11 1.75V3h2.25a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75ZM4.496 6.675l.66 6.6a.25.25 0 0 0 .249.225h5.19a.25.25 0 0 0 .249-.225l.66-6.6a.75.75 0 0 1 1.492.149l-.66 6.6A1.748 1.748 0 0 1 10.595 15h-5.19a1.75 1.75 0 0 1-1.741-1.575l-.66-6.6a.75.75 0 1 1 1.492-.15ZM6.5 1.75V3h3V1.75a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25Z"/></svg>
|
||||
|
After Width: | Height: | Size: 488 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M0 1.75C0 .784.784 0 1.75 0h3.5C6.216 0 7 .784 7 1.75v3.5A1.75 1.75 0 0 1 5.25 7H4v4a1 1 0 0 0 1 1h4v-1.25C9 9.784 9.784 9 10.75 9h3.5c.966 0 1.75.784 1.75 1.75v3.5A1.75 1.75 0 0 1 14.25 16h-3.5A1.75 1.75 0 0 1 9 14.25v-.75H5A2.5 2.5 0 0 1 2.5 11V7h-.75A1.75 1.75 0 0 1 0 5.25Zm1.75-.25a.25.25 0 0 0-.25.25v3.5c0 .138.112.25.25.25h3.5a.25.25 0 0 0 .25-.25v-3.5a.25.25 0 0 0-.25-.25Zm9 9a.25.25 0 0 0-.25.25v3.5c0 .138.112.25.25.25h3.5a.25.25 0 0 0 .25-.25v-3.5a.25.25 0 0 0-.25-.25Z"/></svg>
|
||||
|
After Width: | Height: | Size: 583 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M2.344 2.343h-.001a8 8 0 0 1 11.314 11.314A8.002 8.002 0 0 1 .234 10.089a8 8 0 0 1 2.11-7.746Zm1.06 10.253a6.5 6.5 0 1 0 9.108-9.275 6.5 6.5 0 0 0-9.108 9.275ZM6.03 4.97 8 6.94l1.97-1.97a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l1.97 1.97a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-1.97 1.97a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L6.94 8 4.97 6.03a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018Z"/></svg>
|
||||
|
After Width: | Height: | Size: 539 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z"/></svg>
|
||||
|
After Width: | Height: | Size: 371 B |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 82 KiB |
@@ -0,0 +1,768 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const MAX_RENDERED_RESULTS = 2_000;
|
||||
const MAX_DETAIL_CHARS = 24_000;
|
||||
const MAX_HIGHLIGHTED_RAW_CHARS = 4 * 1024 * 1024;
|
||||
const rawSources = new WeakMap();
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function icon(name) {
|
||||
const safeName = /^[a-z-]+$/.test(name) ? name : "file";
|
||||
return `<span class="octicon icon-${safeName}" aria-hidden="true"></span>`;
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime())
|
||||
? ""
|
||||
: new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function elements(parent, localName) {
|
||||
if (!parent?.getElementsByTagName) return [];
|
||||
return [...parent.getElementsByTagName("*")].filter(
|
||||
(element) => element.localName === localName,
|
||||
);
|
||||
}
|
||||
|
||||
function firstElement(parent, localName) {
|
||||
return elements(parent, localName)[0] ?? null;
|
||||
}
|
||||
|
||||
function directChild(parent, localName) {
|
||||
return (
|
||||
[...(parent?.children ?? [])].find(
|
||||
(element) => element.localName === localName,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function elementText(element) {
|
||||
return String(element?.textContent ?? "").trim();
|
||||
}
|
||||
|
||||
function attribute(element, name) {
|
||||
return String(element?.getAttribute(name) ?? "").trim();
|
||||
}
|
||||
|
||||
function outcomeCategory(outcome) {
|
||||
const normalized = String(outcome ?? "")
|
||||
.toLocaleLowerCase()
|
||||
.replace(/[^a-z]/g, "");
|
||||
if (normalized === "passed") return "passed";
|
||||
if (
|
||||
[
|
||||
"failed",
|
||||
"error",
|
||||
"timeout",
|
||||
"aborted",
|
||||
"passedbutrunaborted",
|
||||
"disconnected",
|
||||
].includes(normalized)
|
||||
) {
|
||||
return "failed";
|
||||
}
|
||||
if (
|
||||
["notexecuted", "notrunnable", "inconclusive", "pending"].includes(
|
||||
normalized,
|
||||
)
|
||||
) {
|
||||
return "skipped";
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
|
||||
function outcomeIcon(category) {
|
||||
if (category === "passed") return "check-circle";
|
||||
if (category === "failed") return "x-circle";
|
||||
if (category === "skipped") return "clock";
|
||||
return "workflow";
|
||||
}
|
||||
|
||||
const KNOWN_TRX_OUTCOMES = new Set([
|
||||
"passed", "failed", "completed", "error", "warning",
|
||||
"inconclusive", "aborted", "timeout", "inprogress", "notexecuted",
|
||||
]);
|
||||
|
||||
function summarizedRunOutcome(report) {
|
||||
const normalized = String(report.outcome ?? "").toLowerCase();
|
||||
if (!KNOWN_TRX_OUTCOMES.has(normalized) || normalized === "completed") {
|
||||
if (report.summaryCounts.failed > 0) return "Failed";
|
||||
if (report.summaryCounts.passed > 0) return "Passed";
|
||||
if (report.summaryCounts.skipped > 0) return "Skipped";
|
||||
}
|
||||
return report.outcome;
|
||||
}
|
||||
|
||||
function parseDuration(value) {
|
||||
const duration = String(value ?? "").trim();
|
||||
if (!duration) return null;
|
||||
|
||||
const clock = duration.match(
|
||||
/^(?:(\d+)\.)?(\d{1,2}):(\d{2}):(\d{2}(?:\.\d+)?)$/,
|
||||
);
|
||||
if (clock) {
|
||||
const [, days = "0", hours, minutes, seconds] = clock;
|
||||
return (
|
||||
Number(days) * 86_400_000 +
|
||||
Number(hours) * 3_600_000 +
|
||||
Number(minutes) * 60_000 +
|
||||
Number(seconds) * 1_000
|
||||
);
|
||||
}
|
||||
|
||||
const iso = duration.match(
|
||||
/^P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/i,
|
||||
);
|
||||
if (!iso) return null;
|
||||
const [, days = "0", hours = "0", minutes = "0", seconds = "0"] = iso;
|
||||
return (
|
||||
Number(days) * 86_400_000 +
|
||||
Number(hours) * 3_600_000 +
|
||||
Number(minutes) * 60_000 +
|
||||
Number(seconds) * 1_000
|
||||
);
|
||||
}
|
||||
|
||||
function formatDuration(durationMs) {
|
||||
if (!Number.isFinite(durationMs)) return "";
|
||||
if (durationMs < 1_000) {
|
||||
return `${Math.max(0, Math.round(durationMs))} ms`;
|
||||
}
|
||||
if (durationMs < 60_000) {
|
||||
const seconds = durationMs / 1_000;
|
||||
return `${seconds
|
||||
.toFixed(seconds < 10 ? 2 : 1)
|
||||
.replace(/\.0+$|(\.\d*[1-9])0+$/, "$1")} s`;
|
||||
}
|
||||
if (durationMs < 3_600_000) {
|
||||
const minutes = Math.floor(durationMs / 60_000);
|
||||
const seconds = Math.floor((durationMs % 60_000) / 1_000);
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
const hours = Math.floor(durationMs / 3_600_000);
|
||||
const minutes = Math.floor((durationMs % 3_600_000) / 60_000);
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
function parseReport(value) {
|
||||
const source = String(value ?? "");
|
||||
if (/<!DOCTYPE|<!ENTITY/i.test(source)) {
|
||||
throw new Error(
|
||||
"DTD and entity declarations are not supported in test result previews.",
|
||||
);
|
||||
}
|
||||
|
||||
const documentNode = new DOMParser().parseFromString(
|
||||
source,
|
||||
"application/xml",
|
||||
);
|
||||
const parserError = [...documentNode.getElementsByTagName("*")].find(
|
||||
(element) => element.localName?.toLocaleLowerCase() === "parsererror",
|
||||
);
|
||||
if (parserError) {
|
||||
const detail = elementText(parserError).replace(/\s+/g, " ").slice(0, 240);
|
||||
throw new Error(detail || "The XML document is malformed.");
|
||||
}
|
||||
|
||||
const root = documentNode.documentElement;
|
||||
if (root?.localName !== "TestRun") {
|
||||
throw new Error(
|
||||
"The XML document does not contain a TRX TestRun root element.",
|
||||
);
|
||||
}
|
||||
|
||||
const definitions = new Map();
|
||||
for (const unitTest of elements(root, "UnitTest")) {
|
||||
const testId = attribute(unitTest, "id").toLocaleLowerCase();
|
||||
if (!testId) continue;
|
||||
const method = firstElement(unitTest, "TestMethod");
|
||||
definitions.set(testId, {
|
||||
name: attribute(unitTest, "name"),
|
||||
className: attribute(method, "className"),
|
||||
methodName: attribute(method, "name"),
|
||||
codeBase: attribute(method, "codeBase"),
|
||||
adapterTypeName: attribute(method, "adapterTypeName"),
|
||||
});
|
||||
}
|
||||
|
||||
const testLists = new Map();
|
||||
for (const testList of elements(root, "TestList")) {
|
||||
const id = attribute(testList, "id").toLocaleLowerCase();
|
||||
if (id) testLists.set(id, attribute(testList, "name"));
|
||||
}
|
||||
|
||||
const results = [];
|
||||
const resultsElement = directChild(root, "Results");
|
||||
const isResultElement = (element) =>
|
||||
element.localName?.endsWith("TestResult") ||
|
||||
element.localName === "TestResultAggregation";
|
||||
|
||||
const readResult = (element, depth) => {
|
||||
const testId = attribute(element, "testId").toLocaleLowerCase();
|
||||
const definition = definitions.get(testId) ?? {};
|
||||
const output = directChild(element, "Output");
|
||||
const errorInfo = firstElement(output, "ErrorInfo");
|
||||
const outcome = attribute(element, "outcome") || "Unknown";
|
||||
const testListId = attribute(element, "testListId").toLocaleLowerCase();
|
||||
const durationValue = attribute(element, "duration");
|
||||
const resultFiles = elements(output, "ResultFile")
|
||||
.map((file) => attribute(file, "path"))
|
||||
.filter(Boolean);
|
||||
const properties = elements(output, "Property")
|
||||
.map((property) => {
|
||||
const key =
|
||||
elementText(directChild(property, "Key")) ||
|
||||
attribute(property, "name");
|
||||
const propertyValue =
|
||||
elementText(directChild(property, "Value")) ||
|
||||
attribute(property, "value");
|
||||
return key ? { key, value: propertyValue } : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
depth,
|
||||
outcome,
|
||||
category: outcomeCategory(outcome),
|
||||
testName:
|
||||
attribute(element, "testName") ||
|
||||
definition.name ||
|
||||
definition.methodName ||
|
||||
"Unnamed test",
|
||||
className: definition.className || attribute(element, "className"),
|
||||
methodName: definition.methodName,
|
||||
codeBase: definition.codeBase,
|
||||
adapterTypeName: definition.adapterTypeName,
|
||||
computerName: attribute(element, "computerName"),
|
||||
startTime: attribute(element, "startTime"),
|
||||
endTime: attribute(element, "endTime"),
|
||||
durationMs: parseDuration(durationValue),
|
||||
executionId: attribute(element, "executionId"),
|
||||
testId: attribute(element, "testId"),
|
||||
testListName: testLists.get(testListId) ?? "",
|
||||
dataRowInfo: attribute(element, "dataRowInfo"),
|
||||
message: elementText(firstElement(errorInfo, "Message")),
|
||||
stackTrace: elementText(firstElement(errorInfo, "StackTrace")),
|
||||
stdout: elementText(firstElement(output, "StdOut")),
|
||||
stderr: elementText(firstElement(output, "StdErr")),
|
||||
debugTrace: elementText(firstElement(output, "DebugTrace")),
|
||||
resultFiles,
|
||||
properties,
|
||||
};
|
||||
};
|
||||
|
||||
const visitResults = (parent, depth = 0) => {
|
||||
for (const element of parent?.children ?? []) {
|
||||
if (!isResultElement(element)) continue;
|
||||
results.push(readResult(element, depth));
|
||||
const innerResults = directChild(element, "InnerResults");
|
||||
if (innerResults) visitResults(innerResults, depth + 1);
|
||||
}
|
||||
};
|
||||
visitResults(resultsElement);
|
||||
|
||||
const countersElement = firstElement(root, "Counters");
|
||||
const counters = {};
|
||||
for (const counterAttribute of countersElement?.attributes ?? []) {
|
||||
const number = Number(counterAttribute.value);
|
||||
if (Number.isFinite(number) && number >= 0) {
|
||||
counters[counterAttribute.name] = number;
|
||||
}
|
||||
}
|
||||
|
||||
const resultCounts = results.reduce(
|
||||
(counts, result) => {
|
||||
counts[result.category] += 1;
|
||||
return counts;
|
||||
},
|
||||
{ passed: 0, failed: 0, skipped: 0, other: 0 },
|
||||
);
|
||||
const sumCounters = (names, fallback) => {
|
||||
const values = names
|
||||
.filter((name) => Number.isFinite(counters[name]))
|
||||
.map((name) => counters[name]);
|
||||
return values.length > 0
|
||||
? values.reduce((total, number) => total + number, 0)
|
||||
: fallback;
|
||||
};
|
||||
|
||||
const times = firstElement(root, "Times");
|
||||
const startTime = attribute(times, "start");
|
||||
const finishTime = attribute(times, "finish");
|
||||
const startTimestamp = new Date(startTime).getTime();
|
||||
const finishTimestamp = new Date(finishTime).getTime();
|
||||
const durationMs =
|
||||
Number.isFinite(startTimestamp) &&
|
||||
Number.isFinite(finishTimestamp) &&
|
||||
finishTimestamp >= startTimestamp
|
||||
? finishTimestamp - startTimestamp
|
||||
: null;
|
||||
const summaryElement = firstElement(root, "ResultSummary");
|
||||
|
||||
return {
|
||||
name: attribute(root, "name") || "Test run",
|
||||
runId: attribute(root, "id"),
|
||||
user: attribute(root, "runUser"),
|
||||
computerName: attribute(root, "computerName"),
|
||||
outcome: attribute(summaryElement, "outcome") || "Unknown",
|
||||
startTime,
|
||||
finishTime,
|
||||
durationMs,
|
||||
resultCounts,
|
||||
summaryCounts: {
|
||||
total: Number.isFinite(counters.total) ? counters.total : results.length,
|
||||
passed: sumCounters(["passed"], resultCounts.passed),
|
||||
failed: sumCounters(
|
||||
[
|
||||
"failed",
|
||||
"error",
|
||||
"timeout",
|
||||
"aborted",
|
||||
"passedButRunAborted",
|
||||
"disconnected",
|
||||
],
|
||||
resultCounts.failed,
|
||||
),
|
||||
skipped: sumCounters(
|
||||
["notExecuted", "notRunnable", "inconclusive", "pending"],
|
||||
resultCounts.skipped,
|
||||
),
|
||||
},
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
function renderMetadataItem(label, value, { mono = false } = {}) {
|
||||
if (!value) return "";
|
||||
return `
|
||||
<div class="trx-meta-item">
|
||||
<dt>${escapeHtml(label)}</dt>
|
||||
<dd${mono ? ' class="mono"' : ""}>${escapeHtml(value)}</dd>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function truncateDetail(value) {
|
||||
const text = String(value ?? "");
|
||||
if (text.length <= MAX_DETAIL_CHARS) {
|
||||
return { text, truncated: false };
|
||||
}
|
||||
return {
|
||||
text: text.slice(0, MAX_DETAIL_CHARS),
|
||||
truncated: true,
|
||||
};
|
||||
}
|
||||
|
||||
function renderDetailBlock(label, value) {
|
||||
if (!value) return "";
|
||||
const detail = truncateDetail(value);
|
||||
return `
|
||||
<section class="trx-output-block">
|
||||
<h4>${escapeHtml(label)}</h4>
|
||||
<pre>${escapeHtml(detail.text)}</pre>
|
||||
${
|
||||
detail.truncated
|
||||
? `<p class="trx-truncation-note">Output truncated after ${MAX_DETAIL_CHARS.toLocaleString()} characters. View the raw XML for the full value.</p>`
|
||||
: ""
|
||||
}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderResult(result, open = false) {
|
||||
const secondaryParts = [];
|
||||
if (result.className) secondaryParts.push(result.className);
|
||||
if (result.dataRowInfo) secondaryParts.push(`Data row ${result.dataRowInfo}`);
|
||||
if (result.category === "failed" && result.message) {
|
||||
secondaryParts.push(
|
||||
result.message.length > 180
|
||||
? `${result.message.slice(0, 179)}...`
|
||||
: result.message,
|
||||
);
|
||||
}
|
||||
const searchValue = [
|
||||
result.testName,
|
||||
result.className,
|
||||
result.methodName,
|
||||
result.outcome,
|
||||
result.message,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLocaleLowerCase()
|
||||
.slice(0, 2_000);
|
||||
const resultDuration = formatDuration(result.durationMs);
|
||||
const metadata = [
|
||||
renderMetadataItem("Class", result.className, { mono: true }),
|
||||
renderMetadataItem("Method", result.methodName, { mono: true }),
|
||||
renderMetadataItem("Duration", resultDuration),
|
||||
renderMetadataItem("Computer", result.computerName),
|
||||
renderMetadataItem(
|
||||
"Started",
|
||||
result.startTime ? formatDate(result.startTime) : "",
|
||||
),
|
||||
renderMetadataItem(
|
||||
"Finished",
|
||||
result.endTime ? formatDate(result.endTime) : "",
|
||||
),
|
||||
renderMetadataItem("Test list", result.testListName),
|
||||
renderMetadataItem("Adapter", result.adapterTypeName, { mono: true }),
|
||||
renderMetadataItem("Code base", result.codeBase, { mono: true }),
|
||||
renderMetadataItem("Test ID", result.testId, { mono: true }),
|
||||
renderMetadataItem("Execution ID", result.executionId, { mono: true }),
|
||||
].join("");
|
||||
const resultFiles =
|
||||
result.resultFiles.length > 0
|
||||
? `
|
||||
<section class="trx-result-files">
|
||||
<h4>Result files</h4>
|
||||
<ul>${result.resultFiles
|
||||
.map((path) => `<li class="mono">${escapeHtml(path)}</li>`)
|
||||
.join("")}</ul>
|
||||
</section>
|
||||
`
|
||||
: "";
|
||||
const properties =
|
||||
result.properties.length > 0
|
||||
? `
|
||||
<section class="trx-properties">
|
||||
<h4>Properties</h4>
|
||||
<dl>${result.properties
|
||||
.map(
|
||||
({ key, value }) => `
|
||||
<div>
|
||||
<dt>${escapeHtml(key)}</dt>
|
||||
<dd>${escapeHtml(value)}</dd>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join("")}</dl>
|
||||
</section>
|
||||
`
|
||||
: "";
|
||||
const details = [
|
||||
metadata ? `<dl class="trx-result-metadata">${metadata}</dl>` : "",
|
||||
renderDetailBlock("Error message", result.message),
|
||||
renderDetailBlock("Stack trace", result.stackTrace),
|
||||
renderDetailBlock("Standard output", result.stdout),
|
||||
renderDetailBlock("Standard error", result.stderr),
|
||||
renderDetailBlock("Debug trace", result.debugTrace),
|
||||
resultFiles,
|
||||
properties,
|
||||
].join("");
|
||||
|
||||
return `
|
||||
<details
|
||||
class="trx-result trx-outcome-${result.category}"
|
||||
data-trx-result
|
||||
data-trx-category="${result.category}"
|
||||
data-trx-search="${escapeHtml(searchValue)}"
|
||||
style="--trx-depth: ${Math.min(result.depth, 4)}"
|
||||
${open ? "open" : ""}
|
||||
>
|
||||
<summary>
|
||||
<span class="trx-result-chevron">${icon("chevron-right")}</span>
|
||||
<span class="trx-result-status">${icon(outcomeIcon(result.category))}</span>
|
||||
<span class="trx-result-copy">
|
||||
<span class="trx-result-name">${escapeHtml(result.testName)}</span>
|
||||
${
|
||||
secondaryParts.length > 0
|
||||
? `<span class="trx-result-secondary">${escapeHtml(secondaryParts.join(" | "))}</span>`
|
||||
: ""
|
||||
}
|
||||
</span>
|
||||
<span class="trx-result-duration">${escapeHtml(resultDuration)}</span>
|
||||
<span class="trx-outcome-badge trx-outcome-${result.category}">${escapeHtml(result.outcome)}</span>
|
||||
</summary>
|
||||
<div class="trx-result-details">
|
||||
${details || '<p class="trx-detail-empty">No additional details were recorded for this result.</p>'}
|
||||
</div>
|
||||
</details>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderMetric(label, value, category, iconName) {
|
||||
return `
|
||||
<div class="trx-metric trx-metric-${category}">
|
||||
<span class="trx-metric-icon">${icon(iconName)}</span>
|
||||
<span class="trx-metric-value">${Number(value).toLocaleString()}</span>
|
||||
<span class="trx-metric-label">${escapeHtml(label)}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderFilter(label, category, count, pressed = false) {
|
||||
return `
|
||||
<button
|
||||
class="trx-filter"
|
||||
type="button"
|
||||
data-trx-filter="${category}"
|
||||
aria-pressed="${pressed}"
|
||||
>
|
||||
${escapeHtml(label)}
|
||||
<span class="Counter Counter--secondary">${Number(count).toLocaleString()}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderParseError(source, message, highlightXml) {
|
||||
const highlighted =
|
||||
source.length <= MAX_HIGHLIGHTED_RAW_CHARS
|
||||
? highlightXml(source)
|
||||
: escapeHtml(source);
|
||||
return `
|
||||
<div class="trx-preview">
|
||||
<section class="trx-error" role="alert">
|
||||
<span class="trx-error-icon">${icon("x-circle")}</span>
|
||||
<div>
|
||||
<h2>Unable to parse test results</h2>
|
||||
<p>${escapeHtml(message)}</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="trx-raw-panel">
|
||||
<div class="trx-section-heading">
|
||||
<div>
|
||||
<span class="trx-eyebrow">Fallback</span>
|
||||
<h3>Raw XML</h3>
|
||||
</div>
|
||||
</div>
|
||||
<pre class="text-preview syntax-code syntax-xml trx-raw-source"><code>${highlighted}</code></pre>
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function render(value, preview, helpers = {}) {
|
||||
const source = String(value ?? "");
|
||||
const highlightXml =
|
||||
typeof helpers.highlightXml === "function"
|
||||
? helpers.highlightXml
|
||||
: escapeHtml;
|
||||
let report;
|
||||
try {
|
||||
report = parseReport(source);
|
||||
} catch (error) {
|
||||
preview.innerHTML = renderParseError(
|
||||
source,
|
||||
error instanceof Error ? error.message : "The TRX document is invalid.",
|
||||
highlightXml,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const categoryOrder = { failed: 0, other: 1, skipped: 2, passed: 3 };
|
||||
const renderedResults = [...report.results]
|
||||
.map((result, index) => ({ result, index }))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
categoryOrder[left.result.category] -
|
||||
categoryOrder[right.result.category] || left.index - right.index,
|
||||
)
|
||||
.slice(0, MAX_RENDERED_RESULTS)
|
||||
.map(({ result }) => result);
|
||||
const runOutcome = summarizedRunOutcome(report);
|
||||
const outcome = outcomeCategory(runOutcome);
|
||||
const limited = renderedResults.length < report.results.length;
|
||||
const runDuration = formatDuration(report.durationMs);
|
||||
const runMetadata = [
|
||||
renderMetadataItem(
|
||||
"Started",
|
||||
report.startTime ? formatDate(report.startTime) : "",
|
||||
),
|
||||
renderMetadataItem(
|
||||
"Finished",
|
||||
report.finishTime ? formatDate(report.finishTime) : "",
|
||||
),
|
||||
renderMetadataItem("Duration", runDuration),
|
||||
renderMetadataItem("Run by", report.user),
|
||||
renderMetadataItem("Computer", report.computerName),
|
||||
renderMetadataItem("Run ID", report.runId, { mono: true }),
|
||||
].join("");
|
||||
|
||||
preview.innerHTML = `
|
||||
<div
|
||||
class="trx-preview"
|
||||
data-trx-total="${report.results.length}"
|
||||
data-trx-rendered="${renderedResults.length}"
|
||||
>
|
||||
<div data-trx-panel="structured">
|
||||
<section class="trx-overview">
|
||||
<div class="trx-run-heading">
|
||||
<div>
|
||||
<span class="trx-eyebrow">Test run</span>
|
||||
<h2>${escapeHtml(report.name)}</h2>
|
||||
</div>
|
||||
<span class="trx-run-outcome trx-outcome-${outcome}">
|
||||
${icon(outcomeIcon(outcome))}
|
||||
${escapeHtml(runOutcome)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="trx-metrics">
|
||||
${renderMetric("Total", report.summaryCounts.total, "all", "workflow")}
|
||||
${renderMetric("Passed", report.summaryCounts.passed, "passed", "check-circle")}
|
||||
${renderMetric("Failed", report.summaryCounts.failed, "failed", "x-circle")}
|
||||
${renderMetric("Skipped", report.summaryCounts.skipped, "skipped", "clock")}
|
||||
</div>
|
||||
${runMetadata ? `<dl class="trx-run-metadata">${runMetadata}</dl>` : ""}
|
||||
</section>
|
||||
|
||||
<section class="trx-results-panel">
|
||||
<div class="trx-section-heading">
|
||||
<div>
|
||||
<span class="trx-eyebrow">Details</span>
|
||||
<h3>Test results</h3>
|
||||
<p data-trx-result-count aria-live="polite"></p>
|
||||
</div>
|
||||
<button class="app-button app-button-small" type="button" data-trx-view="raw">
|
||||
${icon("code")}
|
||||
View raw XML
|
||||
</button>
|
||||
</div>
|
||||
<div class="trx-controls">
|
||||
<label class="trx-search">
|
||||
${icon("search")}
|
||||
<span class="sr-only">Search test results</span>
|
||||
<input
|
||||
class="form-control"
|
||||
type="search"
|
||||
placeholder="Search tests..."
|
||||
autocomplete="off"
|
||||
data-trx-search-input
|
||||
/>
|
||||
</label>
|
||||
<div class="trx-filters" role="group" aria-label="Filter test outcomes">
|
||||
${renderFilter("All", "all", report.results.length, true)}
|
||||
${renderFilter("Passed", "passed", report.resultCounts.passed)}
|
||||
${renderFilter("Failed", "failed", report.resultCounts.failed)}
|
||||
${renderFilter("Skipped", "skipped", report.resultCounts.skipped)}
|
||||
${renderFilter("Other", "other", report.resultCounts.other)}
|
||||
</div>
|
||||
</div>
|
||||
${
|
||||
limited
|
||||
? `
|
||||
<div class="trx-limit-note">
|
||||
${icon("eye")}
|
||||
Showing the first ${renderedResults.length.toLocaleString()} of ${report.results.length.toLocaleString()} result records to keep the preview responsive. Raw XML contains the complete report.
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
<div class="trx-result-list">
|
||||
${renderedResults
|
||||
.map((result, index) =>
|
||||
renderResult(
|
||||
result,
|
||||
index === 0 && result.category === "failed",
|
||||
),
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
<div class="trx-no-results" data-trx-no-results hidden>
|
||||
${icon("search")}
|
||||
<h4>No matching tests</h4>
|
||||
<p>Try another search or outcome filter.</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="trx-raw-panel" data-trx-panel="raw" hidden>
|
||||
<div class="trx-section-heading">
|
||||
<div>
|
||||
<span class="trx-eyebrow">Source</span>
|
||||
<h3>Raw XML</h3>
|
||||
</div>
|
||||
<button class="app-button app-button-small" type="button" data-trx-view="structured">
|
||||
${icon("chevron-left")}
|
||||
Back to results
|
||||
</button>
|
||||
</div>
|
||||
<div class="trx-limit-note" data-trx-raw-note hidden>
|
||||
${icon("eye")}
|
||||
Syntax highlighting is disabled for large reports to keep the source view responsive.
|
||||
</div>
|
||||
<pre class="text-preview syntax-code syntax-xml trx-raw-source"><code data-trx-raw-code></code></pre>
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
const trxPreview = preview.querySelector(".trx-preview");
|
||||
rawSources.set(trxPreview, { source, highlightXml });
|
||||
applyFilters(trxPreview);
|
||||
}
|
||||
|
||||
function showView(preview, viewName) {
|
||||
if (!preview) return;
|
||||
if (viewName === "raw") {
|
||||
const rawSource = rawSources.get(preview);
|
||||
const code = preview.querySelector("[data-trx-raw-code]");
|
||||
if (rawSource && code && code.dataset.trxLoaded !== "true") {
|
||||
if (rawSource.source.length > MAX_HIGHLIGHTED_RAW_CHARS) {
|
||||
code.textContent = rawSource.source;
|
||||
const note = preview.querySelector("[data-trx-raw-note]");
|
||||
if (note) note.hidden = false;
|
||||
} else {
|
||||
code.innerHTML = rawSource.highlightXml(rawSource.source);
|
||||
}
|
||||
code.dataset.trxLoaded = "true";
|
||||
}
|
||||
}
|
||||
for (const panel of preview.querySelectorAll("[data-trx-panel]")) {
|
||||
panel.hidden = panel.dataset.trxPanel !== viewName;
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters(preview) {
|
||||
if (!preview) return;
|
||||
const query = String(
|
||||
preview.querySelector("[data-trx-search-input]")?.value ?? "",
|
||||
)
|
||||
.trim()
|
||||
.toLocaleLowerCase();
|
||||
const terms = query.split(/\s+/).filter(Boolean);
|
||||
const category =
|
||||
preview.querySelector('[data-trx-filter][aria-pressed="true"]')?.dataset
|
||||
.trxFilter ?? "all";
|
||||
const results = [...preview.querySelectorAll("[data-trx-result]")];
|
||||
let visible = 0;
|
||||
for (const result of results) {
|
||||
const matchesCategory =
|
||||
category === "all" || result.dataset.trxCategory === category;
|
||||
const searchValue = result.dataset.trxSearch ?? "";
|
||||
const matchesQuery = terms.every((term) => searchValue.includes(term));
|
||||
result.hidden = !(matchesCategory && matchesQuery);
|
||||
if (!result.hidden) visible += 1;
|
||||
}
|
||||
|
||||
const total = Number(preview.dataset.trxTotal) || results.length;
|
||||
const rendered = Number(preview.dataset.trxRendered) || results.length;
|
||||
const count = preview.querySelector("[data-trx-result-count]");
|
||||
if (count) {
|
||||
count.textContent =
|
||||
total > rendered
|
||||
? `${visible.toLocaleString()} matching in the first ${rendered.toLocaleString()} of ${total.toLocaleString()} results`
|
||||
: `${visible.toLocaleString()} of ${total.toLocaleString()} results`;
|
||||
}
|
||||
const noResults = preview.querySelector("[data-trx-no-results]");
|
||||
if (noResults) noResults.hidden = visible > 0;
|
||||
}
|
||||
|
||||
globalThis.TrxPreview = Object.freeze({
|
||||
applyFilters,
|
||||
parse: parseReport,
|
||||
render,
|
||||
showView,
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,63 @@
|
||||
function trackedOperation(callback) {
|
||||
const operation = Promise.resolve().then(callback);
|
||||
return {
|
||||
operation,
|
||||
// Waiters need ordering only; the initiating maintenance request owns failures.
|
||||
barrier: operation.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export class CacheMaintenanceCoordinator {
|
||||
constructor() {
|
||||
this.artifactDeletions = new Map();
|
||||
this.cacheClear = null;
|
||||
}
|
||||
|
||||
inspectionBarrier(artifactId) {
|
||||
return (
|
||||
this.cacheClear?.barrier ??
|
||||
this.artifactDeletions.get(String(artifactId))?.barrier ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
async deleteArtifact(artifactId, callback) {
|
||||
const id = String(artifactId);
|
||||
while (true) {
|
||||
const existing = this.artifactDeletions.get(id);
|
||||
if (existing) return existing.operation;
|
||||
if (!this.cacheClear) break;
|
||||
await this.cacheClear.barrier;
|
||||
}
|
||||
|
||||
const tracked = trackedOperation(callback);
|
||||
this.artifactDeletions.set(id, tracked);
|
||||
try {
|
||||
return await tracked.operation;
|
||||
} finally {
|
||||
if (this.artifactDeletions.get(id) === tracked) {
|
||||
this.artifactDeletions.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async clearCache(callback) {
|
||||
if (this.cacheClear) return this.cacheClear.operation;
|
||||
|
||||
const tracked = trackedOperation(async () => {
|
||||
await Promise.all(
|
||||
[...this.artifactDeletions.values()].map((entry) => entry.barrier),
|
||||
);
|
||||
return callback();
|
||||
});
|
||||
this.cacheClear = tracked;
|
||||
try {
|
||||
return await tracked.operation;
|
||||
} finally {
|
||||
if (this.cacheClear === tracked) this.cacheClear = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
import { createWriteStream } from "node:fs";
|
||||
import {
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
rename,
|
||||
rm,
|
||||
stat,
|
||||
statfs,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { Readable, Transform } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import {
|
||||
ARTIFACT_DOWNLOAD_IDLE_TIMEOUT_MS,
|
||||
CACHE_ROOT,
|
||||
MAX_ARCHIVE_BYTES,
|
||||
MAX_INLINE_PREVIEW_BYTES,
|
||||
MIN_CACHE_FREE_BYTES,
|
||||
} from "./constants.mjs";
|
||||
import { CacheMaintenanceCoordinator } from "./cache-coordinator.mjs";
|
||||
import { analyzeArtifact, hasRootIndexHtml } from "./detector.mjs";
|
||||
import { getArtifact, openArtifactDownload } from "./github.mjs";
|
||||
import { findZipEntry, readEntryPrefix, readZipEntry, readZipIndex } from "./zip.mjs";
|
||||
|
||||
const downloads = new Map();
|
||||
const indexCache = new Map();
|
||||
const maintenance = new CacheMaintenanceCoordinator();
|
||||
let reservedDownloadBytes = 0;
|
||||
let metadataWriteSequence = 0;
|
||||
|
||||
function normalizeArtifactId(value) {
|
||||
const id = Number.parseInt(value, 10);
|
||||
if (!Number.isSafeInteger(id) || id <= 0) {
|
||||
throw new Error("Artifact id must be a positive integer.");
|
||||
}
|
||||
return String(id);
|
||||
}
|
||||
|
||||
function pathsFor(value) {
|
||||
const id = normalizeArtifactId(value);
|
||||
return {
|
||||
id,
|
||||
archive: join(CACHE_ROOT, `${id}.zip`),
|
||||
metadata: join(CACHE_ROOT, `${id}.json`),
|
||||
};
|
||||
}
|
||||
|
||||
async function writeJsonAtomic(path, value) {
|
||||
const temporary = `${path}.${process.pid}.${++metadataWriteSequence}.tmp`;
|
||||
try {
|
||||
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
await rename(temporary, path);
|
||||
} catch (error) {
|
||||
await rm(temporary, { force: true }).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function readMetadataFile(path) {
|
||||
try {
|
||||
return JSON.parse(await readFile(path, "utf8"));
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") return null;
|
||||
if (error instanceof SyntaxError) {
|
||||
throw new Error(`Cached artifact metadata is invalid: ${path}`, { cause: error });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
const bytes = Math.max(0, Number(value) || 0);
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ["KiB", "MiB", "GiB", "TiB"];
|
||||
let size = bytes;
|
||||
let unit = -1;
|
||||
do {
|
||||
size /= 1024;
|
||||
unit++;
|
||||
} while (size >= 1024 && unit < units.length - 1);
|
||||
return `${size.toFixed(size >= 10 ? 0 : 1)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
async function downloadCapacityBytes() {
|
||||
const filesystem = await statfs(CACHE_ROOT);
|
||||
const available = Number(filesystem.bavail) * Number(filesystem.bsize);
|
||||
return Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
MAX_ARCHIVE_BYTES,
|
||||
available - MIN_CACHE_FREE_BYTES - reservedDownloadBytes,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function assertDownloadFits(bytes, capacity) {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return;
|
||||
if (bytes > MAX_ARCHIVE_BYTES) {
|
||||
throw new Error("Artifact exceeds the 4 GiB ZIP download limit.");
|
||||
}
|
||||
if (bytes > capacity) {
|
||||
throw new Error(
|
||||
`Not enough free disk space to cache this artifact. ${formatBytes(bytes)} is required, with ${formatBytes(capacity)} available after the safety reserve.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadArchive(
|
||||
token,
|
||||
repository,
|
||||
artifact,
|
||||
archivePath,
|
||||
onProgress,
|
||||
externalSignal = null,
|
||||
) {
|
||||
const declaredBytes = Number(artifact.sizeInBytes);
|
||||
let reservedBytes = 0;
|
||||
const reserve = async (totalBytes) => {
|
||||
if (!Number.isFinite(totalBytes) || totalBytes <= reservedBytes) return;
|
||||
assertDownloadFits(totalBytes, MAX_ARCHIVE_BYTES);
|
||||
const additionalBytes = totalBytes - reservedBytes;
|
||||
const capacity = await downloadCapacityBytes();
|
||||
if (additionalBytes > capacity) {
|
||||
throw new Error(
|
||||
`Not enough free disk space to cache this artifact. ${formatBytes(totalBytes)} is required, with ${formatBytes(capacity + reservedBytes)} available after the safety reserve.`,
|
||||
);
|
||||
}
|
||||
reservedDownloadBytes += additionalBytes;
|
||||
reservedBytes = totalBytes;
|
||||
};
|
||||
const releaseReservation = () => {
|
||||
reservedDownloadBytes = Math.max(0, reservedDownloadBytes - reservedBytes);
|
||||
reservedBytes = 0;
|
||||
};
|
||||
await reserve(declaredBytes);
|
||||
|
||||
const controller = new AbortController();
|
||||
if (externalSignal?.aborted) {
|
||||
controller.abort(externalSignal.reason);
|
||||
} else if (externalSignal) {
|
||||
externalSignal.addEventListener("abort", () => controller.abort(externalSignal.reason), { once: true });
|
||||
}
|
||||
let idleTimer;
|
||||
const resetIdleTimeout = () => {
|
||||
clearTimeout(idleTimer);
|
||||
idleTimer = setTimeout(() => {
|
||||
controller.abort(new Error("Artifact download stalled for 60 seconds."));
|
||||
}, ARTIFACT_DOWNLOAD_IDLE_TIMEOUT_MS);
|
||||
};
|
||||
resetIdleTimeout();
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await openArtifactDownload(token, repository, artifact.id, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
clearTimeout(idleTimer);
|
||||
releaseReservation();
|
||||
if (controller.signal.aborted && controller.signal.reason instanceof Error) {
|
||||
throw controller.signal.reason;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const expectedLength = Number.parseInt(response.headers.get("content-length") ?? "", 10);
|
||||
try {
|
||||
await reserve(expectedLength);
|
||||
} catch (error) {
|
||||
clearTimeout(idleTimer);
|
||||
controller.abort(error);
|
||||
releaseReservation();
|
||||
throw error;
|
||||
}
|
||||
const totalBytes =
|
||||
Number.isFinite(expectedLength) && expectedLength > 0
|
||||
? expectedLength
|
||||
: Number.isFinite(declaredBytes) && declaredBytes > 0
|
||||
? declaredBytes
|
||||
: null;
|
||||
|
||||
const temporary = `${archivePath}.${process.pid}.${Date.now()}.part`;
|
||||
let received = 0;
|
||||
let lastReportAt = 0;
|
||||
let bytesPerSecond = 0;
|
||||
const samples = [{ at: Date.now(), bytes: 0 }];
|
||||
const report = (force = false) => {
|
||||
if (!onProgress) return;
|
||||
const now = Date.now();
|
||||
if (!force && now - lastReportAt < 250) return;
|
||||
samples.push({ at: now, bytes: received });
|
||||
while (samples.length > 2 && samples[1].at < now - 5_000) samples.shift();
|
||||
const first = samples[0];
|
||||
const elapsedSeconds = (now - first.at) / 1_000;
|
||||
bytesPerSecond =
|
||||
elapsedSeconds >= 0.2 ? (received - first.bytes) / elapsedSeconds : 0;
|
||||
const percent = totalBytes
|
||||
? Math.min(100, (received / totalBytes) * 100)
|
||||
: null;
|
||||
onProgress({
|
||||
artifactId: artifact.id,
|
||||
artifactName: artifact.name,
|
||||
stage: "downloading",
|
||||
receivedBytes: received,
|
||||
totalBytes,
|
||||
percent,
|
||||
bytesPerSecond,
|
||||
etaSeconds:
|
||||
totalBytes && bytesPerSecond > 0
|
||||
? Math.max(0, (totalBytes - received) / bytesPerSecond)
|
||||
: null,
|
||||
});
|
||||
lastReportAt = now;
|
||||
};
|
||||
report(true);
|
||||
|
||||
const limiter = new Transform({
|
||||
transform(chunk, _encoding, callback) {
|
||||
received += chunk.length;
|
||||
if (received > MAX_ARCHIVE_BYTES) {
|
||||
callback(new Error("Artifact exceeded the 4 GiB ZIP download limit."));
|
||||
return;
|
||||
}
|
||||
if (reservedBytes > 0 && received > reservedBytes) {
|
||||
callback(new Error("Artifact download exhausted the available cache space."));
|
||||
return;
|
||||
}
|
||||
resetIdleTimeout();
|
||||
report();
|
||||
callback(null, chunk);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await pipeline(
|
||||
Readable.fromWeb(response.body),
|
||||
limiter,
|
||||
createWriteStream(temporary, { flags: "wx" }),
|
||||
);
|
||||
report(true);
|
||||
await rename(temporary, archivePath);
|
||||
} catch (error) {
|
||||
await rm(temporary, { force: true });
|
||||
if (controller.signal.aborted && controller.signal.reason instanceof Error) {
|
||||
throw controller.signal.reason;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(idleTimer);
|
||||
releaseReservation();
|
||||
}
|
||||
return {
|
||||
bytesPerSecond,
|
||||
receivedBytes: received,
|
||||
totalBytes: totalBytes ?? received,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildMetadata(token, repository, artifactId, onProgress, signal = null) {
|
||||
const paths = pathsFor(artifactId);
|
||||
const artifact = await getArtifact(token, repository, paths.id);
|
||||
if (artifact.expired) {
|
||||
throw new Error("This GitHub Actions artifact has expired.");
|
||||
}
|
||||
|
||||
await mkdir(CACHE_ROOT, { recursive: true });
|
||||
onProgress?.({
|
||||
artifactId: artifact.id,
|
||||
artifactName: artifact.name,
|
||||
stage: "preparing",
|
||||
receivedBytes: 0,
|
||||
totalBytes: artifact.sizeInBytes,
|
||||
percent: 0,
|
||||
bytesPerSecond: 0,
|
||||
etaSeconds: null,
|
||||
});
|
||||
const transfer = await downloadArchive(
|
||||
token,
|
||||
repository,
|
||||
artifact,
|
||||
paths.archive,
|
||||
onProgress,
|
||||
signal,
|
||||
);
|
||||
const compressedBytes = transfer.receivedBytes;
|
||||
try {
|
||||
onProgress?.({
|
||||
artifactId: artifact.id,
|
||||
artifactName: artifact.name,
|
||||
stage: "indexing",
|
||||
receivedBytes: compressedBytes,
|
||||
totalBytes: transfer.totalBytes,
|
||||
percent: 100,
|
||||
bytesPerSecond: transfer.bytesPerSecond,
|
||||
etaSeconds: 0,
|
||||
});
|
||||
const index = await readZipIndex(paths.archive);
|
||||
const PREFIX_BYTES = 8 * 1024;
|
||||
const analysis = await analyzeArtifact(index, async (entry) => {
|
||||
return readEntryPrefix(paths.archive, entry, PREFIX_BYTES);
|
||||
});
|
||||
const timestamp = new Date().toISOString();
|
||||
const metadata = {
|
||||
artifact,
|
||||
repository,
|
||||
downloadedAt: timestamp,
|
||||
lastAccessedAt: timestamp,
|
||||
compressedBytes,
|
||||
analysis,
|
||||
};
|
||||
await writeJsonAtomic(paths.metadata, metadata);
|
||||
indexCache.set(paths.id, { archivePath: paths.archive, index });
|
||||
onProgress?.({
|
||||
artifactId: artifact.id,
|
||||
artifactName: artifact.name,
|
||||
stage: "ready",
|
||||
receivedBytes: compressedBytes,
|
||||
totalBytes: transfer.totalBytes,
|
||||
percent: 100,
|
||||
bytesPerSecond: transfer.bytesPerSecond,
|
||||
etaSeconds: 0,
|
||||
});
|
||||
return metadata;
|
||||
} catch (error) {
|
||||
await Promise.all([
|
||||
rm(paths.archive, { force: true }),
|
||||
rm(paths.metadata, { force: true }),
|
||||
]);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function cachedMetadata(artifactId) {
|
||||
const paths = pathsFor(artifactId);
|
||||
const metadata = await readMetadataFile(paths.metadata);
|
||||
if (!metadata) return null;
|
||||
try {
|
||||
const archive = await stat(paths.archive);
|
||||
if (!archive.isFile()) return null;
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") return null;
|
||||
throw error;
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export async function inspectArtifact(
|
||||
token,
|
||||
repository,
|
||||
artifactId,
|
||||
{ onProgress } = {},
|
||||
) {
|
||||
const paths = pathsFor(artifactId);
|
||||
while (true) {
|
||||
const barrier = maintenance.inspectionBarrier(paths.id);
|
||||
if (!barrier) break;
|
||||
await barrier;
|
||||
}
|
||||
const key = `${repository}:${paths.id}`;
|
||||
const active = downloads.get(key);
|
||||
if (active) {
|
||||
if (onProgress) active.listeners.add(onProgress);
|
||||
try {
|
||||
return await active.operation;
|
||||
} finally {
|
||||
if (onProgress) active.listeners.delete(onProgress);
|
||||
}
|
||||
}
|
||||
|
||||
const listeners = new Set(onProgress ? [onProgress] : []);
|
||||
const emit = (progress) => {
|
||||
for (const listener of listeners) listener(progress);
|
||||
};
|
||||
const externalController = new AbortController();
|
||||
const operation = (async () => {
|
||||
const existing = await cachedMetadata(paths.id);
|
||||
if (existing?.repository === repository && existing.analysis) {
|
||||
existing.lastAccessedAt = new Date().toISOString();
|
||||
await writeJsonAtomic(paths.metadata, existing);
|
||||
emit({
|
||||
artifactId: Number(paths.id),
|
||||
artifactName: existing.artifact?.name ?? `Artifact ${paths.id}`,
|
||||
stage: "ready",
|
||||
receivedBytes: existing.compressedBytes,
|
||||
totalBytes: existing.compressedBytes,
|
||||
percent: 100,
|
||||
bytesPerSecond: 0,
|
||||
etaSeconds: 0,
|
||||
cached: true,
|
||||
});
|
||||
return existing;
|
||||
}
|
||||
if (existing) {
|
||||
indexCache.delete(paths.id);
|
||||
await removeArtifactFiles(paths);
|
||||
}
|
||||
return buildMetadata(token, repository, paths.id, emit, externalController.signal);
|
||||
})();
|
||||
const download = { listeners, operation, abort: () => externalController.abort(new Error("Cache cleared.")) };
|
||||
downloads.set(key, download);
|
||||
try {
|
||||
return await operation;
|
||||
} catch (error) {
|
||||
emit({
|
||||
artifactId: Number(paths.id),
|
||||
stage: "error",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
if (downloads.get(key) === download) downloads.delete(key);
|
||||
listeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCachedArtifact(artifactId, { touch = true } = {}) {
|
||||
const paths = pathsFor(artifactId);
|
||||
const metadata = await cachedMetadata(paths.id);
|
||||
if (!metadata) return null;
|
||||
if (touch) {
|
||||
metadata.lastAccessedAt = new Date().toISOString();
|
||||
await writeJsonAtomic(paths.metadata, metadata);
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
async function getIndex(artifactId) {
|
||||
const paths = pathsFor(artifactId);
|
||||
const cached = indexCache.get(paths.id);
|
||||
if (cached?.archivePath === paths.archive) return cached.index;
|
||||
const index = await readZipIndex(paths.archive);
|
||||
indexCache.set(paths.id, { archivePath: paths.archive, index });
|
||||
return index;
|
||||
}
|
||||
|
||||
export async function getCachedEntry(artifactId, entryPath) {
|
||||
const paths = pathsFor(artifactId);
|
||||
const metadata = await cachedMetadata(paths.id);
|
||||
if (!metadata) throw new Error("Artifact is not cached. Inspect it first.");
|
||||
const index = await getIndex(paths.id);
|
||||
const entry = findZipEntry(index, entryPath);
|
||||
if (!entry) throw new Error(`File was not found in the artifact: ${entryPath}`);
|
||||
return { metadata, archivePath: paths.archive, index, entry };
|
||||
}
|
||||
|
||||
export async function readCachedEntry(artifactId, entryPath, maxBytes = MAX_INLINE_PREVIEW_BYTES) {
|
||||
const context = await getCachedEntry(artifactId, entryPath);
|
||||
return {
|
||||
...context,
|
||||
content: await readZipEntry(context.archivePath, context.entry, maxBytes),
|
||||
};
|
||||
}
|
||||
|
||||
async function removeArtifactFiles(paths) {
|
||||
await Promise.all([
|
||||
rm(paths.archive, { force: true }),
|
||||
rm(paths.metadata, { force: true }),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function deleteCachedArtifact(artifactId) {
|
||||
const paths = pathsFor(artifactId);
|
||||
return maintenance.deleteArtifact(paths.id, async () => {
|
||||
indexCache.delete(paths.id);
|
||||
const suffix = `:${paths.id}`;
|
||||
const matches = [...downloads.entries()]
|
||||
.filter(([key]) => key.endsWith(suffix))
|
||||
.map(([, entry]) => entry);
|
||||
for (const entry of matches) entry.abort?.();
|
||||
await Promise.allSettled(matches.map((entry) => entry.operation));
|
||||
await removeArtifactFiles(paths);
|
||||
return { deleted: paths.id };
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearArtifactCache() {
|
||||
return maintenance.clearCache(async () => {
|
||||
const inflight = [...downloads.values()];
|
||||
for (const entry of inflight) entry.abort?.();
|
||||
await Promise.allSettled(inflight.map((entry) => entry.operation));
|
||||
indexCache.clear();
|
||||
await rm(CACHE_ROOT, { recursive: true, force: true });
|
||||
await mkdir(CACHE_ROOT, { recursive: true });
|
||||
return { cleared: true };
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCacheSummary() {
|
||||
await mkdir(CACHE_ROOT, { recursive: true });
|
||||
const files = await readdir(CACHE_ROOT, { withFileTypes: true });
|
||||
const metadataFiles = files.filter(
|
||||
(entry) => entry.isFile() && /^\d+\.json$/.test(entry.name),
|
||||
);
|
||||
const artifacts = [];
|
||||
const errors = [];
|
||||
let totalBytes = 0;
|
||||
|
||||
for (const file of metadataFiles) {
|
||||
const id = file.name.slice(0, -5);
|
||||
try {
|
||||
const metadata = await cachedMetadata(id);
|
||||
if (!metadata) continue;
|
||||
const archive = await stat(pathsFor(id).archive);
|
||||
totalBytes += archive.size;
|
||||
artifacts.push({
|
||||
id,
|
||||
name: metadata.artifact?.name ?? `Artifact ${id}`,
|
||||
repository: metadata.repository,
|
||||
bytes: archive.size,
|
||||
downloadedAt: metadata.downloadedAt,
|
||||
lastAccessedAt: metadata.lastAccessedAt,
|
||||
primary:
|
||||
["html", "static-site"].includes(metadata.analysis?.primary?.kind) &&
|
||||
!hasRootIndexHtml(metadata.analysis?.entries ?? [])
|
||||
? null
|
||||
: metadata.analysis?.primary ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
id,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
artifacts.sort((left, right) =>
|
||||
String(right.lastAccessedAt).localeCompare(String(left.lastAccessedAt)));
|
||||
return { totalBytes, count: artifacts.length, artifacts, errors };
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const EXTENSION_ROOT = dirname(fileURLToPath(import.meta.url));
|
||||
export const ASSET_ROOT = join(EXTENSION_ROOT, "assets");
|
||||
|
||||
const copilotHome = process.env.COPILOT_HOME || join(homedir(), ".copilot");
|
||||
export const DATA_ROOT = join(copilotHome, "extensions", "pr-artifact-explorer", "artifacts");
|
||||
export const CACHE_ROOT = join(DATA_ROOT, "cache");
|
||||
export const PREFS_FILE = join(DATA_ROOT, "preferences.json");
|
||||
|
||||
export const GITHUB_API = "https://api.github.com";
|
||||
export const USER_AGENT = "github-copilot-pr-artifact-explorer";
|
||||
|
||||
// The indexer supports standard ZIP archives up to the ZIP32 boundary.
|
||||
export const MAX_ARCHIVE_BYTES = (4 * 1024 * 1024 * 1024) - 2;
|
||||
export const MIN_CACHE_FREE_BYTES = 512 * 1024 * 1024;
|
||||
export const ARTIFACT_DOWNLOAD_IDLE_TIMEOUT_MS = 60_000;
|
||||
export const MAX_CENTRAL_DIRECTORY_BYTES = 64 * 1024 * 1024;
|
||||
export const MAX_ZIP_ENTRIES = 50_000;
|
||||
export const MAX_INLINE_PREVIEW_BYTES = 4 * 1024 * 1024;
|
||||
export const MAX_TRX_PREVIEW_BYTES = 32 * 1024 * 1024;
|
||||
export const MAX_STREAMED_ENTRY_BYTES = 512 * 1024 * 1024;
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "pr-artifact-explorer",
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { posix } from "node:path";
|
||||
|
||||
const MIME_TYPES = Object.freeze({
|
||||
".avif": "image/avif",
|
||||
".bmp": "image/bmp",
|
||||
".cast": "application/x-asciicast",
|
||||
".config": "application/xml; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".csproj": "application/xml; charset=utf-8",
|
||||
".csv": "text/csv; charset=utf-8",
|
||||
".gif": "image/gif",
|
||||
".htm": "text/html; charset=utf-8",
|
||||
".html": "text/html; charset=utf-8",
|
||||
".ico": "image/x-icon",
|
||||
".jpeg": "image/jpeg",
|
||||
".jpg": "image/jpeg",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".log": "text/plain; charset=utf-8",
|
||||
".map": "application/json; charset=utf-8",
|
||||
".md": "text/markdown; charset=utf-8",
|
||||
".mjs": "text/javascript; charset=utf-8",
|
||||
".nuspec": "application/xml; charset=utf-8",
|
||||
".pdf": "application/pdf",
|
||||
".png": "image/png",
|
||||
".props": "application/xml; charset=utf-8",
|
||||
".resx": "application/xml; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".text": "text/plain; charset=utf-8",
|
||||
".toml": "text/plain; charset=utf-8",
|
||||
".ts": "text/plain; charset=utf-8",
|
||||
".tsx": "text/plain; charset=utf-8",
|
||||
".targets": "application/xml; charset=utf-8",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
".trx": "application/xml; charset=utf-8",
|
||||
".webp": "image/webp",
|
||||
".xaml": "application/xml; charset=utf-8",
|
||||
".xml": "application/xml; charset=utf-8",
|
||||
".yaml": "text/yaml; charset=utf-8",
|
||||
".yml": "text/yaml; charset=utf-8",
|
||||
".zip": "application/zip",
|
||||
});
|
||||
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
".config", ".css", ".csproj", ".csv", ".htm", ".html", ".ini", ".js",
|
||||
".json", ".log", ".map", ".md", ".mjs", ".nuspec", ".props", ".ps1",
|
||||
".resx", ".sh", ".targets", ".text", ".toml", ".ts", ".tsx", ".txt",
|
||||
".trx", ".xaml", ".xml", ".yaml", ".yml",
|
||||
]);
|
||||
|
||||
const XML_EXTENSIONS = new Set([
|
||||
".config", ".csproj", ".nuspec", ".props", ".resx", ".targets", ".xaml",
|
||||
".trx", ".xml",
|
||||
]);
|
||||
|
||||
export function extensionOf(path) {
|
||||
const base = posix.basename(path);
|
||||
const index = base.lastIndexOf(".");
|
||||
return index > 0 ? base.slice(index).toLowerCase() : "";
|
||||
}
|
||||
|
||||
export function mimeForPath(path) {
|
||||
return MIME_TYPES[extensionOf(path)] ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
export function kindForPath(path) {
|
||||
const extension = extensionOf(path);
|
||||
if (extension === ".cast") return "asciinema";
|
||||
if (extension === ".trx") return "trx";
|
||||
if (extension === ".html" || extension === ".htm") return "html";
|
||||
if ([".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".bmp", ".svg", ".ico"].includes(extension)) {
|
||||
return "image";
|
||||
}
|
||||
if (extension === ".pdf") return "pdf";
|
||||
if (extension === ".md") return "markdown";
|
||||
if (extension === ".json" || extension === ".map") return "json";
|
||||
if (XML_EXTENSIONS.has(extension)) return "xml";
|
||||
if (TEXT_EXTENSIONS.has(extension)) return "text";
|
||||
if ([".zip", ".gz", ".tgz", ".7z", ".tar", ".rar"].includes(extension)) return "archive";
|
||||
return "binary";
|
||||
}
|
||||
|
||||
export function hasRootIndexHtml(entries) {
|
||||
return entries.some((entry) => {
|
||||
if (!entry?.supported) return false;
|
||||
const path = String(entry?.name ?? entry?.path ?? "")
|
||||
.replaceAll("\\", "/")
|
||||
.replace(/^\.\/+/, "");
|
||||
return path.toLocaleLowerCase() === "index.html";
|
||||
});
|
||||
}
|
||||
|
||||
function shallowest(entries) {
|
||||
return [...entries].sort((left, right) => {
|
||||
const depth = left.name.split("/").length - right.name.split("/").length;
|
||||
return depth || left.name.localeCompare(right.name);
|
||||
})[0] ?? null;
|
||||
}
|
||||
|
||||
async function looksLikeAsciinema(entry, readHead) {
|
||||
if (kindForPath(entry.name) === "asciinema") return true;
|
||||
if (
|
||||
entry.uncompressedSize > 8 * 1024 * 1024 ||
|
||||
!/(recording|asciinema|terminal|session)/i.test(posix.basename(entry.name))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
let head;
|
||||
try {
|
||||
head = await readHead(entry);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!head) return false;
|
||||
const firstLine = head.toString("utf8").split(/\r?\n/, 1)[0].trim();
|
||||
if (!firstLine.startsWith("{")) return false;
|
||||
try {
|
||||
const header = JSON.parse(firstLine);
|
||||
return (
|
||||
(header.version === 2 || header.version === 3) &&
|
||||
Number.isFinite(header.width) &&
|
||||
Number.isFinite(header.height)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function analyzeArtifact(index, readHead) {
|
||||
const files = index.entries.filter((entry) => !entry.directory);
|
||||
const previewableFiles = files.filter((entry) => entry.supported);
|
||||
const castEntries = [];
|
||||
for (const entry of previewableFiles.slice(0, 2_000)) {
|
||||
if (await looksLikeAsciinema(entry, readHead)) castEntries.push(entry);
|
||||
}
|
||||
|
||||
const htmlEntries = previewableFiles.filter(
|
||||
(entry) => kindForPath(entry.name) === "html",
|
||||
);
|
||||
const indexEntries = htmlEntries.filter(
|
||||
(entry) => posix.basename(entry.name).toLowerCase() === "index.html",
|
||||
);
|
||||
const rootIndexEntry = indexEntries.find(
|
||||
(entry) => hasRootIndexHtml([entry]),
|
||||
);
|
||||
const firstTrx = shallowest(
|
||||
previewableFiles.filter((entry) => kindForPath(entry.name) === "trx"),
|
||||
);
|
||||
const firstImage = shallowest(
|
||||
previewableFiles.filter((entry) => kindForPath(entry.name) === "image"),
|
||||
);
|
||||
const firstMarkdown = shallowest(
|
||||
previewableFiles.filter((entry) => kindForPath(entry.name) === "markdown"),
|
||||
);
|
||||
const firstText = shallowest(
|
||||
previewableFiles.filter((entry) =>
|
||||
["json", "text", "xml"].includes(kindForPath(entry.name)),
|
||||
),
|
||||
);
|
||||
|
||||
let primary = null;
|
||||
if (rootIndexEntry) {
|
||||
primary = {
|
||||
kind: "static-site",
|
||||
path: rootIndexEntry.name,
|
||||
root: "",
|
||||
label: "Static HTML site",
|
||||
};
|
||||
} else if (castEntries.length) {
|
||||
primary = {
|
||||
kind: "asciinema",
|
||||
path: castEntries[0].name,
|
||||
label: castEntries.length === 1 ? "Asciinema recording" : `${castEntries.length} Asciinema recordings`,
|
||||
};
|
||||
} else if (firstTrx) {
|
||||
primary = { kind: "trx", path: firstTrx.name, label: "Test results" };
|
||||
} else if (firstImage) {
|
||||
primary = { kind: "image", path: firstImage.name, label: "Image preview" };
|
||||
} else if (firstMarkdown) {
|
||||
primary = { kind: "markdown", path: firstMarkdown.name, label: "Markdown document" };
|
||||
} else if (firstText) {
|
||||
const kind = kindForPath(firstText.name);
|
||||
primary = {
|
||||
kind,
|
||||
path: firstText.name,
|
||||
label: kind === "xml" ? "XML code" : kind === "json" ? "JSON code" : "Text preview",
|
||||
};
|
||||
}
|
||||
|
||||
const counts = {};
|
||||
const entries = files.map((entry) => {
|
||||
const detectedKind = castEntries.includes(entry) ? "asciinema" : kindForPath(entry.name);
|
||||
counts[detectedKind] = (counts[detectedKind] ?? 0) + 1;
|
||||
return {
|
||||
path: entry.name,
|
||||
name: posix.basename(entry.name),
|
||||
size: entry.uncompressedSize,
|
||||
compressedSize: entry.compressedSize,
|
||||
kind: detectedKind,
|
||||
mime: detectedKind === "asciinema" ? "application/x-asciicast" : mimeForPath(entry.name),
|
||||
supported: entry.supported,
|
||||
modifiedAt: entry.modifiedAt,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
primary,
|
||||
entries,
|
||||
counts,
|
||||
fileCount: files.length,
|
||||
totalUncompressedBytes: index.totalUncompressedBytes,
|
||||
zipBacked: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function isInlineTextKind(kind) {
|
||||
return ["json", "markdown", "text", "trx", "xml"].includes(kind);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import {
|
||||
CanvasError,
|
||||
createCanvas,
|
||||
joinSession,
|
||||
} from "@github/copilot-sdk/extension";
|
||||
import { resolveAccounts, invalidateAccounts } from "./accounts.mjs";
|
||||
import {
|
||||
clearArtifactCache,
|
||||
deleteCachedArtifact,
|
||||
getCacheSummary,
|
||||
inspectArtifact,
|
||||
} from "./cache.mjs";
|
||||
import {
|
||||
broadcastCache,
|
||||
closeAllPreviews,
|
||||
navigateInstance,
|
||||
startInstance,
|
||||
stopInstance,
|
||||
} from "./server.mjs";
|
||||
import { stopStaticPreviewsForArtifact } from "./preview.mjs";
|
||||
import { loadPrefs, normalizeRepository, savePrefs } from "./state.mjs";
|
||||
|
||||
function positiveInteger(value, label) {
|
||||
const number = Number.parseInt(value, 10);
|
||||
if (!Number.isSafeInteger(number) || number <= 0) {
|
||||
throw new CanvasError("invalid_input", `${label} must be a positive integer.`);
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
const session = await joinSession({
|
||||
canvases: [
|
||||
createCanvas({
|
||||
id: "pr-artifact-explorer",
|
||||
displayName: "Artifact Explorer",
|
||||
description:
|
||||
"Browse GitHub pull requests and securely preview workflow artifacts, including static sites and asciinema recordings.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
repository: {
|
||||
type: "string",
|
||||
description: "Repository in owner/name format.",
|
||||
},
|
||||
pullNumber: {
|
||||
type: "integer",
|
||||
minimum: 1,
|
||||
description: "Pull request number to open.",
|
||||
},
|
||||
url: {
|
||||
type: "string",
|
||||
description: "GitHub pull request URL to open.",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
name: "open_pull_request",
|
||||
description: "Navigate an open explorer canvas to a pull request.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
repository: { type: "string" },
|
||||
pullNumber: { type: "integer", minimum: 1 },
|
||||
},
|
||||
required: ["repository", "pullNumber"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
handler: (ctx) => {
|
||||
const repository = normalizeRepository(ctx.input?.repository);
|
||||
const pullNumber = positiveInteger(ctx.input?.pullNumber, "pullNumber");
|
||||
const route = `#/pull/${encodeURIComponent(repository)}/${pullNumber}`;
|
||||
return {
|
||||
navigated: navigateInstance(ctx.instanceId, route),
|
||||
repository,
|
||||
pullNumber,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "inspect_artifact",
|
||||
description: "Download, index, and smart-detect a GitHub Actions artifact.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
repository: { type: "string" },
|
||||
artifactId: { type: "integer", minimum: 1 },
|
||||
},
|
||||
required: ["repository", "artifactId"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
handler: async (ctx) => {
|
||||
const repository = normalizeRepository(ctx.input?.repository);
|
||||
const artifactId = positiveInteger(ctx.input?.artifactId, "artifactId");
|
||||
const prefs = await loadPrefs();
|
||||
const auth = await resolveAccounts({
|
||||
preferredId: prefs.account,
|
||||
repository,
|
||||
});
|
||||
if (!auth.activeToken) {
|
||||
throw new CanvasError("github_auth_required", "No usable GitHub account was found.");
|
||||
}
|
||||
const metadata = await inspectArtifact(auth.activeToken, repository, artifactId);
|
||||
await broadcastCache();
|
||||
navigateInstance(
|
||||
ctx.instanceId,
|
||||
`#/artifact/${encodeURIComponent(repository)}/0/${artifactId}`,
|
||||
);
|
||||
const entries = metadata.analysis.entries ?? [];
|
||||
return {
|
||||
artifact: metadata.artifact,
|
||||
analysis: {
|
||||
...metadata.analysis,
|
||||
entries: entries.slice(0, 200),
|
||||
entriesTruncated: entries.length > 200,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cache_status",
|
||||
description: "Report downloaded artifact cache usage.",
|
||||
handler: async () => getCacheSummary(),
|
||||
},
|
||||
{
|
||||
name: "clear_cache",
|
||||
description: "Delete one downloaded artifact or clear the entire artifact cache.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
artifactId: { type: "integer", minimum: 1 },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
handler: async (ctx) => {
|
||||
if (ctx.input?.artifactId != null) {
|
||||
await stopStaticPreviewsForArtifact(ctx.input.artifactId);
|
||||
const result = await deleteCachedArtifact(
|
||||
positiveInteger(ctx.input.artifactId, "artifactId"),
|
||||
);
|
||||
navigateInstance(ctx.instanceId, "#/cache");
|
||||
await broadcastCache({ refresh: true });
|
||||
return result;
|
||||
}
|
||||
await closeAllPreviews();
|
||||
const result = await clearArtifactCache();
|
||||
navigateInstance(ctx.instanceId, "#/cache");
|
||||
await broadcastCache({ refresh: true });
|
||||
return result;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accounts",
|
||||
description: "List GitHub accounts inherited from the Copilot app and GitHub CLI.",
|
||||
handler: async () => {
|
||||
const prefs = await loadPrefs();
|
||||
const auth = await resolveAccounts({
|
||||
preferredId: prefs.account,
|
||||
repository: prefs.repository,
|
||||
force: true,
|
||||
});
|
||||
return { active: auth.active, accounts: auth.accounts };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "set_account",
|
||||
description: "Select the GitHub account used by the artifact explorer.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { id: { type: "string" } },
|
||||
required: ["id"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
handler: async (ctx) => {
|
||||
if (typeof ctx.input?.id !== "string" || !ctx.input.id) {
|
||||
throw new CanvasError("invalid_account", "Account id is required.");
|
||||
}
|
||||
const prefs = await loadPrefs();
|
||||
invalidateAccounts();
|
||||
const auth = await resolveAccounts({
|
||||
preferredId: ctx.input.id,
|
||||
repository: prefs.repository,
|
||||
force: true,
|
||||
});
|
||||
if (!auth.active || auth.active.id !== ctx.input.id) {
|
||||
throw new CanvasError(
|
||||
"invalid_account",
|
||||
`Account "${ctx.input.id}" was not found or is unavailable.`,
|
||||
);
|
||||
}
|
||||
prefs.account = ctx.input.id;
|
||||
await savePrefs(prefs);
|
||||
return { active: auth.active, accounts: auth.accounts };
|
||||
},
|
||||
},
|
||||
],
|
||||
open: async (ctx) => {
|
||||
const entry = await startInstance(
|
||||
ctx.instanceId,
|
||||
ctx.input,
|
||||
(message) => session.log(message, { level: "debug" }),
|
||||
);
|
||||
return {
|
||||
title: "Artifact Explorer",
|
||||
status: "GitHub pull request artifacts",
|
||||
url: entry.url,
|
||||
};
|
||||
},
|
||||
onClose: async (ctx) => {
|
||||
await stopInstance(ctx.instanceId);
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,712 @@
|
||||
import { GITHUB_API, USER_AGENT } from "./constants.mjs";
|
||||
import { normalizeRepository } from "./state.mjs";
|
||||
|
||||
const PULL_ARTIFACT_FILTERS = new Set(["all", "with", "without"]);
|
||||
const PULL_CI_FILTERS = new Set(["all", "failing", "passing", "pending", "none"]);
|
||||
const ARTIFACT_PRESENCE_TTL_MS = 5 * 60 * 1_000;
|
||||
const MISSING_ARTIFACT_TTL_MS = 30 * 1_000;
|
||||
const ARTIFACT_PRESENCE_CACHE_LIMIT = 1_000;
|
||||
// Each run fans out into paginated artifact requests, so keep the initial view bounded.
|
||||
const MAX_PULL_REQUEST_RUNS = 30;
|
||||
const MAX_PULL_REQUEST_ARTIFACTS = 2_000;
|
||||
const artifactPresenceCache = new Map();
|
||||
|
||||
export class GitHubApiError extends Error {
|
||||
constructor(message, status, details = null) {
|
||||
super(message);
|
||||
this.name = "GitHubApiError";
|
||||
this.status = status;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
function repositoryPath(repository) {
|
||||
return normalizeRepository(repository).split("/").map(encodeURIComponent).join("/");
|
||||
}
|
||||
|
||||
function headersFor(token, extra = {}) {
|
||||
return {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"User-Agent": USER_AGENT,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
async function readPayload(response) {
|
||||
const text = await response.text();
|
||||
if (!text) return null;
|
||||
const type = response.headers.get("content-type") ?? "";
|
||||
if (type.includes("json")) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
throw new GitHubApiError("GitHub returned malformed JSON.", response.status, {
|
||||
cause: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export async function githubRequest(token, path, options = {}) {
|
||||
if (!token) throw new GitHubApiError("No GitHub account is selected.", 401);
|
||||
if (!path.startsWith("/")) throw new Error("GitHub API paths must be absolute.");
|
||||
|
||||
const response = await fetch(`${GITHUB_API}${path}`, {
|
||||
method: options.method ?? "GET",
|
||||
headers: headersFor(token, options.headers),
|
||||
body: options.body,
|
||||
signal: options.signal ?? AbortSignal.timeout(30_000),
|
||||
});
|
||||
const payload = await readPayload(response);
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
payload && typeof payload === "object" && payload.message
|
||||
? payload.message
|
||||
: `GitHub API request failed (${response.status}).`;
|
||||
throw new GitHubApiError(message, response.status, payload);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function repositorySummary(repository) {
|
||||
return {
|
||||
fullName: repository.full_name,
|
||||
name: repository.name,
|
||||
owner: {
|
||||
login: repository.owner?.login ?? "",
|
||||
avatarUrl: repository.owner?.avatar_url ?? null,
|
||||
type: repository.owner?.type ?? null,
|
||||
},
|
||||
description: repository.description ?? null,
|
||||
private: repository.private === true,
|
||||
archived: repository.archived === true,
|
||||
visibility: repository.visibility ?? (repository.private ? "private" : "public"),
|
||||
url: repository.html_url,
|
||||
updatedAt: repository.updated_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getRepository(token, repository) {
|
||||
return repositorySummary(
|
||||
await githubRequest(token, `/repos/${repositoryPath(repository)}`),
|
||||
);
|
||||
}
|
||||
|
||||
async function contributorProfilesByNodeId(token, contributors) {
|
||||
const ids = contributors
|
||||
.map((contributor) => contributor.node_id)
|
||||
.filter(Boolean);
|
||||
if (!ids.length) return new Map();
|
||||
|
||||
const payload = await githubRequest(token, "/graphql", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
query: `
|
||||
query ContributorProfiles($ids: [ID!]!) {
|
||||
nodes(ids: $ids) {
|
||||
id
|
||||
... on User {
|
||||
login
|
||||
name
|
||||
}
|
||||
... on Bot {
|
||||
login
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { ids },
|
||||
}),
|
||||
});
|
||||
if (payload?.errors?.length) {
|
||||
throw new GitHubApiError(
|
||||
payload.errors[0]?.message ?? "GitHub could not load contributor profiles.",
|
||||
502,
|
||||
payload.errors,
|
||||
);
|
||||
}
|
||||
if (!Array.isArray(payload?.data?.nodes)) {
|
||||
throw new GitHubApiError("GitHub returned invalid contributor profile data.", 502, payload);
|
||||
}
|
||||
|
||||
return new Map(
|
||||
payload.data.nodes
|
||||
.filter((profile) => profile?.id)
|
||||
.map((profile) => [profile.id, profile]),
|
||||
);
|
||||
}
|
||||
|
||||
export async function listRepositoryContributors(token, repository) {
|
||||
const contributors = (
|
||||
await githubRequest(
|
||||
token,
|
||||
`/repos/${repositoryPath(repository)}/contributors?anon=0&per_page=100`,
|
||||
)
|
||||
).filter((contributor) => contributor?.login);
|
||||
const profilesById = await contributorProfilesByNodeId(token, contributors);
|
||||
|
||||
return contributors
|
||||
.map((contributor) => ({
|
||||
login: contributor.login,
|
||||
name:
|
||||
typeof profilesById.get(contributor.node_id)?.name === "string"
|
||||
? profilesById.get(contributor.node_id).name.trim() || null
|
||||
: null,
|
||||
avatarUrl: contributor.avatar_url ?? null,
|
||||
profileUrl: contributor.html_url ?? null,
|
||||
type: contributor.type ?? null,
|
||||
contributions: Number(contributor.contributions) || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function suggestRepositories(token, query) {
|
||||
const value = String(query ?? "").trim();
|
||||
if (!value || value.length > 100) return [];
|
||||
|
||||
const slash = value.indexOf("/");
|
||||
if (slash > 0) {
|
||||
const owner = value.slice(0, slash).trim();
|
||||
const partial = value.slice(slash + 1).trim();
|
||||
if (!/^[A-Za-z0-9_.-]+$/.test(owner)) return [];
|
||||
|
||||
const profile = await githubRequest(token, `/users/${encodeURIComponent(owner)}`);
|
||||
if (partial) {
|
||||
const qualifier = profile.type === "Organization" ? "org" : "user";
|
||||
const result = await githubRequest(
|
||||
token,
|
||||
`/search/repositories?q=${encodeURIComponent(`${partial} in:name ${qualifier}:${owner}`)}&sort=updated&order=desc&per_page=12`,
|
||||
);
|
||||
return (result.items ?? []).map(repositorySummary);
|
||||
}
|
||||
|
||||
const endpoint =
|
||||
profile.type === "Organization"
|
||||
? `/orgs/${encodeURIComponent(owner)}/repos?type=all&sort=updated&direction=desc&per_page=20`
|
||||
: `/users/${encodeURIComponent(owner)}/repos?type=all&sort=updated&direction=desc&per_page=20`;
|
||||
return (await githubRequest(token, endpoint)).map(repositorySummary);
|
||||
}
|
||||
|
||||
if (value.length < 2) return [];
|
||||
const result = await githubRequest(
|
||||
token,
|
||||
`/search/repositories?q=${encodeURIComponent(`${value} in:name`)}&sort=updated&order=desc&per_page=12`,
|
||||
);
|
||||
return (result.items ?? []).map(repositorySummary);
|
||||
}
|
||||
|
||||
function mapPullRequest(item) {
|
||||
return {
|
||||
number: item.number,
|
||||
title: item.title,
|
||||
state: item.state,
|
||||
draft: Boolean(item.draft),
|
||||
url: item.html_url,
|
||||
author: item.user?.login ?? "ghost",
|
||||
authorAvatarUrl: item.user?.avatar_url ?? null,
|
||||
authorType: item.user?.type ?? null,
|
||||
authorAssociation: item.author_association ?? null,
|
||||
comments: Number(item.comments) || 0,
|
||||
createdAt: item.created_at,
|
||||
updatedAt: item.updated_at,
|
||||
closedAt: item.closed_at,
|
||||
mergedAt: item.merged_at ?? null,
|
||||
headSha: item.head?.sha ?? null,
|
||||
headRef: item.head?.ref ?? null,
|
||||
baseRef: item.base?.ref ?? null,
|
||||
labels: (item.labels ?? []).map((label) => ({
|
||||
name: label.name,
|
||||
color: label.color,
|
||||
description: label.description ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function mapSearchPullRequest(item) {
|
||||
return {
|
||||
number: item.number,
|
||||
title: item.title,
|
||||
state: item.state,
|
||||
draft: Boolean(item.draft),
|
||||
url: item.html_url,
|
||||
author: item.user?.login ?? "ghost",
|
||||
authorAvatarUrl: item.user?.avatar_url ?? null,
|
||||
authorType: item.user?.type ?? null,
|
||||
authorAssociation: item.author_association ?? null,
|
||||
comments: Number(item.comments) || 0,
|
||||
createdAt: item.created_at,
|
||||
updatedAt: item.updated_at,
|
||||
closedAt: item.closed_at,
|
||||
mergedAt: item.pull_request?.merged_at ?? null,
|
||||
headSha: null,
|
||||
headRef: null,
|
||||
baseRef: null,
|
||||
labels: (item.labels ?? []).map((label) => ({
|
||||
name: label.name,
|
||||
color: label.color,
|
||||
description: label.description ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePullFilters(filters = {}) {
|
||||
return {
|
||||
artifacts: PULL_ARTIFACT_FILTERS.has(filters.artifacts) ? filters.artifacts : "all",
|
||||
ci: PULL_CI_FILTERS.has(filters.ci) ? filters.ci : "all",
|
||||
};
|
||||
}
|
||||
|
||||
function ciStatusForRollup(state) {
|
||||
if (state === "FAILURE" || state === "ERROR") return "failing";
|
||||
if (state === "SUCCESS") return "passing";
|
||||
if (state === "PENDING" || state === "EXPECTED") return "pending";
|
||||
return "none";
|
||||
}
|
||||
|
||||
async function pullRequestSignals(token, repository, pulls) {
|
||||
if (!pulls.length) return new Map();
|
||||
const [owner, name] = normalizeRepository(repository).split("/");
|
||||
const selections = pulls
|
||||
.map(
|
||||
(pull, index) => `
|
||||
pull${index}: pullRequest(number: ${Number(pull.number)}) {
|
||||
number
|
||||
isDraft
|
||||
headRefOid
|
||||
reviewDecision
|
||||
totalCommentsCount
|
||||
commits(last: 1) {
|
||||
nodes {
|
||||
commit {
|
||||
statusCheckRollup {
|
||||
state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
)
|
||||
.join("\n");
|
||||
const payload = await githubRequest(token, "/graphql", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
query: `
|
||||
query PullRequestSignals($owner: String!, $name: String!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
${selections}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { owner, name },
|
||||
}),
|
||||
});
|
||||
if (payload?.errors?.length) {
|
||||
throw new GitHubApiError(
|
||||
payload.errors[0]?.message ?? "GitHub could not load pull request signals.",
|
||||
502,
|
||||
payload.errors,
|
||||
);
|
||||
}
|
||||
const result = payload?.data?.repository;
|
||||
if (!result || typeof result !== "object") {
|
||||
throw new GitHubApiError("GitHub returned invalid pull request signal data.", 502, payload);
|
||||
}
|
||||
|
||||
return new Map(
|
||||
pulls.map((pull, index) => {
|
||||
const signal = result[`pull${index}`];
|
||||
if (!signal) {
|
||||
throw new GitHubApiError(
|
||||
`GitHub did not return signal data for pull request #${pull.number}.`,
|
||||
502,
|
||||
payload,
|
||||
);
|
||||
}
|
||||
const rollupState =
|
||||
signal.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state ?? null;
|
||||
return [
|
||||
Number(pull.number),
|
||||
{
|
||||
draft: Boolean(signal.isDraft),
|
||||
headSha: signal.headRefOid ?? null,
|
||||
ciStatus: ciStatusForRollup(rollupState),
|
||||
reviewDecision: signal.reviewDecision ?? null,
|
||||
comments: Number(signal.totalCommentsCount) || 0,
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function findArtifactHeads(token, repository) {
|
||||
const heads = new Set();
|
||||
let artifactCount = 0;
|
||||
for (let page = 1; ; page++) {
|
||||
const payload = await githubRequest(
|
||||
token,
|
||||
`/repos/${repositoryPath(repository)}/actions/artifacts?per_page=100&page=${page}`,
|
||||
);
|
||||
const artifacts = payload.artifacts ?? [];
|
||||
artifactCount += artifacts.length;
|
||||
for (const artifact of artifacts) {
|
||||
const headSha = artifact.workflow_run?.head_sha;
|
||||
if (headSha) heads.add(headSha);
|
||||
}
|
||||
const totalCount = Number(payload.total_count) || 0;
|
||||
if (artifacts.length < 100 || artifactCount >= totalCount) return heads;
|
||||
}
|
||||
}
|
||||
|
||||
function pruneArtifactPresenceCache() {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of artifactPresenceCache) {
|
||||
if (entry.positiveExpiresAt <= now) artifactPresenceCache.delete(key);
|
||||
}
|
||||
while (artifactPresenceCache.size >= ARTIFACT_PRESENCE_CACHE_LIMIT) {
|
||||
artifactPresenceCache.delete(artifactPresenceCache.keys().next().value);
|
||||
}
|
||||
}
|
||||
|
||||
async function artifactHeadsForPulls(token, repository, headShas) {
|
||||
const requestedHeads = new Set(headShas.filter(Boolean));
|
||||
if (!requestedHeads.size) return new Set();
|
||||
const key = normalizeRepository(repository).toLocaleLowerCase();
|
||||
const now = Date.now();
|
||||
const cached = artifactPresenceCache.get(key);
|
||||
if (cached?.missingExpiresAt > now) return cached.promise;
|
||||
if (cached?.positiveExpiresAt > now) {
|
||||
const cachedHeads = await cached.promise;
|
||||
if ([...requestedHeads].every((headSha) => cachedHeads.has(headSha))) {
|
||||
return cachedHeads;
|
||||
}
|
||||
}
|
||||
|
||||
pruneArtifactPresenceCache();
|
||||
const entry = {
|
||||
missingExpiresAt: now + MISSING_ARTIFACT_TTL_MS,
|
||||
positiveExpiresAt: now + ARTIFACT_PRESENCE_TTL_MS,
|
||||
promise: findArtifactHeads(token, repository),
|
||||
};
|
||||
artifactPresenceCache.set(key, entry);
|
||||
try {
|
||||
return await entry.promise;
|
||||
} catch (error) {
|
||||
if (artifactPresenceCache.get(key) === entry) {
|
||||
artifactPresenceCache.delete(key);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listPullRequests(token, repository, state = "open") {
|
||||
const normalizedState = ["open", "closed", "all"].includes(state) ? state : "open";
|
||||
const query = new URLSearchParams({
|
||||
state: normalizedState,
|
||||
sort: "updated",
|
||||
direction: "desc",
|
||||
per_page: "50",
|
||||
});
|
||||
const payload = await githubRequest(
|
||||
token,
|
||||
`/repos/${repositoryPath(repository)}/pulls?${query}`,
|
||||
);
|
||||
return payload.map(mapPullRequest);
|
||||
}
|
||||
|
||||
function normalizePullSearchQuery(filter, state) {
|
||||
const normalizedState = ["open", "closed", "all"].includes(state) ? state : "open";
|
||||
let query = String(filter ?? "")
|
||||
.trim()
|
||||
.slice(0, 500)
|
||||
.replace(/(^|\s)(?:repo|org|user):(?:"[^"]+"|\S+)/gi, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (!/\bis:pr\b/i.test(query)) query = `is:pr ${query}`.trim();
|
||||
if (
|
||||
normalizedState !== "all" &&
|
||||
!/\bis:(?:open|closed|merged|unmerged)\b/i.test(query)
|
||||
) {
|
||||
query = `${query} is:${normalizedState}`;
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
export async function searchPullRequestBase(
|
||||
token,
|
||||
repository,
|
||||
filter = "",
|
||||
state = "open",
|
||||
filters = {},
|
||||
{ perPage = 100 } = {},
|
||||
) {
|
||||
const normalizedFilters = normalizePullFilters(filters);
|
||||
const query = normalizePullSearchQuery(filter, state);
|
||||
const normalizedPerPage = Math.max(1, Math.min(100, Number(perPage) || 100));
|
||||
const parameters = new URLSearchParams({
|
||||
q: `repo:${normalizeRepository(repository)} ${query}`,
|
||||
sort: "updated",
|
||||
order: "desc",
|
||||
per_page: String(normalizedPerPage),
|
||||
});
|
||||
const payload = await githubRequest(token, `/search/issues?${parameters}`);
|
||||
const sourcePulls = (payload.items ?? []).map(mapSearchPullRequest);
|
||||
return {
|
||||
query,
|
||||
totalCount: Number(payload.total_count) || 0,
|
||||
incomplete: payload.incomplete_results === true,
|
||||
filters: normalizedFilters,
|
||||
evaluatedCount: sourcePulls.length,
|
||||
filtered: false,
|
||||
pulls: sourcePulls,
|
||||
};
|
||||
}
|
||||
|
||||
export async function enrichPullRequests(token, repository, pulls, filters = {}) {
|
||||
const normalizedFilters = normalizePullFilters(filters);
|
||||
let enriched = pulls;
|
||||
if (pulls.length) {
|
||||
const signals = await pullRequestSignals(token, repository, pulls);
|
||||
enriched = pulls.map((pull) => ({
|
||||
...pull,
|
||||
...signals.get(Number(pull.number)),
|
||||
}));
|
||||
}
|
||||
if (normalizedFilters.artifacts !== "all") {
|
||||
const artifactHeads = await artifactHeadsForPulls(
|
||||
token,
|
||||
repository,
|
||||
enriched.map((pull) => pull.headSha),
|
||||
);
|
||||
enriched = enriched.map((pull) => ({
|
||||
...pull,
|
||||
hasArtifacts: Boolean(pull.headSha && artifactHeads.has(pull.headSha)),
|
||||
}));
|
||||
}
|
||||
return enriched;
|
||||
}
|
||||
|
||||
export function filterPullRequests(pulls, filters = {}) {
|
||||
const normalizedFilters = normalizePullFilters(filters);
|
||||
let filtered = pulls;
|
||||
if (normalizedFilters.ci !== "all") {
|
||||
filtered = filtered.filter((pull) => pull.ciStatus === normalizedFilters.ci);
|
||||
}
|
||||
if (normalizedFilters.artifacts !== "all") {
|
||||
const expected = normalizedFilters.artifacts === "with";
|
||||
filtered = filtered.filter((pull) => pull.hasArtifacts === expected);
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
export async function searchPullRequests(
|
||||
token,
|
||||
repository,
|
||||
filter = "",
|
||||
state = "open",
|
||||
filters = {},
|
||||
) {
|
||||
const base = await searchPullRequestBase(
|
||||
token,
|
||||
repository,
|
||||
filter,
|
||||
state,
|
||||
filters,
|
||||
);
|
||||
const enriched = await enrichPullRequests(
|
||||
token,
|
||||
repository,
|
||||
base.pulls,
|
||||
base.filters,
|
||||
);
|
||||
const derivedFilterActive =
|
||||
base.filters.artifacts !== "all" || base.filters.ci !== "all";
|
||||
return {
|
||||
...base,
|
||||
filtered: derivedFilterActive,
|
||||
pulls: filterPullRequests(enriched, base.filters),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPullRequest(token, repository, pullNumber) {
|
||||
const number = Number.parseInt(pullNumber, 10);
|
||||
if (!Number.isSafeInteger(number) || number <= 0) {
|
||||
throw new Error("Pull request number must be a positive integer.");
|
||||
}
|
||||
return mapPullRequest(
|
||||
await githubRequest(token, `/repos/${repositoryPath(repository)}/pulls/${number}`),
|
||||
);
|
||||
}
|
||||
|
||||
async function mapLimit(items, limit, callback) {
|
||||
const results = new Array(items.length);
|
||||
let cursor = 0;
|
||||
async function worker() {
|
||||
while (cursor < items.length) {
|
||||
const index = cursor++;
|
||||
results[index] = await callback(items[index], index);
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(limit, items.length) }, () => worker()),
|
||||
);
|
||||
return results;
|
||||
}
|
||||
|
||||
async function listRunArtifacts(token, repository, runId) {
|
||||
const artifacts = [];
|
||||
for (let page = 1; page <= 10; page++) {
|
||||
const payload = await githubRequest(
|
||||
token,
|
||||
`/repos/${repositoryPath(repository)}/actions/runs/${runId}/artifacts?per_page=100&page=${page}`,
|
||||
);
|
||||
const pageArtifacts = payload.artifacts ?? [];
|
||||
artifacts.push(...pageArtifacts);
|
||||
if (pageArtifacts.length < 100 || artifacts.length >= Number(payload.total_count ?? 0)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return artifacts;
|
||||
}
|
||||
|
||||
export async function listPullRequestArtifacts(token, repository, pullNumber) {
|
||||
const pullRequest = await getPullRequest(token, repository, pullNumber);
|
||||
if (!pullRequest.headSha) {
|
||||
return {
|
||||
pullRequest,
|
||||
runs: [],
|
||||
runCount: 0,
|
||||
runsTruncated: false,
|
||||
artifacts: [],
|
||||
artifactCount: 0,
|
||||
artifactsTruncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
const query = new URLSearchParams({
|
||||
head_sha: pullRequest.headSha,
|
||||
per_page: String(MAX_PULL_REQUEST_RUNS),
|
||||
});
|
||||
const runsPayload = await githubRequest(
|
||||
token,
|
||||
`/repos/${repositoryPath(repository)}/actions/runs?${query}`,
|
||||
);
|
||||
const returnedRuns = (runsPayload.workflow_runs ?? [])
|
||||
.sort((left, right) => new Date(right.created_at) - new Date(left.created_at));
|
||||
const runCount = Math.max(
|
||||
returnedRuns.length,
|
||||
Number(runsPayload.total_count) || 0,
|
||||
);
|
||||
const runs = returnedRuns.slice(0, MAX_PULL_REQUEST_RUNS);
|
||||
const runsTruncated = runs.length < runCount;
|
||||
|
||||
const artifactGroups = await mapLimit(runs, 5, async (run) => {
|
||||
const runArtifacts = await listRunArtifacts(token, repository, run.id);
|
||||
return runArtifacts.map((artifact) => ({
|
||||
id: artifact.id,
|
||||
name: artifact.name,
|
||||
sizeInBytes: artifact.size_in_bytes,
|
||||
expired: Boolean(artifact.expired),
|
||||
createdAt: artifact.created_at,
|
||||
updatedAt: artifact.updated_at,
|
||||
expiresAt: artifact.expires_at,
|
||||
run: {
|
||||
id: run.id,
|
||||
name: run.name,
|
||||
number: run.run_number,
|
||||
attempt: run.run_attempt,
|
||||
event: run.event,
|
||||
status: run.status,
|
||||
conclusion: run.conclusion,
|
||||
createdAt: run.created_at,
|
||||
url: run.html_url,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
const allArtifacts = [...new Map(
|
||||
artifactGroups.flat().map((artifact) => [String(artifact.id), artifact]),
|
||||
).values()].sort((left, right) => new Date(right.createdAt) - new Date(left.createdAt));
|
||||
const artifacts = allArtifacts.slice(0, MAX_PULL_REQUEST_ARTIFACTS);
|
||||
|
||||
return {
|
||||
pullRequest,
|
||||
runs: runs.map((run) => ({
|
||||
id: run.id,
|
||||
name: run.name,
|
||||
number: run.run_number,
|
||||
attempt: run.run_attempt,
|
||||
event: run.event,
|
||||
status: run.status,
|
||||
conclusion: run.conclusion,
|
||||
createdAt: run.created_at,
|
||||
url: run.html_url,
|
||||
})),
|
||||
runCount,
|
||||
runsTruncated,
|
||||
artifacts,
|
||||
artifactCount: allArtifacts.length,
|
||||
artifactsTruncated:
|
||||
runsTruncated || artifacts.length < allArtifacts.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getArtifact(token, repository, artifactId) {
|
||||
const id = Number.parseInt(artifactId, 10);
|
||||
if (!Number.isSafeInteger(id) || id <= 0) {
|
||||
throw new Error("Artifact id must be a positive integer.");
|
||||
}
|
||||
const artifact = await githubRequest(
|
||||
token,
|
||||
`/repos/${repositoryPath(repository)}/actions/artifacts/${id}`,
|
||||
);
|
||||
return {
|
||||
id: artifact.id,
|
||||
name: artifact.name,
|
||||
sizeInBytes: artifact.size_in_bytes,
|
||||
expired: Boolean(artifact.expired),
|
||||
createdAt: artifact.created_at,
|
||||
updatedAt: artifact.updated_at,
|
||||
expiresAt: artifact.expires_at,
|
||||
run: artifact.workflow_run
|
||||
? {
|
||||
id: artifact.workflow_run.id,
|
||||
headBranch: artifact.workflow_run.head_branch,
|
||||
headSha: artifact.workflow_run.head_sha,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function openArtifactDownload(
|
||||
token,
|
||||
repository,
|
||||
artifactId,
|
||||
{ signal } = {},
|
||||
) {
|
||||
if (!token) throw new GitHubApiError("No GitHub account is selected.", 401);
|
||||
const id = Number.parseInt(artifactId, 10);
|
||||
const response = await fetch(
|
||||
`${GITHUB_API}/repos/${repositoryPath(repository)}/actions/artifacts/${id}/zip`,
|
||||
{
|
||||
headers: headersFor(token),
|
||||
redirect: "follow",
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const payload = await readPayload(response);
|
||||
const message =
|
||||
payload && typeof payload === "object" && payload.message
|
||||
? payload.message
|
||||
: `Artifact download failed (${response.status}).`;
|
||||
throw new GitHubApiError(message, response.status, payload);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new GitHubApiError("Artifact download returned an empty response.", response.status);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
export class ExpiringPromiseCache {
|
||||
#entries = new Map();
|
||||
#maxEntries;
|
||||
#ttlMs;
|
||||
|
||||
constructor({ maxEntries, ttlMs }) {
|
||||
if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
|
||||
throw new TypeError("maxEntries must be a positive integer.");
|
||||
}
|
||||
if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
|
||||
throw new TypeError("ttlMs must be a positive number.");
|
||||
}
|
||||
this.#maxEntries = maxEntries;
|
||||
this.#ttlMs = ttlMs;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.#entries.clear();
|
||||
}
|
||||
|
||||
delete(key) {
|
||||
this.#entries.delete(String(key));
|
||||
}
|
||||
|
||||
peek(key) {
|
||||
const normalizedKey = String(key);
|
||||
const entry = this.#entries.get(normalizedKey);
|
||||
if (!entry?.hasValue || entry.expiresAt <= Date.now()) {
|
||||
if (!entry?.promise) this.#entries.delete(normalizedKey);
|
||||
return undefined;
|
||||
}
|
||||
this.#touch(normalizedKey, entry);
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
set(key, value) {
|
||||
const normalizedKey = String(key);
|
||||
const entry = {
|
||||
expiresAt: Date.now() + this.#ttlMs,
|
||||
forceRefresh: false,
|
||||
hasValue: true,
|
||||
promise: null,
|
||||
value,
|
||||
};
|
||||
this.#entries.set(normalizedKey, entry);
|
||||
this.#touch(normalizedKey, entry);
|
||||
this.#enforceLimit();
|
||||
return value;
|
||||
}
|
||||
|
||||
async get(key, loader, { force = false } = {}) {
|
||||
if (typeof loader !== "function") throw new TypeError("loader must be a function.");
|
||||
const normalizedKey = String(key);
|
||||
const now = Date.now();
|
||||
let entry = this.#entries.get(normalizedKey);
|
||||
if (!force && entry?.hasValue && entry.expiresAt > now) {
|
||||
this.#touch(normalizedKey, entry);
|
||||
return entry.value;
|
||||
}
|
||||
if (entry?.promise && (!force || entry.forceRefresh)) return entry.promise;
|
||||
|
||||
this.#prune(now);
|
||||
if (force) entry = null;
|
||||
entry ??= {
|
||||
expiresAt: 0,
|
||||
forceRefresh: false,
|
||||
hasValue: false,
|
||||
promise: null,
|
||||
value: undefined,
|
||||
};
|
||||
const promise = Promise.resolve()
|
||||
.then(loader)
|
||||
.then(
|
||||
(value) => {
|
||||
if (
|
||||
this.#entries.get(normalizedKey) === entry &&
|
||||
entry.promise === promise
|
||||
) {
|
||||
entry.expiresAt = Date.now() + this.#ttlMs;
|
||||
entry.forceRefresh = false;
|
||||
entry.hasValue = true;
|
||||
entry.promise = null;
|
||||
entry.value = value;
|
||||
this.#touch(normalizedKey, entry);
|
||||
this.#enforceLimit();
|
||||
}
|
||||
return value;
|
||||
},
|
||||
(error) => {
|
||||
if (
|
||||
this.#entries.get(normalizedKey) === entry &&
|
||||
entry.promise === promise
|
||||
) {
|
||||
entry.forceRefresh = false;
|
||||
entry.promise = null;
|
||||
if (!entry.hasValue || entry.expiresAt <= Date.now()) {
|
||||
this.#entries.delete(normalizedKey);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
entry.forceRefresh = force;
|
||||
entry.promise = promise;
|
||||
this.#entries.set(normalizedKey, entry);
|
||||
this.#enforceLimit();
|
||||
return promise;
|
||||
}
|
||||
|
||||
#touch(key, entry) {
|
||||
this.#entries.delete(key);
|
||||
this.#entries.set(key, entry);
|
||||
}
|
||||
|
||||
#prune(now) {
|
||||
for (const [key, entry] of this.#entries) {
|
||||
if (!entry.promise && (!entry.hasValue || entry.expiresAt <= now)) {
|
||||
this.#entries.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#enforceLimit() {
|
||||
while (this.#entries.size > this.#maxEntries) {
|
||||
const candidate = [...this.#entries].find(([, entry]) => !entry.promise);
|
||||
if (!candidate) return;
|
||||
this.#entries.delete(candidate[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { createServer } from "node:http";
|
||||
import { posix } from "node:path";
|
||||
import { Transform } from "node:stream";
|
||||
import { getCachedEntry } from "./cache.mjs";
|
||||
import { mimeForPath } from "./detector.mjs";
|
||||
import { isCanonicalHost } from "./security.mjs";
|
||||
import { streamZipEntry } from "./zip.mjs";
|
||||
|
||||
const previewServers = new Map();
|
||||
const HTML_INJECTION_SEARCH_BYTES = 64 * 1024;
|
||||
|
||||
const PREVIEW_CSP = [
|
||||
"default-src 'self' data: blob:",
|
||||
"base-uri 'self'",
|
||||
"connect-src 'self'",
|
||||
"font-src 'self' data:",
|
||||
"form-action 'none'",
|
||||
"frame-src 'self' data: blob:",
|
||||
"img-src 'self' data: blob:",
|
||||
"media-src 'self' data: blob:",
|
||||
"object-src 'none'",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' blob:",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"worker-src 'self' blob:",
|
||||
].join("; ");
|
||||
|
||||
function normalizeTheme(value) {
|
||||
if (value !== "light" && value !== "dark") {
|
||||
throw new Error("Preview theme must be light or dark.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeParentOrigin(value) {
|
||||
const origin = new URL(value);
|
||||
if (origin.protocol !== "http:" || origin.hostname !== "127.0.0.1") {
|
||||
throw new Error("Preview parent must use a loopback origin.");
|
||||
}
|
||||
return origin.origin;
|
||||
}
|
||||
|
||||
function themeBridgeMarkup(theme, parentOrigin) {
|
||||
const initialTheme = JSON.stringify(theme);
|
||||
const expectedOrigin = JSON.stringify(parentOrigin);
|
||||
return `<script data-copilot-preview-theme>(()=>{const expectedOrigin=${expectedOrigin};const apply=theme=>{if(theme!=="light"&&theme!=="dark")return;const root=document.documentElement;const modeClass=theme+"-mode";const usesPlainMode=root.classList.contains("light")||root.classList.contains("dark");root.dataset.colorMode=theme;root.dataset.theme=theme;root.setAttribute("data-color-scheme",theme);root.setAttribute("data-bs-theme",theme);root.style.colorScheme=theme;root.classList.remove("light-mode","dark-mode");root.classList.add(modeClass);if(usesPlainMode){root.classList.toggle("light",theme==="light");root.classList.toggle("dark",theme==="dark")}try{localStorage.setItem("theme",modeClass)}catch{}};apply(${initialTheme});addEventListener("message",event=>{if(event.source!==window.parent||event.origin!==expectedOrigin||event.data?.type!=="copilot-preview-theme")return;apply(event.data.theme)});})();</script>`;
|
||||
}
|
||||
|
||||
function insertionOffset(buffer, fallback = false) {
|
||||
const source = buffer.toString("utf8");
|
||||
const element = /<html(?:\s[^>]*)?>/i.exec(source) ?? /<head(?:\s[^>]*)?>/i.exec(source);
|
||||
if (element) {
|
||||
return Buffer.byteLength(
|
||||
source.slice(0, element.index + element[0].length),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
if (!fallback) return null;
|
||||
const doctype = /<!doctype[^>]*>/i.exec(source);
|
||||
return doctype
|
||||
? Buffer.byteLength(source.slice(0, doctype.index + doctype[0].length), "utf8")
|
||||
: 0;
|
||||
}
|
||||
|
||||
function createHtmlInjectionTransform(markup) {
|
||||
const injection = Buffer.from(markup, "utf8");
|
||||
let pending = Buffer.alloc(0);
|
||||
let injected = false;
|
||||
|
||||
return new Transform({
|
||||
transform(chunk, _encoding, callback) {
|
||||
if (injected) {
|
||||
callback(null, chunk);
|
||||
return;
|
||||
}
|
||||
pending = Buffer.concat([pending, Buffer.from(chunk)]);
|
||||
const offset = insertionOffset(pending);
|
||||
if (offset !== null || pending.length >= HTML_INJECTION_SEARCH_BYTES) {
|
||||
const resolvedOffset = offset ?? insertionOffset(pending, true);
|
||||
this.push(pending.subarray(0, resolvedOffset));
|
||||
this.push(injection);
|
||||
this.push(pending.subarray(resolvedOffset));
|
||||
pending = Buffer.alloc(0);
|
||||
injected = true;
|
||||
}
|
||||
callback();
|
||||
},
|
||||
flush(callback) {
|
||||
if (!injected) {
|
||||
const offset = insertionOffset(pending, true);
|
||||
this.push(pending.subarray(0, offset));
|
||||
this.push(injection);
|
||||
this.push(pending.subarray(offset));
|
||||
}
|
||||
callback();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function decodeRequestPath(pathname) {
|
||||
try {
|
||||
return pathname
|
||||
.split("/")
|
||||
.map((part) => decodeURIComponent(part))
|
||||
.join("/")
|
||||
.replace(/^\/+/, "");
|
||||
} catch (error) {
|
||||
throw Object.assign(
|
||||
new Error("Preview URL contains invalid encoding.", { cause: error }),
|
||||
{ statusCode: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function safeRelativePath(path) {
|
||||
if (!path) return "";
|
||||
const normalized = posix.normalize(path.replaceAll("\\", "/"));
|
||||
if (
|
||||
normalized === ".." ||
|
||||
normalized.startsWith("../") ||
|
||||
normalized.startsWith("/") ||
|
||||
/^[A-Za-z]:\//.test(normalized)
|
||||
) {
|
||||
throw Object.assign(new Error("Preview path escapes the artifact root."), { statusCode: 400 });
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function servePreviewRequest(
|
||||
artifactId,
|
||||
root,
|
||||
entryName,
|
||||
theme,
|
||||
parentOrigin,
|
||||
serverEntry,
|
||||
req,
|
||||
res,
|
||||
) {
|
||||
if (!isCanonicalHost(req, serverEntry.host)) {
|
||||
res.writeHead(403, {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Content-Security-Policy": PREVIEW_CSP,
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
});
|
||||
res.end("Preview host is not allowed.");
|
||||
return;
|
||||
}
|
||||
if (!["GET", "HEAD"].includes(req.method)) {
|
||||
res.writeHead(405, { Allow: "GET, HEAD" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(req.url, "http://127.0.0.1");
|
||||
let relative = safeRelativePath(decodeRequestPath(url.pathname));
|
||||
if (!relative || relative.endsWith("/")) {
|
||||
relative = `${relative}${posix.basename(entryName)}`;
|
||||
}
|
||||
const requestedEntry = safeRelativePath(root ? posix.join(root, relative) : relative);
|
||||
const context = await getCachedEntry(artifactId, requestedEntry);
|
||||
if (!context.entry.supported) {
|
||||
res.writeHead(415, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("This file uses an unsupported ZIP encoding.");
|
||||
return;
|
||||
}
|
||||
const contentType = mimeForPath(context.entry.name);
|
||||
const themeBridge = contentType.startsWith("text/html")
|
||||
? themeBridgeMarkup(theme, parentOrigin)
|
||||
: "";
|
||||
res.writeHead(200, {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Length": String(
|
||||
context.entry.uncompressedSize + Buffer.byteLength(themeBridge),
|
||||
),
|
||||
"Content-Security-Policy": PREVIEW_CSP,
|
||||
"Content-Type": contentType,
|
||||
"Cross-Origin-Resource-Policy": "same-origin",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
});
|
||||
if (req.method === "HEAD") {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (themeBridge && context.entry.uncompressedSize === 0) {
|
||||
res.end(themeBridge);
|
||||
return;
|
||||
}
|
||||
await streamZipEntry(
|
||||
context.archivePath,
|
||||
context.entry,
|
||||
res,
|
||||
themeBridge ? createHtmlInjectionTransform(themeBridge) : null,
|
||||
);
|
||||
} catch (error) {
|
||||
if (!res.headersSent) {
|
||||
const status = error?.statusCode === 400 ? 400 : 404;
|
||||
res.writeHead(status, {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Content-Security-Policy": PREVIEW_CSP,
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
});
|
||||
}
|
||||
res.end(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
export async function startStaticPreview(artifactId, entryPath, options) {
|
||||
const context = await getCachedEntry(artifactId, entryPath);
|
||||
if (!context.entry.supported) {
|
||||
throw new Error("This file uses an unsupported ZIP encoding.");
|
||||
}
|
||||
if (!mimeForPath(context.entry.name).startsWith("text/html")) {
|
||||
throw new Error("Only HTML files can start a static preview.");
|
||||
}
|
||||
if (
|
||||
posix.dirname(context.entry.name) !== "." ||
|
||||
posix.basename(context.entry.name).toLowerCase() !== "index.html"
|
||||
) {
|
||||
throw new Error("Static previews require the root index.html file.");
|
||||
}
|
||||
const rootValue = posix.dirname(context.entry.name);
|
||||
const root = rootValue === "." ? "" : rootValue;
|
||||
const theme = normalizeTheme(options?.theme);
|
||||
const parentOrigin = normalizeParentOrigin(options?.parentOrigin);
|
||||
const key = JSON.stringify([
|
||||
String(artifactId),
|
||||
root,
|
||||
posix.basename(context.entry.name),
|
||||
theme,
|
||||
parentOrigin,
|
||||
]);
|
||||
const existing = previewServers.get(key);
|
||||
if (existing) return existing.url;
|
||||
|
||||
const serverEntry = { host: "" };
|
||||
const server = createServer((req, res) => {
|
||||
void servePreviewRequest(
|
||||
String(artifactId),
|
||||
root,
|
||||
context.entry.name,
|
||||
theme,
|
||||
parentOrigin,
|
||||
serverEntry,
|
||||
req,
|
||||
res,
|
||||
);
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
throw new Error("Static preview server did not receive a TCP port.");
|
||||
}
|
||||
serverEntry.host = `127.0.0.1:${address.port}`;
|
||||
const url = `http://127.0.0.1:${address.port}/`;
|
||||
previewServers.set(key, { artifactId: String(artifactId), server, url, canvasOrigin: parentOrigin });
|
||||
return url;
|
||||
}
|
||||
|
||||
export async function stopStaticPreviewsForArtifact(artifactId) {
|
||||
const id = String(artifactId);
|
||||
const matching = [...previewServers.entries()].filter(
|
||||
([, value]) => value.artifactId === id,
|
||||
);
|
||||
await Promise.all(
|
||||
matching.map(async ([key, value]) => {
|
||||
previewServers.delete(key);
|
||||
await new Promise((resolve) => value.server.close(resolve));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function stopStaticPreviewsForOrigin(canvasOrigin) {
|
||||
const matching = [...previewServers.entries()].filter(
|
||||
([, value]) => value.canvasOrigin === canvasOrigin,
|
||||
);
|
||||
await Promise.all(
|
||||
matching.map(async ([key, value]) => {
|
||||
previewServers.delete(key);
|
||||
await new Promise((resolve) => value.server.close(resolve));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function stopAllStaticPreviews() {
|
||||
const active = [...previewServers.values()];
|
||||
previewServers.clear();
|
||||
await Promise.all(
|
||||
active.map((value) => new Promise((resolve) => value.server.close(resolve))),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
const CAPABILITY_TOKEN_MARKER = "__PR_ARTIFACT_EXPLORER_CAPABILITY_TOKEN__";
|
||||
|
||||
function escapeHtmlAttribute(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
}
|
||||
|
||||
export const HTML = `<!doctype html>
|
||||
<html lang="en" data-color-mode="auto" data-light-theme="light" data-dark-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="pr-artifact-explorer-token" content="${CAPABILITY_TOKEN_MARKER}" />
|
||||
<title>PR Artifact Explorer</title>
|
||||
<link rel="icon" href="data:," />
|
||||
<link rel="stylesheet" href="/assets/primer-color-modes.css" />
|
||||
<link rel="stylesheet" href="/assets/primer-core.css" />
|
||||
<link rel="stylesheet" href="/assets/primer-product.css" />
|
||||
<link rel="stylesheet" href="/assets/asciinema-player.css" />
|
||||
<link rel="stylesheet" href="/assets/app.css" />
|
||||
<script defer src="/assets/asciinema-player.min.js"></script>
|
||||
<script defer src="/assets/trx-preview.js"></script>
|
||||
<script defer src="/assets/app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<header class="Header app-header">
|
||||
<a class="Header-item Header-link app-brand" href="#/" aria-label="Artifact Explorer home">
|
||||
<span class="octicon icon-mark-github app-brand-mark" aria-hidden="true"></span>
|
||||
<span>Artifact Explorer</span>
|
||||
</a>
|
||||
<form id="repository-form" class="Header-item Header-item--full repository-form" role="search" novalidate>
|
||||
<div id="repository-combobox" class="repository-combobox">
|
||||
<div class="repository-input-shell">
|
||||
<label class="sr-only" for="repository-input">Repository or pull request</label>
|
||||
<span class="octicon icon-search repository-search-icon" aria-hidden="true"></span>
|
||||
<input
|
||||
id="repository-input"
|
||||
class="Header-input repository-input"
|
||||
autocomplete="off"
|
||||
placeholder="Find a repository"
|
||||
spellcheck="false"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-controls="repository-results"
|
||||
aria-expanded="false"
|
||||
/>
|
||||
<button
|
||||
id="repository-menu-button"
|
||||
class="repository-menu-button"
|
||||
type="button"
|
||||
aria-label="Open repository quick switch"
|
||||
aria-controls="repository-panel"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<span class="octicon icon-chevron-down" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="repository-panel" class="repository-panel" hidden>
|
||||
<div class="repository-panel-header">
|
||||
<strong>Repositories</strong>
|
||||
<span>Pin one and favorite up to three</span>
|
||||
</div>
|
||||
<div id="repository-results" class="repository-results" role="listbox"></div>
|
||||
<div class="repository-panel-footer">Type <code>owner/</code> to browse that account.</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="Header-item header-cache-item">
|
||||
<a id="cache-link" class="Header-link cache-link" href="#/cache">
|
||||
<span class="octicon icon-package" aria-hidden="true"></span>
|
||||
<span id="cache-label">Cache</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="Header-item account-control">
|
||||
<a id="account-button" class="account-chip" href="#/accounts" aria-label="GitHub account" title="GitHub account">
|
||||
<span id="account-fallback" class="account-avatar-fallback">
|
||||
<span class="octicon icon-person" aria-hidden="true"></span>
|
||||
</span>
|
||||
<img id="account-avatar" class="account-avatar" alt="" hidden />
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
<nav id="breadcrumbs" class="breadcrumb-bar" aria-label="Breadcrumb"></nav>
|
||||
<main id="view" class="container-xl app-main" aria-live="polite"></main>
|
||||
<div id="toast-region" class="toast-region" role="status" aria-live="polite"></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
export function renderHtml(capabilityToken) {
|
||||
return HTML.replace(
|
||||
CAPABILITY_TOKEN_MARKER,
|
||||
escapeHtmlAttribute(capabilityToken),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
analyzeArtifact,
|
||||
hasRootIndexHtml,
|
||||
} from "./detector.mjs";
|
||||
import { CacheMaintenanceCoordinator } from "./cache-coordinator.mjs";
|
||||
import {
|
||||
enrichPullRequests,
|
||||
filterPullRequests,
|
||||
listPullRequestArtifacts,
|
||||
} from "./github.mjs";
|
||||
import { contentDeliveryMode, startInstance, stopInstance } from "./server.mjs";
|
||||
import {
|
||||
CAPABILITY_TOKEN_HEADER,
|
||||
hasCapabilityToken,
|
||||
isCanonicalHost,
|
||||
isCrossSiteRequest,
|
||||
requiresCapabilityToken,
|
||||
} from "./security.mjs";
|
||||
import {
|
||||
DEFAULT_PREFS,
|
||||
normalizeRepository,
|
||||
normalizePrefs,
|
||||
rememberRepository,
|
||||
} from "./state.mjs";
|
||||
|
||||
const here = new URL(".", import.meta.url);
|
||||
|
||||
function jsonResponse(payload, status = 200) {
|
||||
return {
|
||||
headers: { get: () => "application/json" },
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
text: async () => JSON.stringify(payload),
|
||||
};
|
||||
}
|
||||
|
||||
function rawStatus(url, headers) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = httpRequest(url, { headers }, (response) => {
|
||||
response.resume();
|
||||
response.on("end", () => resolve(response.statusCode));
|
||||
});
|
||||
request.on("error", reject);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((complete) => {
|
||||
resolve = complete;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
test("malformed recent repository values normalize to an empty list", () => {
|
||||
for (const recent of [{ unexpected: true }, 42]) {
|
||||
const preferences = normalizePrefs({
|
||||
repository: "github/awesome-copilot",
|
||||
repositories: { recent },
|
||||
});
|
||||
assert.deepEqual(preferences.repositories.recent, [
|
||||
"github/awesome-copilot",
|
||||
]);
|
||||
}
|
||||
|
||||
const fallback = normalizePrefs({
|
||||
repository: "not a repository",
|
||||
repositories: { recent: 42 },
|
||||
});
|
||||
assert.equal(fallback.repository, DEFAULT_PREFS.repository);
|
||||
assert.deepEqual(fallback.repositories.recent, [DEFAULT_PREFS.repository]);
|
||||
|
||||
const preferences = {
|
||||
repositories: { recent: { unexpected: true } },
|
||||
};
|
||||
rememberRepository(preferences, "github/awesome-copilot");
|
||||
assert.deepEqual(preferences.repositories.recent, [
|
||||
"github/awesome-copilot",
|
||||
]);
|
||||
});
|
||||
|
||||
test("artifact filtering pages repository artifacts once and caches head SHAs", async (t) => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const artifactRequests = [];
|
||||
t.after(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
globalThis.fetch = async (url, options = {}) => {
|
||||
const requestUrl = new URL(url);
|
||||
if (requestUrl.pathname === "/graphql") {
|
||||
const body = JSON.parse(options.body);
|
||||
assert.equal(body.variables.owner, "example");
|
||||
assert.equal(body.variables.name, "artifact-cache-test");
|
||||
return jsonResponse({
|
||||
data: {
|
||||
repository: {
|
||||
pull0: {
|
||||
headRefOid: "head-with-artifact",
|
||||
isDraft: false,
|
||||
reviewDecision: null,
|
||||
totalCommentsCount: 0,
|
||||
commits: { nodes: [] },
|
||||
},
|
||||
pull1: {
|
||||
headRefOid: "head-without-artifact",
|
||||
isDraft: false,
|
||||
reviewDecision: null,
|
||||
totalCommentsCount: 0,
|
||||
commits: { nodes: [] },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
requestUrl.pathname,
|
||||
"/repos/example/artifact-cache-test/actions/artifacts",
|
||||
);
|
||||
artifactRequests.push(requestUrl.search);
|
||||
const page = Number(requestUrl.searchParams.get("page"));
|
||||
if (page === 1) {
|
||||
return jsonResponse({
|
||||
total_count: 101,
|
||||
artifacts: Array.from({ length: 100 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
workflow_run: { head_sha: "unrelated-head" },
|
||||
})),
|
||||
});
|
||||
}
|
||||
assert.equal(page, 2);
|
||||
return jsonResponse({
|
||||
total_count: 101,
|
||||
artifacts: [
|
||||
{
|
||||
id: 101,
|
||||
workflow_run: { head_sha: "head-with-artifact" },
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const pulls = [{ number: 1 }, { number: 2 }];
|
||||
const filters = { artifacts: "with" };
|
||||
const first = await enrichPullRequests(
|
||||
"token",
|
||||
"example/artifact-cache-test",
|
||||
pulls,
|
||||
filters,
|
||||
);
|
||||
assert.deepEqual(
|
||||
first.map((pull) => pull.hasArtifacts),
|
||||
[true, false],
|
||||
);
|
||||
assert.deepEqual(
|
||||
filterPullRequests(first, filters).map((pull) => pull.number),
|
||||
[1],
|
||||
);
|
||||
assert.deepEqual(
|
||||
filterPullRequests(first, { artifacts: "without" }).map(
|
||||
(pull) => pull.number,
|
||||
),
|
||||
[2],
|
||||
);
|
||||
|
||||
await enrichPullRequests(
|
||||
"token",
|
||||
"example/artifact-cache-test",
|
||||
pulls,
|
||||
filters,
|
||||
);
|
||||
assert.equal(artifactRequests.length, 2);
|
||||
assert.match(artifactRequests[0], /per_page=100/);
|
||||
assert.match(artifactRequests[0], /page=1/);
|
||||
assert.match(artifactRequests[1], /page=2/);
|
||||
});
|
||||
|
||||
test("pull artifact results report the intentional workflow-run cap", async (t) => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const artifactRequests = [];
|
||||
t.after(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
globalThis.fetch = async (url) => {
|
||||
const requestUrl = new URL(url);
|
||||
if (requestUrl.pathname === "/repos/example/run-cap/pulls/7") {
|
||||
return jsonResponse({
|
||||
number: 7,
|
||||
title: "Run cap",
|
||||
state: "open",
|
||||
head: { sha: "head-sha", ref: "feature" },
|
||||
base: { ref: "main" },
|
||||
user: { login: "octocat" },
|
||||
labels: [],
|
||||
});
|
||||
}
|
||||
if (requestUrl.pathname === "/repos/example/run-cap/actions/runs") {
|
||||
assert.equal(requestUrl.searchParams.get("per_page"), "30");
|
||||
return jsonResponse({
|
||||
total_count: 35,
|
||||
workflow_runs: Array.from({ length: 30 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
name: "CI",
|
||||
run_number: index + 1,
|
||||
run_attempt: 1,
|
||||
event: "pull_request",
|
||||
status: "completed",
|
||||
conclusion: "success",
|
||||
created_at: new Date(Date.UTC(2026, 0, 1, 0, 0, 35 - index)).toISOString(),
|
||||
html_url: `https://github.com/example/run-cap/actions/runs/${index + 1}`,
|
||||
})),
|
||||
});
|
||||
}
|
||||
const artifactMatch = requestUrl.pathname.match(
|
||||
/^\/repos\/example\/run-cap\/actions\/runs\/(\d+)\/artifacts$/,
|
||||
);
|
||||
assert.ok(artifactMatch);
|
||||
artifactRequests.push(Number(artifactMatch[1]));
|
||||
return jsonResponse({
|
||||
total_count: 1,
|
||||
artifacts: [
|
||||
{
|
||||
id: Number(artifactMatch[1]) * 10,
|
||||
name: `artifact-${artifactMatch[1]}`,
|
||||
size_in_bytes: 100,
|
||||
expired: false,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
expires_at: "2026-04-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const result = await listPullRequestArtifacts(
|
||||
"token",
|
||||
"example/run-cap",
|
||||
7,
|
||||
);
|
||||
assert.equal(artifactRequests.length, 30);
|
||||
assert.equal(result.runs.length, 30);
|
||||
assert.equal(result.runCount, 35);
|
||||
assert.equal(result.runsTruncated, true);
|
||||
assert.equal(result.artifacts.length, 30);
|
||||
assert.equal(result.artifactCount, 30);
|
||||
assert.equal(result.artifactsTruncated, true);
|
||||
});
|
||||
|
||||
test("only the archive root index uses static-site preview routing", async () => {
|
||||
const source = await readFile(new URL("assets/app.js", here), "utf8");
|
||||
const helpers = source.slice(
|
||||
source.indexOf("function artifactEntryKind"),
|
||||
source.indexOf("function directoryPathsForFile"),
|
||||
);
|
||||
const shouldUseStaticSitePreview = Function(
|
||||
`${helpers}; return shouldUseStaticSitePreview;`,
|
||||
)();
|
||||
const root = { path: "index.html", kind: "html", supported: true };
|
||||
const nested = {
|
||||
path: "docs/index.html",
|
||||
kind: "html",
|
||||
supported: true,
|
||||
};
|
||||
const unsupported = {
|
||||
path: "index.html",
|
||||
kind: "html",
|
||||
supported: false,
|
||||
};
|
||||
const entries = [root, nested];
|
||||
|
||||
assert.equal(shouldUseStaticSitePreview(root, entries), true);
|
||||
assert.equal(shouldUseStaticSitePreview(nested, entries), false);
|
||||
assert.equal(shouldUseStaticSitePreview(nested, [nested]), false);
|
||||
assert.equal(
|
||||
shouldUseStaticSitePreview(unsupported, [unsupported]),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("completed TRX summaries derive their displayed outcome from counters", async () => {
|
||||
const source = await readFile(
|
||||
new URL("assets/trx-preview.js", here),
|
||||
"utf8",
|
||||
);
|
||||
const helpers = source.slice(
|
||||
source.indexOf("const KNOWN_TRX_OUTCOMES"),
|
||||
source.indexOf("function parseDuration"),
|
||||
);
|
||||
const summarizedRunOutcome = Function(
|
||||
`${helpers}; return summarizedRunOutcome;`,
|
||||
)();
|
||||
|
||||
assert.equal(
|
||||
summarizedRunOutcome({
|
||||
outcome: "Completed",
|
||||
summaryCounts: { failed: 1, passed: 4, skipped: 0 },
|
||||
}),
|
||||
"Failed",
|
||||
);
|
||||
assert.equal(
|
||||
summarizedRunOutcome({
|
||||
outcome: "Completed",
|
||||
summaryCounts: { failed: 0, passed: 5, skipped: 0 },
|
||||
}),
|
||||
"Passed",
|
||||
);
|
||||
assert.equal(
|
||||
summarizedRunOutcome({
|
||||
outcome: "Completed",
|
||||
summaryCounts: { failed: 0, passed: 0, skipped: 2 },
|
||||
}),
|
||||
"Skipped",
|
||||
);
|
||||
assert.equal(
|
||||
summarizedRunOutcome({
|
||||
outcome: "Completed",
|
||||
summaryCounts: { failed: 0, passed: 0, skipped: 0 },
|
||||
}),
|
||||
"Completed",
|
||||
);
|
||||
assert.equal(
|
||||
summarizedRunOutcome({
|
||||
outcome: "Failed",
|
||||
summaryCounts: { failed: 0, passed: 5, skipped: 0 },
|
||||
}),
|
||||
"Failed",
|
||||
);
|
||||
});
|
||||
|
||||
test("repository normalization rejects owner and name dot segments", () => {
|
||||
for (const repository of ["../user", "./repo", "owner/..", "owner/."]) {
|
||||
assert.throws(
|
||||
() => normalizeRepository(repository),
|
||||
/cannot be dot segments/,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
normalizeRepository("github/awesome-copilot"),
|
||||
"github/awesome-copilot",
|
||||
);
|
||||
});
|
||||
|
||||
test("unsupported entries remain listed but cannot become the primary preview", async () => {
|
||||
const analysis = await analyzeArtifact(
|
||||
{
|
||||
entries: [
|
||||
{
|
||||
name: "index.html",
|
||||
directory: false,
|
||||
supported: false,
|
||||
compressedSize: 10,
|
||||
uncompressedSize: 20,
|
||||
},
|
||||
{
|
||||
name: "session.cast",
|
||||
directory: false,
|
||||
supported: false,
|
||||
compressedSize: 10,
|
||||
uncompressedSize: 20,
|
||||
},
|
||||
{
|
||||
name: "results.trx",
|
||||
directory: false,
|
||||
supported: true,
|
||||
compressedSize: 10,
|
||||
uncompressedSize: 20,
|
||||
},
|
||||
],
|
||||
totalUncompressedBytes: 60,
|
||||
},
|
||||
async () => {
|
||||
throw new Error("Unsupported entries must not be probed.");
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(analysis.entries.length, 3);
|
||||
assert.deepEqual(analysis.primary, {
|
||||
kind: "trx",
|
||||
path: "results.trx",
|
||||
label: "Test results",
|
||||
});
|
||||
assert.equal(hasRootIndexHtml(analysis.entries), false);
|
||||
});
|
||||
|
||||
test("unsupported entry downloads fall back to the original artifact archive", () => {
|
||||
assert.equal(contentDeliveryMode({ supported: false }, false), "unsupported");
|
||||
assert.equal(contentDeliveryMode({ supported: false }, true), "archive");
|
||||
assert.equal(contentDeliveryMode({ supported: true }, true), "entry");
|
||||
});
|
||||
|
||||
test("cache maintenance blocks inspections during artifact deletion", async () => {
|
||||
const coordinator = new CacheMaintenanceCoordinator();
|
||||
const deletionEntered = deferred();
|
||||
const releaseDeletion = deferred();
|
||||
const deletion = coordinator.deleteArtifact("42", async () => {
|
||||
deletionEntered.resolve();
|
||||
await releaseDeletion.promise;
|
||||
return { deleted: "42" };
|
||||
});
|
||||
await deletionEntered.promise;
|
||||
|
||||
let inspectionStarted = false;
|
||||
const inspection = (async () => {
|
||||
while (true) {
|
||||
const barrier = coordinator.inspectionBarrier("42");
|
||||
if (!barrier) break;
|
||||
await barrier;
|
||||
}
|
||||
inspectionStarted = true;
|
||||
})();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(inspectionStarted, false);
|
||||
assert.equal(coordinator.inspectionBarrier("99"), null);
|
||||
|
||||
releaseDeletion.resolve();
|
||||
assert.deepEqual(await deletion, { deleted: "42" });
|
||||
await inspection;
|
||||
assert.equal(inspectionStarted, true);
|
||||
});
|
||||
|
||||
test("cache clearing waits for deletions and blocks every inspection", async () => {
|
||||
const coordinator = new CacheMaintenanceCoordinator();
|
||||
const deletionEntered = deferred();
|
||||
const releaseDeletion = deferred();
|
||||
const deletion = coordinator.deleteArtifact("42", async () => {
|
||||
deletionEntered.resolve();
|
||||
await releaseDeletion.promise;
|
||||
});
|
||||
await deletionEntered.promise;
|
||||
|
||||
const clearEntered = deferred();
|
||||
const releaseClear = deferred();
|
||||
const clear = coordinator.clearCache(async () => {
|
||||
clearEntered.resolve();
|
||||
await releaseClear.promise;
|
||||
return { cleared: true };
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(coordinator.inspectionBarrier("99") !== null, true);
|
||||
|
||||
let inspectionStarted = false;
|
||||
const inspection = (async () => {
|
||||
while (true) {
|
||||
const barrier = coordinator.inspectionBarrier("99");
|
||||
if (!barrier) break;
|
||||
await barrier;
|
||||
}
|
||||
inspectionStarted = true;
|
||||
})();
|
||||
releaseDeletion.resolve();
|
||||
await deletion;
|
||||
await clearEntered.promise;
|
||||
assert.equal(inspectionStarted, false);
|
||||
|
||||
releaseClear.resolve();
|
||||
assert.deepEqual(await clear, { cleared: true });
|
||||
await inspection;
|
||||
assert.equal(inspectionStarted, true);
|
||||
});
|
||||
|
||||
test("artifact truncation messaging names omitted workflow runs", async () => {
|
||||
const source = await readFile(new URL("assets/app.js", here), "utf8");
|
||||
const helperSource = source.slice(
|
||||
source.indexOf("function artifactResultLimitMessage"),
|
||||
source.indexOf("function renderPullPayload"),
|
||||
);
|
||||
const artifactResultLimitMessage = Function(
|
||||
`${helperSource}; return artifactResultLimitMessage;`,
|
||||
)();
|
||||
|
||||
assert.equal(
|
||||
artifactResultLimitMessage({
|
||||
runs: Array.from({ length: 30 }),
|
||||
runCount: 35,
|
||||
runsTruncated: true,
|
||||
artifacts: Array.from({ length: 100 }),
|
||||
artifactCount: 120,
|
||||
}),
|
||||
"Showing artifacts from the newest 30 of 35 workflow runs. Showing the newest 100 of 120 artifacts from those runs.",
|
||||
);
|
||||
});
|
||||
|
||||
test("capability tokens protect loopback APIs, content, and events", () => {
|
||||
const token = "secret-token";
|
||||
const req = (headers = {}) => ({ headers });
|
||||
const apiUrl = new URL("http://127.0.0.1:1234/api/bootstrap");
|
||||
const contentUrl = new URL(
|
||||
`http://127.0.0.1:1234/content/1/file.txt?token=${token}`,
|
||||
);
|
||||
const eventsUrl = new URL(
|
||||
`http://127.0.0.1:1234/events?token=${token}`,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
isCanonicalHost(req({ host: "127.0.0.1:1234" }), "127.0.0.1:1234"),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isCanonicalHost(req({ host: "attacker.example:1234" }), "127.0.0.1:1234"),
|
||||
false,
|
||||
);
|
||||
assert.equal(requiresCapabilityToken("/api/bootstrap"), true);
|
||||
assert.equal(requiresCapabilityToken("/content/1/file.txt"), true);
|
||||
assert.equal(requiresCapabilityToken("/events"), true);
|
||||
assert.equal(requiresCapabilityToken("/assets/app.js"), false);
|
||||
assert.equal(
|
||||
hasCapabilityToken(req({ [CAPABILITY_TOKEN_HEADER]: token }), apiUrl, token),
|
||||
true,
|
||||
);
|
||||
assert.equal(hasCapabilityToken(req(), contentUrl, token), true);
|
||||
assert.equal(hasCapabilityToken(req(), eventsUrl, token), true);
|
||||
assert.equal(
|
||||
hasCapabilityToken(
|
||||
req(),
|
||||
new URL(`${apiUrl}?token=${token}`),
|
||||
token,
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isCrossSiteRequest(
|
||||
req({ origin: "https://attacker.example" }),
|
||||
"http://127.0.0.1:1234",
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("canvas server rejects spoofed hosts and unauthenticated protected routes", async (t) => {
|
||||
const instanceId = `review-security-${Date.now()}`;
|
||||
const entry = await startInstance(instanceId, null, () => {});
|
||||
t.after(() => stopInstance(instanceId));
|
||||
|
||||
const root = await fetch(entry.url);
|
||||
assert.equal(root.status, 200);
|
||||
assert.match(
|
||||
await root.text(),
|
||||
new RegExp(`name="pr-artifact-explorer-token" content="${entry.token}"`),
|
||||
);
|
||||
assert.equal((await fetch(`${entry.origin}/`)).status, 403);
|
||||
|
||||
for (const path of ["api/bootstrap", "content/1/file.txt", "events"]) {
|
||||
const response = await fetch(`${entry.origin}/${path}`);
|
||||
assert.equal(response.status, 403);
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
await rawStatus(entry.url, { Host: "attacker.example" }),
|
||||
403,
|
||||
);
|
||||
const foreignOriginResponse = await fetch(`${entry.origin}/api/bootstrap`, {
|
||||
headers: {
|
||||
[CAPABILITY_TOKEN_HEADER]: entry.token,
|
||||
Origin: "https://attacker.example",
|
||||
},
|
||||
});
|
||||
assert.equal(foreignOriginResponse.status, 403);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
export const CAPABILITY_TOKEN_HEADER = "x-pr-artifact-explorer-token";
|
||||
|
||||
export function isCanonicalHost(req, canonicalHost) {
|
||||
return (
|
||||
String(req.headers.host ?? "").toLowerCase() ===
|
||||
String(canonicalHost ?? "").toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
export function isCrossSiteRequest(req, canonicalOrigin) {
|
||||
const origin = req.headers.origin;
|
||||
if (origin) {
|
||||
if (origin === canonicalOrigin) return false;
|
||||
if (origin === "null") return true;
|
||||
if (/^https?:\/\//i.test(origin)) return true;
|
||||
return false;
|
||||
}
|
||||
const site = req.headers["sec-fetch-site"];
|
||||
return site === "cross-site" || site === "same-site";
|
||||
}
|
||||
|
||||
export function requiresCapabilityToken(pathname) {
|
||||
return (
|
||||
pathname.startsWith("/api/") ||
|
||||
pathname.startsWith("/content/") ||
|
||||
pathname === "/events"
|
||||
);
|
||||
}
|
||||
|
||||
export function hasCapabilityToken(req, url, expectedToken) {
|
||||
if (!expectedToken) return false;
|
||||
const header = req.headers[CAPABILITY_TOKEN_HEADER];
|
||||
const headerToken = Array.isArray(header) ? header[0] : header;
|
||||
if (headerToken === expectedToken) return true;
|
||||
const allowsQueryToken =
|
||||
url.pathname.startsWith("/content/") || url.pathname === "/events";
|
||||
return allowsQueryToken && url.searchParams.get("token") === expectedToken;
|
||||
}
|
||||
@@ -0,0 +1,962 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { join, posix } from "node:path";
|
||||
import { createServer } from "node:http";
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { resolveAccounts, invalidateAccounts } from "./accounts.mjs";
|
||||
import {
|
||||
clearArtifactCache,
|
||||
deleteCachedArtifact,
|
||||
getCacheSummary,
|
||||
getCachedArtifact,
|
||||
getCachedEntry,
|
||||
inspectArtifact,
|
||||
readCachedEntry,
|
||||
} from "./cache.mjs";
|
||||
import {
|
||||
ASSET_ROOT,
|
||||
MAX_INLINE_PREVIEW_BYTES,
|
||||
MAX_STREAMED_ENTRY_BYTES,
|
||||
MAX_TRX_PREVIEW_BYTES,
|
||||
} from "./constants.mjs";
|
||||
import {
|
||||
hasRootIndexHtml,
|
||||
isInlineTextKind,
|
||||
kindForPath,
|
||||
mimeForPath,
|
||||
} from "./detector.mjs";
|
||||
import {
|
||||
enrichPullRequests,
|
||||
filterPullRequests,
|
||||
getRepository,
|
||||
GitHubApiError,
|
||||
listRepositoryContributors,
|
||||
listPullRequestArtifacts,
|
||||
searchPullRequestBase,
|
||||
searchPullRequests,
|
||||
suggestRepositories,
|
||||
} from "./github.mjs";
|
||||
import {
|
||||
startStaticPreview,
|
||||
stopAllStaticPreviews,
|
||||
stopStaticPreviewsForArtifact,
|
||||
stopStaticPreviewsForOrigin,
|
||||
} from "./preview.mjs";
|
||||
import { ExpiringPromiseCache } from "./memory-cache.mjs";
|
||||
import { renderHtml } from "./render.mjs";
|
||||
import {
|
||||
hasCapabilityToken,
|
||||
isCanonicalHost,
|
||||
isCrossSiteRequest,
|
||||
requiresCapabilityToken,
|
||||
} from "./security.mjs";
|
||||
import {
|
||||
loadPrefs,
|
||||
normalizeRepository,
|
||||
rememberRepository,
|
||||
savePrefs,
|
||||
setExplorerPreferences,
|
||||
setFavoriteRepository,
|
||||
setPinnedRepository,
|
||||
setPlayerPreferences,
|
||||
setPullFilterPreferences,
|
||||
} from "./state.mjs";
|
||||
import { streamZipEntry } from "./zip.mjs";
|
||||
|
||||
const servers = new Map();
|
||||
const MAX_BODY_BYTES = 1024 * 1024;
|
||||
const PULL_REQUEST_CACHE_TTL_MS = 2 * 60 * 1_000;
|
||||
const PROGRESSIVE_PULL_BATCH_SIZE = 25;
|
||||
const pullRequestCache = new ExpiringPromiseCache({
|
||||
maxEntries: 100,
|
||||
ttlMs: PULL_REQUEST_CACHE_TTL_MS,
|
||||
});
|
||||
const progressivePullRequestCache = new ExpiringPromiseCache({
|
||||
maxEntries: 100,
|
||||
ttlMs: PULL_REQUEST_CACHE_TTL_MS,
|
||||
});
|
||||
|
||||
const MAIN_CSP = [
|
||||
"default-src 'self'",
|
||||
"base-uri 'self'",
|
||||
"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",
|
||||
"object-src 'none'",
|
||||
"script-src 'self' 'wasm-unsafe-eval'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"worker-src 'self' blob:",
|
||||
].join("; ");
|
||||
|
||||
class HttpError extends Error {
|
||||
constructor(status, message) {
|
||||
super(message);
|
||||
this.name = "HttpError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
function createCapabilityToken() {
|
||||
return randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
function sendJson(res, status, value) {
|
||||
const body = JSON.stringify(value);
|
||||
res.writeHead(status, {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Length": Buffer.byteLength(body),
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function sendText(res, status, value, type = "text/plain; charset=utf-8", headers = {}) {
|
||||
const body = String(value);
|
||||
res.writeHead(status, {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Length": Buffer.byteLength(body),
|
||||
"Content-Type": type,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
...headers,
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function pullRequestCacheKey(account, kind, values) {
|
||||
return [
|
||||
account?.id ?? account?.login ?? "unknown",
|
||||
kind,
|
||||
...values.map((value) => String(value ?? "").toLocaleLowerCase()),
|
||||
].join("|");
|
||||
}
|
||||
|
||||
function progressivePullPayload(session, pulls, {
|
||||
complete = false,
|
||||
offset = 0,
|
||||
} = {}) {
|
||||
const filterActive =
|
||||
session.base.filters.artifacts !== "all" ||
|
||||
session.base.filters.ci !== "all";
|
||||
return {
|
||||
...session.base,
|
||||
filtered: complete && filterActive,
|
||||
pulls,
|
||||
progressive: {
|
||||
batchSize: PROGRESSIVE_PULL_BATCH_SIZE,
|
||||
complete,
|
||||
detailedCount: session.completedNumbers.size,
|
||||
loadedCount: Math.min(
|
||||
session.base.pulls.length,
|
||||
offset + pulls.length,
|
||||
),
|
||||
offset,
|
||||
sessionId: session.id,
|
||||
sourceCount: session.base.pulls.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function completeProgressivePullPayload(session) {
|
||||
const enriched = session.base.pulls
|
||||
.map((pull) => session.enrichedPulls.get(Number(pull.number)))
|
||||
.filter(Boolean);
|
||||
return progressivePullPayload(
|
||||
session,
|
||||
filterPullRequests(enriched, session.base.filters),
|
||||
{ complete: true },
|
||||
);
|
||||
}
|
||||
|
||||
async function loadProgressivePullDetails(
|
||||
session,
|
||||
cacheKey,
|
||||
token,
|
||||
repository,
|
||||
offset,
|
||||
) {
|
||||
const completed = session.detailResults.get(offset);
|
||||
if (completed) return completed;
|
||||
const existing = session.detailPromises.get(offset);
|
||||
if (existing) return existing;
|
||||
const source = session.base.pulls.slice(
|
||||
offset,
|
||||
offset + PROGRESSIVE_PULL_BATCH_SIZE,
|
||||
);
|
||||
const promise = (async () => {
|
||||
const enriched = await enrichPullRequests(
|
||||
token,
|
||||
repository,
|
||||
source,
|
||||
session.base.filters,
|
||||
);
|
||||
for (const pull of enriched) {
|
||||
session.enrichedPulls.set(Number(pull.number), pull);
|
||||
session.completedNumbers.add(Number(pull.number));
|
||||
}
|
||||
const sourceNumbers = source.map((pull) => Number(pull.number));
|
||||
const complete =
|
||||
session.completedNumbers.size >= session.base.pulls.length;
|
||||
const payload = complete ? completeProgressivePullPayload(session) : null;
|
||||
if (
|
||||
payload &&
|
||||
progressivePullRequestCache.peek(cacheKey) === session
|
||||
) {
|
||||
pullRequestCache.set(cacheKey, payload);
|
||||
}
|
||||
const result = {
|
||||
complete,
|
||||
completedCount: session.completedNumbers.size,
|
||||
payload,
|
||||
pulls: filterPullRequests(enriched, session.base.filters),
|
||||
sourceNumbers,
|
||||
};
|
||||
session.detailResults.set(offset, result);
|
||||
return result;
|
||||
})();
|
||||
session.detailPromises.set(offset, promise);
|
||||
try {
|
||||
return await promise;
|
||||
} finally {
|
||||
if (session.detailPromises.get(offset) === promise) {
|
||||
session.detailPromises.delete(offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendFile(res, filePath, type) {
|
||||
const info = await stat(filePath);
|
||||
if (!info.isFile()) throw new HttpError(404, "Asset not found.");
|
||||
res.writeHead(200, {
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
"Content-Length": String(info.size),
|
||||
"Content-Type": type,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
});
|
||||
await pipeline(createReadStream(filePath), res);
|
||||
}
|
||||
|
||||
async function readJsonBody(req) {
|
||||
const type = req.headers["content-type"] ?? "";
|
||||
if (!String(type).toLowerCase().startsWith("application/json")) {
|
||||
throw new HttpError(415, "Request body must be JSON.");
|
||||
}
|
||||
const chunks = [];
|
||||
let length = 0;
|
||||
for await (const chunk of req) {
|
||||
length += chunk.length;
|
||||
if (length > MAX_BODY_BYTES) throw new HttpError(413, "Request body is too large.");
|
||||
chunks.push(chunk);
|
||||
}
|
||||
if (!chunks.length) return {};
|
||||
try {
|
||||
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
} catch (error) {
|
||||
throw new HttpError(400, "Request body is not valid JSON.", { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
function broadcast(entry, event, payload) {
|
||||
const encoded = JSON.stringify(payload);
|
||||
for (const response of entry.sseClients) {
|
||||
try {
|
||||
response.write(`event: ${event}\ndata: ${encoded}\n\n`);
|
||||
} catch {
|
||||
entry.sseClients.delete(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveAuth(repository, force = false) {
|
||||
const prefs = await loadPrefs();
|
||||
const previousRepository = prefs.repository;
|
||||
const previousRecent = JSON.stringify(prefs.repositories.recent);
|
||||
const normalizedRepository = rememberRepository(prefs, repository);
|
||||
const auth = await resolveAccounts({
|
||||
preferredId: prefs.account,
|
||||
repository: normalizedRepository,
|
||||
force,
|
||||
});
|
||||
let changed =
|
||||
prefs.repository !== previousRepository ||
|
||||
JSON.stringify(prefs.repositories.recent) !== previousRecent;
|
||||
if (auth.active && prefs.account !== auth.active.id) {
|
||||
prefs.account = auth.active.id;
|
||||
changed = true;
|
||||
}
|
||||
if (changed) await savePrefs(prefs);
|
||||
if (!auth.activeToken) {
|
||||
throw new HttpError(
|
||||
401,
|
||||
"No usable GitHub account was detected. Sign in through the Copilot app or GitHub CLI.",
|
||||
);
|
||||
}
|
||||
return { prefs, auth };
|
||||
}
|
||||
|
||||
function parseArtifactRoute(pathname) {
|
||||
return pathname.match(/^\/api\/artifacts\/(\d+)$/);
|
||||
}
|
||||
|
||||
function decodeEntryPath(encoded) {
|
||||
try {
|
||||
return encoded.split("/").map((part) => decodeURIComponent(part)).join("/");
|
||||
} catch (error) {
|
||||
throw new HttpError(400, "File path contains invalid URL encoding.", { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
export function contentDeliveryMode(entry, download) {
|
||||
if (entry?.supported) return "entry";
|
||||
return download ? "archive" : "unsupported";
|
||||
}
|
||||
|
||||
function archiveDownloadName(context, artifactId) {
|
||||
const rawName = String(
|
||||
context.metadata?.artifact?.name || `artifact-${artifactId}`,
|
||||
).replaceAll("\\", "/");
|
||||
const baseName = posix.basename(rawName) || `artifact-${artifactId}`;
|
||||
return baseName.toLowerCase().endsWith(".zip")
|
||||
? baseName
|
||||
: `${baseName}.zip`;
|
||||
}
|
||||
|
||||
async function sendArchiveDownload(req, res, context, artifactId) {
|
||||
const info = await stat(context.archivePath);
|
||||
const filename = archiveDownloadName(context, artifactId);
|
||||
res.writeHead(200, {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Disposition":
|
||||
`attachment; filename*=UTF-8''${encodeURIComponent(filename)}`,
|
||||
"Content-Length": String(info.size),
|
||||
"Content-Security-Policy": "default-src 'none'; sandbox",
|
||||
"Content-Type": "application/zip",
|
||||
"Cross-Origin-Resource-Policy": "same-origin",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
});
|
||||
if (req.method === "HEAD") {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
await pipeline(createReadStream(context.archivePath), res);
|
||||
}
|
||||
|
||||
async function handleContentRequest(req, res, pathname, searchParams) {
|
||||
const match = pathname.match(/^\/content\/(\d+)\/(.+)$/);
|
||||
if (!match) return false;
|
||||
if (!["GET", "HEAD"].includes(req.method)) {
|
||||
throw new HttpError(405, "Only GET and HEAD are supported.");
|
||||
}
|
||||
const artifactId = match[1];
|
||||
const entryPath = decodeEntryPath(match[2]);
|
||||
const context = await getCachedEntry(artifactId, entryPath);
|
||||
const download = searchParams.get("download") === "1";
|
||||
const deliveryMode = contentDeliveryMode(context.entry, download);
|
||||
if (deliveryMode === "unsupported") {
|
||||
throw new HttpError(415, "This ZIP entry cannot be served.");
|
||||
}
|
||||
if (deliveryMode === "archive") {
|
||||
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 headers = {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Length": String(context.entry.uncompressedSize),
|
||||
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",
|
||||
"Content-Type": mimeForPath(context.entry.name),
|
||||
"Cross-Origin-Resource-Policy": "same-origin",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
};
|
||||
if (download) {
|
||||
headers["Content-Disposition"] =
|
||||
`attachment; filename*=UTF-8''${encodeURIComponent(posix.basename(context.entry.name))}`;
|
||||
}
|
||||
res.writeHead(200, headers);
|
||||
if (req.method === "HEAD") {
|
||||
res.end();
|
||||
} else {
|
||||
await streamZipEntry(context.archivePath, context.entry, res);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleApi(req, res, entry, url) {
|
||||
const path = url.pathname;
|
||||
if (req.method === "GET" && path === "/api/bootstrap") {
|
||||
const prefs = await loadPrefs();
|
||||
const repository =
|
||||
typeof entry.initialInput?.repository === "string"
|
||||
? normalizeRepository(entry.initialInput.repository)
|
||||
: prefs.repository;
|
||||
const auth = await resolveAccounts({
|
||||
preferredId: prefs.account,
|
||||
repository,
|
||||
});
|
||||
if (auth.active && prefs.account !== auth.active.id) {
|
||||
prefs.account = auth.active.id;
|
||||
await savePrefs(prefs);
|
||||
}
|
||||
return sendJson(res, 200, {
|
||||
prefs,
|
||||
account: auth.active,
|
||||
accounts: auth.accounts,
|
||||
cache: await getCacheSummary(),
|
||||
initialInput: entry.initialInput ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "GET" && path === "/api/accounts") {
|
||||
const prefs = await loadPrefs();
|
||||
const repository = normalizeRepository(
|
||||
url.searchParams.get("repo") || prefs.repository,
|
||||
);
|
||||
const { auth } = await resolveAuth(repository, true);
|
||||
pullRequestCache.clear();
|
||||
progressivePullRequestCache.clear();
|
||||
return sendJson(res, 200, {
|
||||
account: auth.active,
|
||||
accounts: auth.accounts,
|
||||
prefs: await loadPrefs(),
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "POST" && path === "/api/account") {
|
||||
const body = await readJsonBody(req);
|
||||
if (typeof body.id !== "string" || !body.id) {
|
||||
throw new HttpError(400, "Account id is required.");
|
||||
}
|
||||
const repository = normalizeRepository(body.repository);
|
||||
const prefs = await loadPrefs();
|
||||
prefs.account = body.id;
|
||||
await savePrefs(prefs);
|
||||
invalidateAccounts();
|
||||
pullRequestCache.clear();
|
||||
progressivePullRequestCache.clear();
|
||||
const { auth } = await resolveAuth(repository, true);
|
||||
broadcast(entry, "refresh", { reason: "account" });
|
||||
return sendJson(res, 200, {
|
||||
account: auth.active,
|
||||
accounts: auth.accounts,
|
||||
prefs: await loadPrefs(),
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "GET" && path === "/api/repositories/suggest") {
|
||||
const query = String(url.searchParams.get("q") ?? "").trim();
|
||||
const prefs = await loadPrefs();
|
||||
const auth = await resolveAccounts({
|
||||
preferredId: prefs.account,
|
||||
repository: prefs.repository,
|
||||
});
|
||||
if (!auth.activeToken) {
|
||||
throw new HttpError(401, "No usable GitHub account was found.");
|
||||
}
|
||||
return sendJson(res, 200, {
|
||||
repositories: await suggestRepositories(auth.activeToken, query),
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "POST" && path === "/api/repositories/validate") {
|
||||
const body = await readJsonBody(req);
|
||||
const repository = normalizeRepository(body.repository);
|
||||
const prefs = await loadPrefs();
|
||||
const auth = await resolveAccounts({
|
||||
preferredId: prefs.account,
|
||||
repository,
|
||||
});
|
||||
if (!auth.activeToken) {
|
||||
throw new HttpError(401, "No usable GitHub account was found.");
|
||||
}
|
||||
return sendJson(res, 200, {
|
||||
repository: await getRepository(auth.activeToken, repository),
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "GET" && path === "/api/repositories/authors") {
|
||||
const repository = normalizeRepository(url.searchParams.get("repo"));
|
||||
const { auth } = await resolveAuth(repository);
|
||||
return sendJson(res, 200, {
|
||||
authors: await listRepositoryContributors(auth.activeToken, repository),
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "POST" && path === "/api/repositories/pin") {
|
||||
const body = await readJsonBody(req);
|
||||
const prefs = await loadPrefs();
|
||||
setPinnedRepository(prefs, body.repository);
|
||||
return sendJson(res, 200, await savePrefs(prefs));
|
||||
}
|
||||
|
||||
if (req.method === "POST" && path === "/api/repositories/favorite") {
|
||||
const body = await readJsonBody(req);
|
||||
if (typeof body.favorite !== "boolean") {
|
||||
throw new HttpError(400, "favorite must be a boolean.");
|
||||
}
|
||||
const prefs = await loadPrefs();
|
||||
try {
|
||||
setFavoriteRepository(prefs, body.repository, body.favorite);
|
||||
} catch (error) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
error instanceof Error ? error.message : "Repository could not be favorited.",
|
||||
);
|
||||
}
|
||||
return sendJson(res, 200, await savePrefs(prefs));
|
||||
}
|
||||
|
||||
if (req.method === "POST" && path === "/api/preferences") {
|
||||
const body = await readJsonBody(req);
|
||||
const prefs = await loadPrefs();
|
||||
if (typeof body.repository === "string") {
|
||||
rememberRepository(prefs, body.repository);
|
||||
}
|
||||
if (["open", "closed", "all"].includes(body.pullState)) {
|
||||
prefs.pullState = body.pullState;
|
||||
}
|
||||
if (
|
||||
typeof body.repository === "string" &&
|
||||
body.pullFilter &&
|
||||
typeof body.pullFilter === "object"
|
||||
) {
|
||||
setPullFilterPreferences(prefs, body.repository, body.pullFilter);
|
||||
}
|
||||
if (body.explorer && typeof body.explorer === "object") {
|
||||
setExplorerPreferences(prefs, body.explorer);
|
||||
}
|
||||
if (body.player && typeof body.player === "object") {
|
||||
setPlayerPreferences(prefs, body.player);
|
||||
}
|
||||
return sendJson(res, 200, await savePrefs(prefs));
|
||||
}
|
||||
|
||||
if (req.method === "GET" && path === "/api/pulls") {
|
||||
const repository = normalizeRepository(url.searchParams.get("repo"));
|
||||
const state = url.searchParams.get("state") ?? "open";
|
||||
const query = url.searchParams.get("q") ?? "";
|
||||
const artifacts = url.searchParams.get("artifacts") ?? "all";
|
||||
const ci = url.searchParams.get("ci") ?? "all";
|
||||
const { auth } = await resolveAuth(repository);
|
||||
const cacheKey = pullRequestCacheKey(auth.active, "list", [
|
||||
repository,
|
||||
state,
|
||||
query,
|
||||
artifacts,
|
||||
ci,
|
||||
]);
|
||||
const phase = url.searchParams.get("phase");
|
||||
const force = url.searchParams.get("refresh") === "1";
|
||||
if (phase) {
|
||||
if (!["initial", "batch", "details"].includes(phase)) {
|
||||
throw new HttpError(400, "Unknown progressive pull request phase.");
|
||||
}
|
||||
if (phase === "initial") {
|
||||
const cached = force ? undefined : pullRequestCache.peek(cacheKey);
|
||||
if (cached) {
|
||||
return sendJson(res, 200, {
|
||||
repository,
|
||||
...cached,
|
||||
account: auth.active,
|
||||
progressive: {
|
||||
batchSize: PROGRESSIVE_PULL_BATCH_SIZE,
|
||||
complete: true,
|
||||
detailedCount: cached.evaluatedCount,
|
||||
loadedCount: cached.pulls.length,
|
||||
offset: 0,
|
||||
sourceCount: cached.evaluatedCount,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (force) pullRequestCache.delete(cacheKey);
|
||||
const session = await progressivePullRequestCache.get(
|
||||
cacheKey,
|
||||
async () => ({
|
||||
id: randomUUID(),
|
||||
base: await searchPullRequestBase(
|
||||
auth.activeToken,
|
||||
repository,
|
||||
query,
|
||||
state,
|
||||
{ artifacts, ci },
|
||||
{ perPage: 100 },
|
||||
),
|
||||
completedNumbers: new Set(),
|
||||
detailPromises: new Map(),
|
||||
detailResults: new Map(),
|
||||
enrichedPulls: new Map(),
|
||||
}),
|
||||
{ force },
|
||||
);
|
||||
if (!session.base.pulls.length) {
|
||||
if (progressivePullRequestCache.peek(cacheKey) !== session) {
|
||||
throw new HttpError(
|
||||
409,
|
||||
"The progressive pull request load expired. Refresh the list.",
|
||||
);
|
||||
}
|
||||
const payload = completeProgressivePullPayload(session);
|
||||
pullRequestCache.set(cacheKey, payload);
|
||||
return sendJson(res, 200, {
|
||||
repository,
|
||||
...payload,
|
||||
account: auth.active,
|
||||
});
|
||||
}
|
||||
return sendJson(res, 200, {
|
||||
repository,
|
||||
...progressivePullPayload(
|
||||
session,
|
||||
session.base.pulls.slice(0, PROGRESSIVE_PULL_BATCH_SIZE),
|
||||
),
|
||||
account: auth.active,
|
||||
});
|
||||
}
|
||||
|
||||
const session = progressivePullRequestCache.peek(cacheKey);
|
||||
const requestedSessionId = url.searchParams.get("session");
|
||||
if (!session || !requestedSessionId || session.id !== requestedSessionId) {
|
||||
throw new HttpError(
|
||||
409,
|
||||
"The progressive pull request load expired. Refresh the list.",
|
||||
);
|
||||
}
|
||||
const offset = Number.parseInt(url.searchParams.get("offset"), 10);
|
||||
if (
|
||||
!Number.isSafeInteger(offset) ||
|
||||
offset < 0 ||
|
||||
offset >= session.base.pulls.length ||
|
||||
offset % PROGRESSIVE_PULL_BATCH_SIZE !== 0
|
||||
) {
|
||||
throw new HttpError(400, "A valid progressive batch offset is required.");
|
||||
}
|
||||
if (phase === "batch") {
|
||||
return sendJson(res, 200, {
|
||||
repository,
|
||||
...progressivePullPayload(
|
||||
session,
|
||||
session.base.pulls.slice(
|
||||
offset,
|
||||
offset + PROGRESSIVE_PULL_BATCH_SIZE,
|
||||
),
|
||||
{ offset },
|
||||
),
|
||||
account: auth.active,
|
||||
});
|
||||
}
|
||||
const details = await loadProgressivePullDetails(
|
||||
session,
|
||||
cacheKey,
|
||||
auth.activeToken,
|
||||
repository,
|
||||
offset,
|
||||
);
|
||||
if (progressivePullRequestCache.peek(cacheKey) !== session) {
|
||||
throw new HttpError(
|
||||
409,
|
||||
"The progressive pull request load expired. Refresh the list.",
|
||||
);
|
||||
}
|
||||
return sendJson(res, 200, {
|
||||
repository,
|
||||
...details,
|
||||
account: auth.active,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await pullRequestCache.get(
|
||||
cacheKey,
|
||||
() =>
|
||||
searchPullRequests(
|
||||
auth.activeToken,
|
||||
repository,
|
||||
query,
|
||||
state,
|
||||
{ artifacts, ci },
|
||||
),
|
||||
{ force },
|
||||
);
|
||||
return sendJson(res, 200, {
|
||||
repository,
|
||||
...result,
|
||||
account: auth.active,
|
||||
});
|
||||
}
|
||||
|
||||
const pullMatch = path.match(/^\/api\/pulls\/(\d+)$/);
|
||||
if (req.method === "GET" && pullMatch) {
|
||||
const repository = normalizeRepository(url.searchParams.get("repo"));
|
||||
const { auth } = await resolveAuth(repository);
|
||||
const payload = await pullRequestCache.get(
|
||||
pullRequestCacheKey(auth.active, "detail", [repository, pullMatch[1]]),
|
||||
() =>
|
||||
listPullRequestArtifacts(
|
||||
auth.activeToken,
|
||||
repository,
|
||||
pullMatch[1],
|
||||
),
|
||||
{ force: url.searchParams.get("refresh") === "1" },
|
||||
);
|
||||
return sendJson(res, 200, {
|
||||
repository,
|
||||
...payload,
|
||||
account: auth.active,
|
||||
});
|
||||
}
|
||||
|
||||
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 artifactMatch = parseArtifactRoute(path);
|
||||
if (req.method === "GET" && artifactMatch) {
|
||||
const metadata = await getCachedArtifact(artifactMatch[1]);
|
||||
if (!metadata) throw new HttpError(404, "Artifact is not cached.");
|
||||
return sendJson(res, 200, metadata);
|
||||
}
|
||||
|
||||
const textMatch = path.match(/^\/api\/artifacts\/(\d+)\/text$/);
|
||||
if (req.method === "GET" && textMatch) {
|
||||
const entryPath = url.searchParams.get("path");
|
||||
if (!entryPath) throw new HttpError(400, "File path is required.");
|
||||
const kind = kindForPath(entryPath);
|
||||
if (!isInlineTextKind(kind)) {
|
||||
throw new HttpError(415, "This file is not an inline text preview.");
|
||||
}
|
||||
const context = await readCachedEntry(
|
||||
textMatch[1],
|
||||
entryPath,
|
||||
kind === "trx" ? MAX_TRX_PREVIEW_BYTES : MAX_INLINE_PREVIEW_BYTES,
|
||||
);
|
||||
return sendJson(res, 200, {
|
||||
path: context.entry.name,
|
||||
kind,
|
||||
content: context.content.toString("utf8"),
|
||||
});
|
||||
}
|
||||
|
||||
const previewMatch = path.match(/^\/api\/artifacts\/(\d+)\/preview-url$/);
|
||||
if (req.method === "GET" && previewMatch) {
|
||||
const entryPath = url.searchParams.get("path");
|
||||
if (!entryPath) throw new HttpError(400, "File path is required.");
|
||||
if (kindForPath(entryPath) !== "html") {
|
||||
throw new HttpError(415, "Only HTML files can use the site preview.");
|
||||
}
|
||||
const metadata = await getCachedArtifact(previewMatch[1], { touch: false });
|
||||
if (!metadata) throw new HttpError(404, "Artifact is not cached. Inspect it first.");
|
||||
if (!hasRootIndexHtml(metadata.analysis?.entries ?? [])) {
|
||||
throw new HttpError(
|
||||
415,
|
||||
"HTML previews require index.html at the artifact root.",
|
||||
);
|
||||
}
|
||||
const theme = url.searchParams.get("theme");
|
||||
if (theme !== "light" && theme !== "dark") {
|
||||
throw new HttpError(400, "Preview theme must be light or dark.");
|
||||
}
|
||||
return sendJson(res, 200, {
|
||||
url: await startStaticPreview(previewMatch[1], entryPath, {
|
||||
theme,
|
||||
parentOrigin: new URL(entry.url).origin,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "GET" && path === "/api/cache") {
|
||||
return sendJson(res, 200, await getCacheSummary());
|
||||
}
|
||||
|
||||
const cacheMatch = path.match(/^\/api\/cache\/(\d+)$/);
|
||||
if (req.method === "DELETE" && cacheMatch) {
|
||||
await stopStaticPreviewsForArtifact(cacheMatch[1]);
|
||||
const result = await deleteCachedArtifact(cacheMatch[1]);
|
||||
broadcast(entry, "cache", await getCacheSummary());
|
||||
return sendJson(res, 200, result);
|
||||
}
|
||||
|
||||
if (req.method === "DELETE" && path === "/api/cache") {
|
||||
await stopAllStaticPreviews();
|
||||
const result = await clearArtifactCache();
|
||||
broadcast(entry, "cache", await getCacheSummary());
|
||||
return sendJson(res, 200, result);
|
||||
}
|
||||
|
||||
if (req.method === "GET" && path === "/events") {
|
||||
res.writeHead(200, {
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
"Content-Type": "text/event-stream",
|
||||
});
|
||||
res.write(": connected\n\n");
|
||||
entry.sseClients.add(res);
|
||||
req.on("close", () => entry.sseClients.delete(res));
|
||||
return;
|
||||
}
|
||||
|
||||
throw new HttpError(404, "API route not found.");
|
||||
}
|
||||
|
||||
async function handleRequest(req, res, entry) {
|
||||
const url = new URL(req.url, "http://127.0.0.1");
|
||||
try {
|
||||
if (!isCanonicalHost(req, entry.host)) {
|
||||
throw new HttpError(403, "Request host is not allowed.");
|
||||
}
|
||||
const protectedRoute = requiresCapabilityToken(url.pathname);
|
||||
if (protectedRoute && isCrossSiteRequest(req, entry.origin)) {
|
||||
throw new HttpError(403, "Cross-site requests are not allowed.");
|
||||
}
|
||||
if (protectedRoute && !hasCapabilityToken(req, url, entry.token)) {
|
||||
throw new HttpError(403, "Missing or invalid capability token.");
|
||||
}
|
||||
|
||||
const documentRoute =
|
||||
req.method === "GET" &&
|
||||
(url.pathname === "/" || url.pathname === "/index.html");
|
||||
if (documentRoute && url.searchParams.get("token") !== entry.token) {
|
||||
throw new HttpError(403, "Missing or invalid capability token.");
|
||||
}
|
||||
if (documentRoute) {
|
||||
return sendText(res, 200, renderHtml(entry.token), "text/html; charset=utf-8", {
|
||||
"Content-Security-Policy": MAIN_CSP,
|
||||
"Referrer-Policy": "no-referrer",
|
||||
});
|
||||
}
|
||||
|
||||
const assetTypes = new Map([
|
||||
["/assets/primer-color-modes.css", ["primer-color-modes.css", "text/css; charset=utf-8"]],
|
||||
["/assets/primer-core.css", ["primer-core.css", "text/css; charset=utf-8"]],
|
||||
["/assets/primer-product.css", ["primer-product.css", "text/css; charset=utf-8"]],
|
||||
["/assets/asciinema-player.css", ["asciinema-player.css", "text/css; charset=utf-8"]],
|
||||
["/assets/asciinema-player.min.js", ["asciinema-player.min.js", "text/javascript; charset=utf-8"]],
|
||||
["/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/app.js", ["app.js", "text/javascript; charset=utf-8"]],
|
||||
]);
|
||||
if (req.method === "GET" && assetTypes.has(url.pathname)) {
|
||||
const [name, type] = assetTypes.get(url.pathname);
|
||||
return await sendFile(res, join(ASSET_ROOT, name), type);
|
||||
}
|
||||
const iconMatch = url.pathname.match(/^\/assets\/octicons\/([a-z-]+)\.svg$/);
|
||||
if (req.method === "GET" && iconMatch) {
|
||||
return await sendFile(
|
||||
res,
|
||||
join(ASSET_ROOT, "octicons", `${iconMatch[1]}.svg`),
|
||||
"image/svg+xml",
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
throw new HttpError(404, "Route not found.");
|
||||
} catch (error) {
|
||||
entry.log?.(`${req.method} ${url.pathname}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
if (res.headersSent) {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const status =
|
||||
error instanceof HttpError
|
||||
? error.status
|
||||
: error instanceof GitHubApiError
|
||||
? error.status
|
||||
: 500;
|
||||
sendJson(res, status, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function startInstance(instanceId, initialInput, log) {
|
||||
let entry = servers.get(instanceId);
|
||||
if (entry) {
|
||||
entry.initialInput = initialInput ?? entry.initialInput;
|
||||
return entry;
|
||||
}
|
||||
|
||||
entry = {
|
||||
host: null,
|
||||
initialInput: initialInput ?? null,
|
||||
log,
|
||||
origin: null,
|
||||
server: null,
|
||||
sseClients: new Set(),
|
||||
token: createCapabilityToken(),
|
||||
url: null,
|
||||
};
|
||||
const server = createServer((req, res) => {
|
||||
void handleRequest(req, res, entry);
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
throw new Error("Canvas server did not receive a TCP port.");
|
||||
}
|
||||
entry.server = server;
|
||||
entry.host = `127.0.0.1:${address.port}`;
|
||||
entry.origin = `http://${entry.host}`;
|
||||
entry.url = `${entry.origin}/?token=${encodeURIComponent(entry.token)}`;
|
||||
servers.set(instanceId, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
export async function stopInstance(instanceId) {
|
||||
const entry = servers.get(instanceId);
|
||||
if (!entry) return;
|
||||
servers.delete(instanceId);
|
||||
for (const response of entry.sseClients) response.end();
|
||||
if (entry.url) {
|
||||
await stopStaticPreviewsForOrigin(new URL(entry.url).origin);
|
||||
}
|
||||
await new Promise((resolve) => entry.server.close(resolve));
|
||||
}
|
||||
|
||||
export function navigateInstance(instanceId, route) {
|
||||
const entry = servers.get(instanceId);
|
||||
if (!entry) return false;
|
||||
broadcast(entry, "navigate", { route });
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function broadcastCache({ refresh = false } = {}) {
|
||||
const summary = await getCacheSummary();
|
||||
for (const entry of servers.values()) {
|
||||
broadcast(entry, "cache", summary);
|
||||
if (refresh) broadcast(entry, "refresh", { reason: "cache" });
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeAllPreviews() {
|
||||
await stopAllStaticPreviews();
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { PREFS_FILE } from "./constants.mjs";
|
||||
|
||||
const MAX_RECENT_REPOSITORIES = 12;
|
||||
const MAX_FAVORITE_REPOSITORIES = 3;
|
||||
const MAX_PULL_FILTER_REPOSITORIES = 50;
|
||||
const PLAYER_FONT_SIZES = new Set(["12px", "14px", "16px", "18px", "20px", "22px"]);
|
||||
const PLAYER_FONTS = new Set(["system", "cascadia", "consolas"]);
|
||||
const PLAYER_SPEEDS = new Set([0.5, 1, 1.5, 2]);
|
||||
const PULL_ARTIFACT_FILTERS = new Set(["all", "with", "without"]);
|
||||
const PULL_CI_FILTERS = new Set(["all", "failing", "passing", "pending", "none"]);
|
||||
let preferenceSaveQueue = Promise.resolve();
|
||||
let preferenceSaveSequence = 0;
|
||||
const WINDOWS_RENAME_RETRY_CODES = new Set(["EACCES", "EBUSY", "EPERM"]);
|
||||
|
||||
export const DEFAULT_PREFS = Object.freeze({
|
||||
account: null,
|
||||
repository: "microsoft/aspire",
|
||||
pullState: "open",
|
||||
repositories: {
|
||||
pinned: null,
|
||||
favorites: [],
|
||||
recent: ["microsoft/aspire"],
|
||||
},
|
||||
pullFilters: {},
|
||||
explorer: {
|
||||
sidebarCollapsed: false,
|
||||
},
|
||||
player: {
|
||||
fontSize: "16px",
|
||||
fontFamily: "system",
|
||||
lineHeight: 1.2,
|
||||
speed: 1,
|
||||
},
|
||||
});
|
||||
|
||||
export function normalizeRepository(value) {
|
||||
const repository = String(value ?? "").trim().replace(/^https?:\/\/github\.com\//i, "");
|
||||
const match = repository.match(/^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?$/);
|
||||
if (!match) {
|
||||
throw new Error("Repository must use the owner/name format.");
|
||||
}
|
||||
if ([match[1], match[2]].some((part) => part === "." || part === "..")) {
|
||||
throw new Error("Repository owner and name cannot be dot segments.");
|
||||
}
|
||||
return `${match[1]}/${match[2]}`;
|
||||
}
|
||||
|
||||
function normalizeStoredRepository(value) {
|
||||
try {
|
||||
return typeof value === "string" && value.trim() ? normalizeRepository(value) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRepositoryList(value, limit) {
|
||||
const repositories = [];
|
||||
for (const item of Array.isArray(value) ? value : []) {
|
||||
const repository = normalizeStoredRepository(item);
|
||||
if (repository && !repositories.includes(repository)) repositories.push(repository);
|
||||
if (repositories.length === limit) break;
|
||||
}
|
||||
return repositories;
|
||||
}
|
||||
|
||||
function normalizePlayer(value) {
|
||||
const lineHeight = Number(value?.lineHeight);
|
||||
const speed = Number(value?.speed);
|
||||
return {
|
||||
fontSize: PLAYER_FONT_SIZES.has(value?.fontSize)
|
||||
? value.fontSize
|
||||
: DEFAULT_PREFS.player.fontSize,
|
||||
fontFamily: PLAYER_FONTS.has(value?.fontFamily)
|
||||
? value.fontFamily
|
||||
: DEFAULT_PREFS.player.fontFamily,
|
||||
lineHeight:
|
||||
Number.isFinite(lineHeight) && lineHeight >= 1 && lineHeight <= 1.6
|
||||
? Math.round(lineHeight * 10) / 10
|
||||
: DEFAULT_PREFS.player.lineHeight,
|
||||
speed: PLAYER_SPEEDS.has(speed) ? speed : DEFAULT_PREFS.player.speed,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeExplorer(value) {
|
||||
return {
|
||||
sidebarCollapsed: value?.sidebarCollapsed === true,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePullFilter(value) {
|
||||
const author =
|
||||
typeof value?.author === "string" && value.author.trim()
|
||||
? value.author.trim().slice(0, 100)
|
||||
: null;
|
||||
const updatedAt = Number(value?.updatedAt);
|
||||
return {
|
||||
state: ["open", "closed", "all"].includes(value?.state) ? value.state : "open",
|
||||
author,
|
||||
artifacts: PULL_ARTIFACT_FILTERS.has(value?.artifacts) ? value.artifacts : "all",
|
||||
ci: PULL_CI_FILTERS.has(value?.ci) ? value.ci : "all",
|
||||
updatedAt: Number.isFinite(updatedAt) && updatedAt > 0 ? updatedAt : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePullFilters(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||
const entries = [];
|
||||
for (const [key, filter] of Object.entries(value)) {
|
||||
const repository = normalizeStoredRepository(key);
|
||||
if (!repository || !filter || typeof filter !== "object") continue;
|
||||
entries.push([
|
||||
repository.toLocaleLowerCase(),
|
||||
normalizePullFilter(filter),
|
||||
]);
|
||||
}
|
||||
return Object.fromEntries(
|
||||
entries
|
||||
.sort((left, right) => right[1].updatedAt - left[1].updatedAt)
|
||||
.slice(0, MAX_PULL_FILTER_REPOSITORIES),
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizePrefs(value) {
|
||||
const prefs = value && typeof value === "object" ? value : {};
|
||||
const repository =
|
||||
normalizeStoredRepository(prefs.repository) ?? DEFAULT_PREFS.repository;
|
||||
const pinned = normalizeStoredRepository(prefs.repositories?.pinned);
|
||||
const favorites = normalizeRepositoryList(
|
||||
prefs.repositories?.favorites,
|
||||
MAX_FAVORITE_REPOSITORIES,
|
||||
).filter((item) => item !== pinned);
|
||||
return {
|
||||
account: typeof prefs.account === "string" && prefs.account ? prefs.account : null,
|
||||
repository,
|
||||
pullState: ["open", "closed", "all"].includes(prefs.pullState)
|
||||
? prefs.pullState
|
||||
: DEFAULT_PREFS.pullState,
|
||||
repositories: {
|
||||
pinned,
|
||||
favorites,
|
||||
recent: normalizeRepositoryList(
|
||||
[
|
||||
repository,
|
||||
...(Array.isArray(prefs.repositories?.recent)
|
||||
? prefs.repositories.recent
|
||||
: []),
|
||||
],
|
||||
MAX_RECENT_REPOSITORIES,
|
||||
),
|
||||
},
|
||||
pullFilters: normalizePullFilters(prefs.pullFilters),
|
||||
explorer: normalizeExplorer(prefs.explorer),
|
||||
player: normalizePlayer(prefs.player),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadPrefs() {
|
||||
try {
|
||||
return normalizePrefs(JSON.parse(await readFile(PREFS_FILE, "utf8")));
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") {
|
||||
return normalizePrefs();
|
||||
}
|
||||
if (error instanceof SyntaxError) {
|
||||
throw new Error(`Preferences are not valid JSON: ${PREFS_FILE}`, { cause: error });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function replacePreferencesFile(temporary) {
|
||||
let lastError;
|
||||
for (let attempt = 0; attempt < 7; attempt++) {
|
||||
try {
|
||||
await rename(temporary, PREFS_FILE);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!WINDOWS_RENAME_RETRY_CODES.has(error?.code)) throw error;
|
||||
lastError = error;
|
||||
if (attempt === 6) break;
|
||||
await delay(10 * 2 ** attempt);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await rm(temporary, { force: true });
|
||||
} catch (cleanupError) {
|
||||
throw new AggregateError(
|
||||
[lastError, cleanupError],
|
||||
"Preferences could not be replaced or cleaned up.",
|
||||
);
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export async function savePrefs(value) {
|
||||
const prefs = normalizePrefs(value);
|
||||
const save = async () => {
|
||||
await mkdir(dirname(PREFS_FILE), { recursive: true });
|
||||
const temporary =
|
||||
`${PREFS_FILE}.${process.pid}.${++preferenceSaveSequence}.tmp`;
|
||||
await writeFile(temporary, `${JSON.stringify(prefs, null, 2)}\n`, "utf8");
|
||||
await replacePreferencesFile(temporary);
|
||||
return prefs;
|
||||
};
|
||||
const pending = preferenceSaveQueue.then(save, save);
|
||||
preferenceSaveQueue = pending.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return pending;
|
||||
}
|
||||
|
||||
export function rememberRepository(preferences, value) {
|
||||
const repository = normalizeRepository(value);
|
||||
preferences.repository = repository;
|
||||
preferences.repositories ??= {};
|
||||
preferences.repositories.recent = normalizeRepositoryList(
|
||||
[
|
||||
repository,
|
||||
...(Array.isArray(preferences.repositories.recent)
|
||||
? preferences.repositories.recent
|
||||
: []),
|
||||
],
|
||||
MAX_RECENT_REPOSITORIES,
|
||||
);
|
||||
return repository;
|
||||
}
|
||||
|
||||
export function setPinnedRepository(preferences, value) {
|
||||
const repository = value == null || value === "" ? null : normalizeRepository(value);
|
||||
preferences.repositories ??= {};
|
||||
preferences.repositories.pinned = repository;
|
||||
preferences.repositories.favorites = normalizeRepositoryList(
|
||||
preferences.repositories.favorites,
|
||||
MAX_FAVORITE_REPOSITORIES,
|
||||
).filter((item) => item !== repository);
|
||||
return repository;
|
||||
}
|
||||
|
||||
export function setFavoriteRepository(preferences, value, favorite) {
|
||||
const repository = normalizeRepository(value);
|
||||
preferences.repositories ??= {};
|
||||
const favorites = normalizeRepositoryList(
|
||||
preferences.repositories.favorites,
|
||||
MAX_FAVORITE_REPOSITORIES,
|
||||
).filter((item) => item !== repository);
|
||||
if (favorite) {
|
||||
if (preferences.repositories.pinned === repository) {
|
||||
throw new Error("The pinned repository is already available in quick switch.");
|
||||
}
|
||||
if (favorites.length >= MAX_FAVORITE_REPOSITORIES) {
|
||||
throw new Error("You can favorite up to three repositories.");
|
||||
}
|
||||
favorites.unshift(repository);
|
||||
}
|
||||
preferences.repositories.favorites = favorites;
|
||||
return favorites;
|
||||
}
|
||||
|
||||
export function setPullFilterPreferences(preferences, repositoryValue, value) {
|
||||
const repository = normalizeRepository(repositoryValue).toLocaleLowerCase();
|
||||
preferences.pullFilters = normalizePullFilters({
|
||||
...preferences.pullFilters,
|
||||
[repository]: {
|
||||
...preferences.pullFilters?.[repository],
|
||||
...value,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
return preferences.pullFilters[repository];
|
||||
}
|
||||
|
||||
export function setExplorerPreferences(preferences, value) {
|
||||
preferences.explorer = normalizeExplorer({
|
||||
...preferences.explorer,
|
||||
...value,
|
||||
});
|
||||
return preferences.explorer;
|
||||
}
|
||||
|
||||
export function setPlayerPreferences(preferences, value) {
|
||||
preferences.player = normalizePlayer({
|
||||
...preferences.player,
|
||||
...value,
|
||||
});
|
||||
return preferences.player;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "{}"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2011-2021 Marcin Kulik
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 GitHub Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2021 GitHub Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,401 @@
|
||||
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 { createInflateRaw } from "node:zlib";
|
||||
import {
|
||||
MAX_CENTRAL_DIRECTORY_BYTES,
|
||||
MAX_STREAMED_ENTRY_BYTES,
|
||||
MAX_ZIP_ENTRIES,
|
||||
} from "./constants.mjs";
|
||||
|
||||
const EOCD_SIGNATURE = 0x06054b50;
|
||||
const CENTRAL_SIGNATURE = 0x02014b50;
|
||||
const LOCAL_SIGNATURE = 0x04034b50;
|
||||
const ZIP64_U16 = 0xffff;
|
||||
const ZIP64_U32 = 0xffffffff;
|
||||
|
||||
const crcTable = new Uint32Array(256);
|
||||
for (let index = 0; index < 256; index++) {
|
||||
let value = index;
|
||||
for (let bit = 0; bit < 8; bit++) {
|
||||
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
|
||||
}
|
||||
crcTable[index] = value >>> 0;
|
||||
}
|
||||
|
||||
function crc32(buffer) {
|
||||
let value = 0xffffffff;
|
||||
for (const byte of buffer) {
|
||||
value = crcTable[(value ^ byte) & 0xff] ^ (value >>> 8);
|
||||
}
|
||||
return (value ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
async function readExactly(handle, length, position) {
|
||||
const buffer = Buffer.allocUnsafe(length);
|
||||
let offset = 0;
|
||||
while (offset < length) {
|
||||
const result = await handle.read(buffer, offset, length - offset, position + offset);
|
||||
if (result.bytesRead === 0) {
|
||||
throw new Error("ZIP archive ended unexpectedly.");
|
||||
}
|
||||
offset += result.bytesRead;
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function safeEntryPath(rawName) {
|
||||
if (!rawName || rawName.includes("\0")) {
|
||||
throw new Error("ZIP archive contains an invalid file name.");
|
||||
}
|
||||
const slashed = rawName.replaceAll("\\", "/").replace(/^\.\/+/, "");
|
||||
const normalized = posix.normalize(slashed);
|
||||
if (
|
||||
normalized === "." ||
|
||||
normalized === ".." ||
|
||||
normalized.startsWith("../") ||
|
||||
normalized.startsWith("/") ||
|
||||
/^[A-Za-z]:\//.test(normalized)
|
||||
) {
|
||||
throw new Error(`ZIP archive contains an unsafe path: ${rawName}`);
|
||||
}
|
||||
if (normalized.length > 2_048) {
|
||||
throw new Error("ZIP archive contains an excessively long file name.");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function dosDateTime(date, time) {
|
||||
if (!date) return null;
|
||||
const year = 1980 + ((date >> 9) & 0x7f);
|
||||
const month = (date >> 5) & 0x0f;
|
||||
const day = date & 0x1f;
|
||||
const hour = (time >> 11) & 0x1f;
|
||||
const minute = (time >> 5) & 0x3f;
|
||||
const second = (time & 0x1f) * 2;
|
||||
const value = new Date(Date.UTC(year, Math.max(0, month - 1), day, hour, minute, second));
|
||||
return Number.isNaN(value.getTime()) ? null : value.toISOString();
|
||||
}
|
||||
|
||||
function decodeName(buffer, utf8) {
|
||||
// GitHub-generated artifacts use UTF-8. For legacy archives, decoding as
|
||||
// latin1 preserves a stable byte-for-byte lookup instead of replacing bytes.
|
||||
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.");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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) {
|
||||
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.");
|
||||
}
|
||||
if (entryCount > MAX_ZIP_ENTRIES) {
|
||||
throw new Error(`ZIP contains too many entries (${entryCount.toLocaleString()}).`);
|
||||
}
|
||||
if (centralSize > MAX_CENTRAL_DIRECTORY_BYTES) {
|
||||
throw new Error("ZIP central directory exceeds the 64 MiB safety limit.");
|
||||
}
|
||||
if (centralOffset + centralSize > archive.size) {
|
||||
throw new Error("ZIP central directory points outside the archive.");
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
|
||||
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 (entries.length !== entryCount) {
|
||||
throw new Error(
|
||||
`ZIP entry count mismatch. Expected ${entryCount}, found ${entries.length}.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
path: zipPath,
|
||||
size: archive.size,
|
||||
entries,
|
||||
totalUncompressedBytes: entries.reduce(
|
||||
(total, entry) => total + (entry.directory ? 0 : entry.uncompressedSize),
|
||||
0,
|
||||
),
|
||||
};
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function entryDataRange(zipPath, entry) {
|
||||
const handle = await open(zipPath, "r");
|
||||
try {
|
||||
const header = await readExactly(handle, 30, entry.localHeaderOffset);
|
||||
if (header.readUInt32LE(0) !== LOCAL_SIGNATURE) {
|
||||
throw new Error(`ZIP local header is missing for ${entry.name}.`);
|
||||
}
|
||||
const nameLength = header.readUInt16LE(26);
|
||||
const extraLength = header.readUInt16LE(28);
|
||||
const start = entry.localHeaderOffset + 30 + nameLength + extraLength;
|
||||
const endExclusive = start + entry.compressedSize;
|
||||
const archive = await stat(zipPath);
|
||||
if (endExclusive > archive.size) {
|
||||
throw new Error(`ZIP entry data points outside the archive: ${entry.name}`);
|
||||
}
|
||||
return { start, endExclusive };
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function findZipEntry(index, requestedName) {
|
||||
const normalized = safeEntryPath(String(requestedName ?? ""));
|
||||
return index.entries.find((entry) => entry.name === normalized) ?? null;
|
||||
}
|
||||
|
||||
export async function readZipEntry(zipPath, entry, maxBytes) {
|
||||
if (!entry?.supported) {
|
||||
throw new Error(`ZIP entry cannot be previewed: ${entry?.name ?? "unknown"}`);
|
||||
}
|
||||
if (entry.uncompressedSize > maxBytes) {
|
||||
throw new Error(
|
||||
`ZIP entry is too large for an inline preview (${entry.uncompressedSize.toLocaleString()} bytes).`,
|
||||
);
|
||||
}
|
||||
const { start } = await entryDataRange(zipPath, entry);
|
||||
const handle = await open(zipPath, "r");
|
||||
let compressed;
|
||||
try {
|
||||
compressed = await readExactly(handle, entry.compressedSize, start);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
let output;
|
||||
if (entry.method === 0) {
|
||||
output = compressed;
|
||||
} else {
|
||||
const cap = entry.uncompressedSize;
|
||||
output = await new Promise((resolve, reject) => {
|
||||
const inflate = createInflateRaw();
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
inflate.on("data", (chunk) => {
|
||||
total += chunk.length;
|
||||
if (total > cap) {
|
||||
inflate.destroy(new Error(`ZIP entry decompressed past declared size: ${entry.name}`));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
inflate.once("end", () => resolve(Buffer.concat(chunks)));
|
||||
inflate.once("error", reject);
|
||||
inflate.end(compressed);
|
||||
});
|
||||
}
|
||||
if (output.length !== entry.uncompressedSize) {
|
||||
throw new Error(`ZIP entry size check failed: ${entry.name}`);
|
||||
}
|
||||
if (crc32(output) !== entry.crc32) {
|
||||
throw new Error(`ZIP entry checksum failed: ${entry.name}`);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function createVerifyTransform(entry) {
|
||||
const cap = Math.min(entry.uncompressedSize, MAX_STREAMED_ENTRY_BYTES);
|
||||
let total = 0;
|
||||
let crcValue = 0xffffffff;
|
||||
return new Transform({
|
||||
transform(chunk, _encoding, callback) {
|
||||
total += chunk.length;
|
||||
if (total > cap) {
|
||||
callback(new Error(`ZIP entry decompressed past declared size: ${entry.name}`));
|
||||
return;
|
||||
}
|
||||
for (const byte of chunk) {
|
||||
crcValue = crcTable[(crcValue ^ byte) & 0xff] ^ (crcValue >>> 8);
|
||||
}
|
||||
callback(null, chunk);
|
||||
},
|
||||
flush(callback) {
|
||||
if (total !== entry.uncompressedSize) {
|
||||
callback(new Error(`ZIP entry size check failed: ${entry.name}`));
|
||||
return;
|
||||
}
|
||||
const actual = (crcValue ^ 0xffffffff) >>> 0;
|
||||
if (actual !== entry.crc32) {
|
||||
callback(new Error(`ZIP entry checksum failed: ${entry.name}`));
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function streamZipEntry(zipPath, entry, writable, transform = 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.");
|
||||
}
|
||||
if (entry.compressedSize === 0) {
|
||||
writable.end();
|
||||
return;
|
||||
}
|
||||
const { start, endExclusive } = await entryDataRange(zipPath, entry);
|
||||
const source = createReadStream(zipPath, { start, end: endExclusive - 1 });
|
||||
const streams = [source];
|
||||
if (entry.method !== 0) streams.push(createInflateRaw());
|
||||
streams.push(createVerifyTransform(entry));
|
||||
if (transform) streams.push(transform);
|
||||
streams.push(writable);
|
||||
await pipeline(...streams);
|
||||
}
|
||||
|
||||
export async function readEntryPrefix(zipPath, entry, maxBytes) {
|
||||
if (!entry?.supported) return null;
|
||||
if (entry.uncompressedSize === 0) return Buffer.alloc(0);
|
||||
const { start } = await entryDataRange(zipPath, entry);
|
||||
if (entry.method === 0) {
|
||||
const readBytes = Math.min(maxBytes, entry.compressedSize);
|
||||
const handle = await open(zipPath, "r");
|
||||
try {
|
||||
return await readExactly(handle, readBytes, start);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
const handle = await open(zipPath, "r");
|
||||
let compressed;
|
||||
try {
|
||||
compressed = await readExactly(handle, entry.compressedSize, start);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const inflate = createInflateRaw();
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
let done = false;
|
||||
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;
|
||||
chunks.push(slice);
|
||||
total += slice.length;
|
||||
if (total >= maxBytes) {
|
||||
done = true;
|
||||
inflate.destroy();
|
||||
resolve(Buffer.concat(chunks));
|
||||
}
|
||||
});
|
||||
inflate.once("end", () => {
|
||||
if (!done) {
|
||||
done = true;
|
||||
resolve(Buffer.concat(chunks));
|
||||
}
|
||||
});
|
||||
inflate.once("error", (err) => {
|
||||
if (!done) {
|
||||
done = true;
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
inflate.end(compressed);
|
||||
});
|
||||
}
|
||||