repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/mockup/mockup-tool.ts
Rawimport { randomUUID } from "node:crypto";
import {
access,
mkdir,
readdir,
readFile,
rm,
stat,
writeFile,
} from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import {
defineTool,
type ExtensionContext,
keyHint,
} from "@earendil-works/pi-coding-agent";
import { getCapabilities, hyperlink, Text } from "@earendil-works/pi-tui";
import { MOCKUP_DESCRIPTION, mockupParameters } from "./contract.js";
import {
type MockupRequest,
type MockupResult,
type MockupServer,
startMockupServer,
validateMockupRequest,
} from "./mockup.js";
import {
createMockupPreviewManager,
MockupPreviewComponent,
type MockupPreviewManager,
PreviewCancelledError,
} from "./mockup-preview.js";
const CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const CACHE_LIMIT_BYTES = 256 * 1024 * 1024;
export type MockupHistoryEntry = {
iteration: number;
question: string;
options: Array<{ id: string; label: string }>;
action: MockupResult["action"];
selected: string[];
notes: string;
annotations: MockupResult["annotations"];
cacheManifest?: string;
};
export type MockupToolDetails = MockupResult & {
sessionId: string;
chainId: string;
iteration: number;
url: string;
cacheManifest?: string;
cacheError?: string;
history: MockupHistoryEntry[];
contextCapsule: string;
};
type MockupProgressDetails = {
kind: "waiting";
status: "opening" | "waiting";
url: string;
iteration: number;
browserFailed?: boolean;
timings?: {
serverMs?: number;
openerMs?: number;
};
};
type PreviewProgressDetails = {
kind: "preview";
status: "starting" | "running" | "complete";
};
type CancelledDetails = {
kind: "cancelled";
};
type ActiveChain = {
server: MockupServer;
chainId: string;
};
type RuntimeOptions = {
cacheRoot?: string;
/** Resolved per browser-lane execution; defaults to opening. */
openBrowser?: (ctx: ExtensionContext) => boolean;
browserOpener?: (url: string) => Promise<boolean>;
previewManager?: MockupPreviewManager;
};
export function createMockupRuntime(options: RuntimeOptions = {}) {
const active = new Map<string, ActiveChain>();
const contexts = new Map<string, ExtensionContext>();
const previews = options.previewManager ?? createMockupPreviewManager();
const cacheRoot = options.cacheRoot ?? mockupCacheRoot();
const remember = (ctx: ExtensionContext): string => {
const sessionId = ctx.sessionManager.getSessionId();
contexts.set(sessionId, ctx);
return sessionId;
};
const abortAgent = (sessionId: string): boolean => {
const ctx = contexts.get(sessionId);
if (!ctx || ctx.isIdle()) return false;
try {
ctx.abort();
return true;
} catch {
return false;
}
};
const schedulePrune = (protectedSessionId?: string): void => {
const timer = setTimeout(() => {
void pruneMockupCache(cacheRoot, protectedSessionId).catch(
() => undefined,
);
}, 0);
timer.unref?.();
};
const tool = defineTool({
name: "mockup",
label: "mockup",
description: MOCKUP_DESCRIPTION,
promptSnippet:
"Preview HTML mockups in Pi or review them interactively in a browser",
parameters: mockupParameters,
renderShell: "self",
renderCall(args, theme, context) {
const text =
(context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
const options = Array.isArray(args.options) ? args.options : [];
const optionCount = options.length;
const optionWord = `option${optionCount === 1 ? "" : "s"}`;
const htmlCharacters = options.reduce(
(total, option) =>
total + (typeof option.html === "string" ? option.html.length : 0),
0,
);
const phase =
context.isPartial && !context.argsComplete
? ` · generating HTML${htmlCharacters > 0 ? ` · ${htmlCharacters} chars` : ""}`
: context.isPartial && !context.executionStarted
? " · waiting to run"
: "";
let output = `${theme.fg("toolTitle", theme.bold("mockup"))} ${theme.fg("accent", args.title ?? "untitled")} ${theme.fg("toolOutput", `· ${optionCount} ${optionWord}${args.multi ? " · multi-select" : ""}${phase}`)}`;
if (!context.expanded) {
output += theme.fg(
"dim",
` (${keyHint("app.tools.expand", "to expand")})`,
);
} else {
if (args.question)
output += `\n${theme.fg("toolOutput", args.question)}`;
for (const option of args.options ?? []) {
const description = option.description
? theme.fg("dim", ` · ${option.description}`)
: "";
output += `\n${theme.fg("accent", String(option.id))} ${theme.fg("toolOutput", option.label)}${description}`;
}
}
text.setText(output);
return text;
},
renderResult(result, { expanded, isPartial }, theme, context) {
const details = result.details as
| Partial<MockupToolDetails>
| MockupProgressDetails
| PreviewProgressDetails
| CancelledDetails
| undefined;
if (details && "kind" in details && details.kind === "preview") {
const preview = previews.get(context.toolCallId);
if (preview) {
if (context.lastComponent instanceof MockupPreviewComponent) {
context.lastComponent.update(
(style, value) => theme.fg(style, value),
context.invalidate,
context.showImages,
);
if (details.status === "complete") context.lastComponent.settle();
return context.lastComponent;
}
return new MockupPreviewComponent(
preview,
(style, value) => theme.fg(style, value),
context.invalidate,
context.showImages,
);
}
if (details.status === "starting")
return new Text(theme.fg("accent", "Preparing previews…"), 0, 0);
return new Text(
theme.fg(
"muted",
"Preview expired. Run the mockup again to render it.",
),
0,
0,
);
}
const text =
context.lastComponent instanceof Text
? context.lastComponent
: new Text("", 0, 0);
if (details && "kind" in details && details.kind === "cancelled") {
text.setText(theme.fg("muted", "Mockup cancelled."));
return text;
}
if (
isPartial &&
details &&
"kind" in details &&
details.kind === "waiting"
) {
const status = details.browserFailed
? theme.fg("warning", "Browser did not open. Open this mockup:")
: details.status === "opening"
? theme.fg("accent", "Review ready · opening browser…")
: theme.fg("accent", "Review ready · awaiting feedback");
let diagnostics = expanded
? theme.fg("dim", ` · iteration ${details.iteration}`)
: "";
if (expanded && details.timings) {
if (details.timings.serverMs !== undefined)
diagnostics += theme.fg(
"dim",
` · server ${details.timings.serverMs}ms`,
);
if (details.timings.openerMs !== undefined)
diagnostics += theme.fg(
"dim",
` · opener ${details.timings.openerMs}ms`,
);
}
text.setText(`${status}${diagnostics}\n${linkedUrl(details.url)}`);
return text;
}
if (context.isError || !details || "kind" in details) {
text.setText(
expandableFallback(
result.content,
expanded,
theme,
context.isError ? "error" : "muted",
),
);
return text;
}
const action = details.action ?? "submitted";
const status =
action === "accept"
? theme.fg("success", "✓ accepted")
: theme.fg("warning", "↻ iterate");
const selected = (details.selected ?? []).join(", ") || "none";
const annotations = details.annotations ?? [];
let output = `${status}${theme.fg("dim", ` · selected ${selected} · ${annotations.length} annotation${annotations.length === 1 ? "" : "s"}`)}`;
if (!expanded) {
output += theme.fg(
"dim",
` (${keyHint("app.tools.expand", "to expand")})`,
);
} else {
if (details.notes)
output += `\n${theme.fg("toolOutput", `notes: ${details.notes}`)}`;
for (const annotation of annotations) {
output += `\n${theme.fg("accent", annotation.optionId)} ${theme.fg("dim", `@ ${Math.round(annotation.x * 100)}%,${Math.round(annotation.y * 100)}%`)} ${theme.fg("toolOutput", annotation.note)}`;
}
if (details.url) output += `\n${linkedUrl(details.url)}`;
if (details.history && details.history.length > 1)
output += `\n${theme.fg("dim", `iterations: ${details.history.length}`)}`;
if (details.cacheManifest)
output += `\n${theme.fg("dim", `cache: ${details.cacheManifest}`)}`;
else if (details.cacheError)
output += `\n${theme.fg("warning", `cache unavailable: ${details.cacheError}`)}`;
}
text.setText(output);
return text;
},
async execute(toolCallId, params, signal, onUpdate, ctx) {
const request = validateMockupRequest(params);
const sessionId = remember(ctx);
const previous = latestMockupDetails(ctx);
const continuing =
previous?.sessionId === sessionId && previous.action === "iterate";
const pendingChain = active.get(sessionId);
const continuingInBrowser =
continuing && pendingChain?.chainId === previous.chainId;
const lane = continuingInBrowser
? "browser"
: await selectMockupLane(ctx, signal);
if (lane === "cancel") return cancelledResult();
if (pendingChain && !continuingInBrowser) {
pendingChain.server.close("superseded");
active.delete(sessionId);
}
if (lane === "preview") {
onUpdate?.({
content: [{ type: "text", text: "Preparing mockup previews in Pi." }],
details: { kind: "preview", status: "starting" },
});
try {
const preview = await previews.start(toolCallId, request, signal);
onUpdate?.({
content: [{ type: "text", text: "Showing mockup previews in Pi." }],
details: { kind: "preview", status: "running" },
});
await abortable(preview.done, signal, () => preview.close());
return {
content: [
{
type: "text" as const,
text: JSON.stringify({
action: "previewed",
next: "Wait for the human's conversational feedback. Do not infer acceptance, rejection, or selection from previewing.",
}),
},
],
details: { kind: "preview" as const, status: "complete" as const },
terminate: true,
};
} catch (error) {
previews.get(toolCallId)?.close();
if (signal?.aborted || error instanceof PreviewCancelledError)
return cancelledResult();
ctx.ui.notify(
`Pi preview unavailable: ${error instanceof Error ? error.message : String(error)}`,
"warning",
);
if ((await selectBrowserFallback(ctx, signal)) !== "browser")
return cancelledResult();
}
}
let chain = continuingInBrowser ? pendingChain : undefined;
const created = !chain;
let serverMs: number | undefined;
if (!chain) {
const serverStartedAt = performance.now();
chain = {
chainId: continuing ? previous.chainId : randomUUID(),
server: await startMockupServer(request, {
openBrowser: options.openBrowser?.(ctx) ?? true,
browserOpener: options.browserOpener,
abortAgent: () => abortAgent(sessionId),
}),
};
serverMs = Math.round(performance.now() - serverStartedAt);
active.set(sessionId, chain);
}
const iteration = continuing ? previous.iteration + 1 : 1;
const history = continuing ? previous.history : [];
try {
const connected = chain.server.connected();
const resultPromise = created
? chain.server.result
: chain.server.present(request);
let openerMs: number | undefined;
const progress = (
status: MockupProgressDetails["status"],
browserFailed = false,
) =>
onUpdate?.({
content: [
{
type: "text" as const,
text:
status === "opening"
? `Review ready; opening browser: ${chain.server.url}`
: `Review ready; awaiting feedback: ${chain.server.url}`,
},
],
details: {
kind: "waiting" as const,
status,
url: chain.server.url,
iteration,
timings: {
...(serverMs !== undefined && { serverMs }),
...(openerMs !== undefined && { openerMs }),
},
...(browserFailed && { browserFailed: true }),
},
});
if (!connected) {
progress("opening");
const openerStartedAt = performance.now();
const opened = await chain.server.open();
openerMs = Math.round(performance.now() - openerStartedAt);
progress("waiting", opened === false);
} else {
progress("waiting");
}
const result = await abortable(resultPromise, signal, () =>
chain?.server.close("aborted"),
);
let cacheManifest: string | undefined;
let cacheError: string | undefined;
try {
cacheManifest = await persistMockupIteration(cacheRoot, {
sessionId,
sessionFile: ctx.sessionManager.getSessionFile(),
chainId: chain.chainId,
iteration,
toolCallId,
request,
result,
previousManifest: continuing ? previous.cacheManifest : undefined,
});
} catch (error) {
cacheError = error instanceof Error ? error.message : String(error);
}
const current = historyEntry(iteration, request, result, cacheManifest);
const nextHistory = [...history, current];
const contextCapsule = formatContextCapsule(
chain.chainId,
nextHistory,
cacheError,
);
const details: MockupToolDetails = {
...result,
sessionId,
chainId: chain.chainId,
iteration,
url: chain.server.url,
cacheManifest,
cacheError,
history: nextHistory,
contextCapsule,
};
if (result.action === "accept") {
chain.server.close("accepted");
active.delete(sessionId);
schedulePrune(sessionId);
}
return {
content: [
{
type: "text" as const,
text: JSON.stringify({
action: result.action,
selected: result.selected,
notes: result.notes,
annotations: result.annotations,
cacheManifest,
contextCapsule,
next:
result.action === "iterate"
? "Apply the feedback and call mockup again unless the human redirects or stops the review."
: "Continue with the accepted direction.",
}),
},
],
details,
};
} catch (error) {
chain.server.close("failed");
active.delete(sessionId);
schedulePrune(sessionId);
throw error;
}
},
});
return {
tool,
remember,
contextCapsule(ctx: ExtensionContext): string | undefined {
const sessionId = remember(ctx);
const details = latestMockupDetails(ctx);
return details?.sessionId === sessionId
? details.contextCapsule
: undefined;
},
agentWorking(ctx: ExtensionContext): void {
const sessionId = remember(ctx);
active.get(sessionId)?.server.progress("Pi is working on the revision.");
},
agentSettled(ctx: ExtensionContext): void {
const sessionId = remember(ctx);
const chain = active.get(sessionId);
if (chain?.server.settled()) {
active.delete(sessionId);
schedulePrune(sessionId);
}
},
async shutdown(ctx: ExtensionContext): Promise<void> {
const sessionId = ctx.sessionManager.getSessionId();
active.get(sessionId)?.server.close("session ended");
active.delete(sessionId);
contexts.delete(sessionId);
await previews.close();
schedulePrune();
},
};
}
type MockupLane = "preview" | "browser" | "cancel";
async function selectMockupLane(
ctx: ExtensionContext,
signal?: AbortSignal,
): Promise<MockupLane> {
if (ctx.mode !== "tui") return "browser";
const unavailable = previewUnavailableReason();
if (unavailable) {
ctx.ui.notify(unavailable, "warning");
return selectBrowserFallback(ctx, signal);
}
const choice = await ctx.ui.select(
"How should this mockup open?",
["Show previews in Pi", "Open interactive browser", "Cancel"],
{ signal },
);
if (choice === "Show previews in Pi") return "preview";
if (choice === "Open interactive browser") return "browser";
return "cancel";
}
async function selectBrowserFallback(
ctx: ExtensionContext,
signal?: AbortSignal,
): Promise<"browser" | "cancel"> {
if (ctx.mode !== "tui") return "browser";
const choice = await ctx.ui.select(
"Use the interactive browser instead?",
["Open interactive browser", "Cancel"],
{ signal },
);
return choice === "Open interactive browser" ? "browser" : "cancel";
}
function previewUnavailableReason(): string | undefined {
if (!getCapabilities().images)
return "This terminal does not support inline image previews.";
const fullscreen = process.argv.some(
(argument, index) =>
argument === "--tui-mode=fullscreen" ||
(argument === "--tui-mode" && process.argv[index + 1] === "fullscreen"),
);
if (fullscreen && getCapabilities().images === "kitty")
return "Inline previews are unavailable in fullscreen mode until Pi's image redraw regression is fixed.";
return undefined;
}
function cancelledResult() {
return {
content: [
{
type: "text" as const,
text: JSON.stringify({
action: "cancelled",
next: "Do not continue the mockup review.",
}),
},
],
details: { kind: "cancelled" as const },
terminate: true,
};
}
function latestMockupDetails(
ctx: ExtensionContext,
): MockupToolDetails | undefined {
const branch = ctx.sessionManager.getBranch();
for (let index = branch.length - 1; index >= 0; index--) {
const entry = branch[index] as unknown as Record<string, unknown>;
if (entry.type !== "message") continue;
const message = entry.message;
if (!isRecord(message)) continue;
if (message.role !== "toolResult" || message.toolName !== "mockup")
continue;
const details = message.details;
if (!isRecord(details) || typeof details.contextCapsule !== "string")
return undefined;
return details as unknown as MockupToolDetails;
}
return undefined;
}
function historyEntry(
iteration: number,
request: MockupRequest,
result: MockupResult,
cacheManifest?: string,
): MockupHistoryEntry {
return {
iteration,
question: request.question,
options: request.options.map(({ id, label }) => ({ id, label })),
action: result.action,
selected: result.selected,
notes: result.notes,
annotations: result.annotations,
cacheManifest,
};
}
export function formatContextCapsule(
chainId: string,
history: MockupHistoryEntry[],
cacheError?: string,
): string {
const latest = history.at(-1);
if (!latest) return "";
const lines = [
"Mockup context capsule",
`Chain: ${chainId}`,
`Current: iteration ${latest.iteration}, ${latest.action}`,
`Question: ${latest.question}`,
`Selected: ${selectedLabels(latest)}`,
`Notes: ${latest.notes || "none"}`,
];
if (latest.annotations.length === 0) lines.push("Annotations: none");
else {
lines.push("Annotations:");
for (const annotation of latest.annotations)
lines.push(
`- ${annotation.optionId} @ ${Math.round(annotation.x * 100)}%,${Math.round(annotation.y * 100)}%: ${annotation.note}`,
);
}
lines.push(
"Rejected rationale: not recorded separately; never infer rejection from non-selection.",
`Cache manifest: ${latest.cacheManifest ?? `unavailable${cacheError ? ` (${cacheError})` : ""}`}`,
);
if (history.length > 1) {
lines.push("Earlier iterations, newest first:");
for (const entry of history.slice(0, -1).reverse()) {
lines.push(
`- ${entry.iteration} ${entry.action}; selected ${selectedLabels(entry)}; notes: ${entry.notes || "none"}`,
);
for (const annotation of entry.annotations)
lines.push(` annotation ${annotation.optionId}: ${annotation.note}`);
if (entry.cacheManifest) lines.push(` cache: ${entry.cacheManifest}`);
}
}
return lines.join("\n");
}
function linkedUrl(url: string): string {
return getCapabilities().hyperlinks ? hyperlink(url, url) : url;
}
function expandableFallback(
content: Array<{ type: string; text?: string }>,
expanded: boolean,
theme: { fg(style: string, text: string): string },
style: "error" | "muted",
): string {
const full = content
.flatMap((item) => (item.type === "text" && item.text ? [item.text] : []))
.join("\n")
.trim();
if (expanded) return theme.fg(style, full);
const firstLine = full.split(/\r?\n/, 1)[0] ?? "";
const normalized = firstLine.replace(/\s+/g, " ").trim();
const summary =
normalized.length > 160 ? `${normalized.slice(0, 159)}…` : normalized;
return full !== firstLine || summary !== normalized
? `${theme.fg(style, summary)}\n${theme.fg("muted", "↳")} ${keyHint("app.tools.expand", "to expand")}`
: theme.fg(style, summary);
}
function selectedLabels(entry: MockupHistoryEntry): string {
if (entry.selected.length === 0) return "none";
const labels = new Map(
entry.options.map((option) => [option.id, option.label]),
);
return entry.selected
.map((id) => `${id} (${labels.get(id) ?? id})`)
.join(", ");
}
export function mockupCacheRoot(
platform: NodeJS.Platform = process.platform,
env: NodeJS.ProcessEnv = process.env,
home: string = homedir(),
): string {
if (platform === "win32")
return path.join(
env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"),
"pi",
"cache",
"mockups",
);
if (platform === "darwin")
return path.join(home, "Library", "Caches", "pi", "mockups");
return path.join(
env.XDG_CACHE_HOME ?? path.join(home, ".cache"),
"pi",
"mockups",
);
}
type PersistInput = {
sessionId: string;
sessionFile: string | undefined;
chainId: string;
iteration: number;
toolCallId: string;
request: MockupRequest;
result: MockupResult;
previousManifest?: string;
};
export async function persistMockupIteration(
root: string,
input: PersistInput,
): Promise<string> {
const sessionDir = path.join(root, safeSegment(input.sessionId));
const iterationDir = path.join(
sessionDir,
safeSegment(input.chainId),
`${String(input.iteration).padStart(3, "0")}-${safeSegment(input.toolCallId)}`,
);
await mkdir(iterationDir, { recursive: true });
await Promise.all([
writeFile(
path.join(sessionDir, "session.json"),
`${JSON.stringify({ sessionId: input.sessionId, sessionFile: input.sessionFile }, null, 2)}\n`,
),
writeFile(
path.join(iterationDir, "request.json"),
`${JSON.stringify(input.request, null, 2)}\n`,
),
writeFile(
path.join(iterationDir, "feedback.json"),
`${JSON.stringify(input.result, null, 2)}\n`,
),
...input.request.options.map((option) =>
writeFile(
path.join(iterationDir, `${safeSegment(option.id)}.html`),
option.html,
),
),
]);
const manifest = path.join(iterationDir, "manifest.json");
await writeFile(
manifest,
`${JSON.stringify(
{
version: 1,
recordedAt: new Date().toISOString(),
sessionId: input.sessionId,
chainId: input.chainId,
iteration: input.iteration,
toolCallId: input.toolCallId,
previousManifest: input.previousManifest,
request: "request.json",
feedback: "feedback.json",
options: input.request.options.map((option) => ({
id: option.id,
label: option.label,
html: `${safeSegment(option.id)}.html`,
})),
},
null,
2,
)}\n`,
);
return manifest;
}
export async function pruneMockupCache(
root: string,
protectedSessionId?: string,
now = Date.now(),
policy: { ttlMs: number; maxBytes: number } = {
ttlMs: CACHE_TTL_MS,
maxBytes: CACHE_LIMIT_BYTES,
},
): Promise<void> {
const sessions = await directories(root);
const candidates: Array<{ path: string; modified: number; bytes: number }> =
[];
for (const session of sessions) {
if (session.name === safeSegment(protectedSessionId ?? "")) continue;
const metadataPath = path.join(session.path, "session.json");
const metadata = await readJson(metadataPath);
if (
isRecord(metadata) &&
typeof metadata.sessionFile === "string" &&
!(await exists(metadata.sessionFile))
) {
await rm(session.path, { recursive: true, force: true });
continue;
}
for (const chain of await directories(session.path)) {
for (const iteration of await directories(chain.path)) {
const info = await stat(iteration.path);
if (now - info.mtimeMs >= policy.ttlMs) {
await rm(iteration.path, { recursive: true, force: true });
continue;
}
candidates.push({
path: iteration.path,
modified: info.mtimeMs,
bytes: await directorySize(iteration.path),
});
}
}
}
let total = candidates.reduce((sum, candidate) => sum + candidate.bytes, 0);
for (const candidate of candidates.sort(
(left, right) => left.modified - right.modified,
)) {
if (total <= policy.maxBytes) break;
await rm(candidate.path, { recursive: true, force: true });
total -= candidate.bytes;
}
}
async function directories(
root: string,
): Promise<Array<{ name: string; path: string }>> {
try {
return (await readdir(root, { withFileTypes: true }))
.filter((entry) => entry.isDirectory() && !entry.isSymbolicLink())
.map((entry) => ({
name: entry.name,
path: path.join(root, entry.name),
}));
} catch (error) {
if (isNotFound(error)) return [];
throw error;
}
}
async function directorySize(root: string): Promise<number> {
let bytes = 0;
for (const entry of await readdir(root, { withFileTypes: true })) {
if (entry.isSymbolicLink()) continue;
const child = path.join(root, entry.name);
if (entry.isDirectory()) bytes += await directorySize(child);
else if (entry.isFile()) bytes += (await stat(child)).size;
}
return bytes;
}
async function readJson(file: string): Promise<unknown> {
try {
return JSON.parse(await readFile(file, "utf8"));
} catch (error) {
if (isNotFound(error)) return undefined;
throw error;
}
}
async function exists(file: string): Promise<boolean> {
try {
await access(file);
return true;
} catch {
return false;
}
}
function safeSegment(value: string): string {
return value.replaceAll(/[^a-zA-Z0-9._-]/g, "_") || "unknown";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function isNotFound(error: unknown): boolean {
return isRecord(error) && error.code === "ENOENT";
}
function abortable<T>(
promise: Promise<T>,
signal: AbortSignal | undefined,
abort: () => void,
): Promise<T> {
if (!signal) return promise;
if (signal.aborted) {
abort();
return Promise.reject(new Error("mockup aborted"));
}
return new Promise((resolve, reject) => {
const onAbort = () => {
abort();
reject(new Error("mockup aborted"));
};
signal.addEventListener("abort", onAbort, { once: true });
promise
.then(resolve, reject)
.finally(() => signal.removeEventListener("abort", onAbort));
});
}