Add PR Artifact Explorer canvas 🤖🤖🤖 (#2341)
* Add PR Artifact Explorer canvas Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 33fefd65-ed18-4eda-9c7d-48008f4a9c9d * Address PR #2341 review feedback and codespell CI failures PR review fixes: - zip.mjs: bounded streaming inflate for readZipEntry (output cap = entry.uncompressedSize) to prevent memory exhaustion from malformed entries - zip.mjs: new bounded verify Transform in streamZipEntry that enforces decompressed byte cap and verifies CRC32 on flush - zip.mjs: new readEntryPrefix() helper that inflates only up to a byte cap for indexing use cases - preview.mjs: move URL/path parsing inside the try block so URIError becomes a 400 response instead of an unhandled rejection - cache.mjs: buildMetadata uses bounded readEntryPrefix (8 KiB) instead of full readEntry+slice, eliminating unbounded decompression during indexing - cache.mjs: clearArtifactCache aborts and awaits in-flight downloads via AbortController before removing cache dirs so clear cannot be repopulated - server.mjs / preview.mjs: track static preview servers by canvas origin and stop them on canvas close, preventing loopback server leaks - trx-preview.js: prefer authoritative ResultSummary.outcome when it is a recognized TRX value; only infer from counters when unknown - extension.mjs: set_account validates the requested id resolves to the active account before persisting; unknown ids now return an error - server.mjs: reject progressive-pull offsets that are not multiples of PROGRESSIVE_PULL_BATCH_SIZE to prevent caching incomplete pulls as complete - README.md: remove Markdown-rendering claim (files are shown as escaped text) CI fix: - .codespellrc: skip vendored asciinema-player(*.min.js) and primer-*.css bundles Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 47369ff4-859a-425f-8a47-763cc1a5f25f * Address second round of PR #2341 review feedback - github.mjs: request isDraft in the pullRequestSignals GraphQL enrichment and propagate draft into search-sourced pulls (search REST API has no draft field) - cache.mjs: give each writeJsonAtomic write a unique temp filename via a module sequence and clean up the temp file on failure to avoid concurrent-write races - cache.mjs: deleteCachedArtifact now aborts and awaits any in-flight download for the artifact before removing files; internal mismatched-metadata purge uses a raw removeArtifactFiles helper to avoid aborting its own operation - accounts.mjs: skip non-github.com CLI accounts before reading tokens so GitHub Enterprise Server credentials are never sent to api.github.com - preview.mjs: require the requested entry to be the root index.html before launching a static preview (previously any nested HTML file could start one) - zip.mjs: validate the EOCD comment length ends exactly at the archive tail so a file comment containing the EOCD signature is not mistaken for the record Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 47369ff4-859a-425f-8a47-763cc1a5f25f * Address remaining PR artifact explorer feedback Fix malformed preference normalization, replace artifact-presence request fanout with cached repository artifact pagination, restrict static previews to the root index, and derive completed TRX outcomes from counters. Add focused regression coverage for all four behaviors. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: db486df1-17f1-4623-ac7e-61eff2c76da4 * Fix PR artifact explorer review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6ee2bc81-2114-4b1f-987a-cb47ea35e132 * Stabilize artifact discovery and cache cleanup Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6ee2bc81-2114-4b1f-987a-cb47ea35e132 --------- Co-authored-by: David Pine <7679720+IEvangelist@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 33fefd65-ed18-4eda-9c7d-48008f4a9c9d Copilot-Session: 47369ff4-859a-425f-8a47-763cc1a5f25f Copilot-Session: db486df1-17f1-4623-ac7e-61eff2c76da4 Copilot-Session: 6ee2bc81-2114-4b1f-987a-cb47ea35e132
@@ -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": "."
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||