fix(studio): review round 2 - a conversion that fails after its commit recovers without a restart

- The convert job reloads the course on success and on failure, so learners
  get the intact import (rolled back) or the published studio course, never a
  course whose folder moved away.
- POST /convert on a course with an incomplete conversion (.converting-<id>,
  staging or retired folders) queues a job that recovers and finishes it
  instead of answering 409; the studio page shows "Conversion incomplete" with
  Retry for the owner.
- The 409 for pending import jobs names their kinds and ids; the dead withLock
  in git.ts is gone; the dot-folder discovery rule is documented in
  docs/operations.md and spec section 5.
- Route tests (SQLite and Postgres) inject a failed cross-filesystem copy, a
  checksum mismatch and a pre-commit failure.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 21:00:45 +01:00
co-authored by Claude Opus 5.5
parent 771d755da4
commit f02e30450c
11 changed files with 168 additions and 34 deletions
+9
View File
@@ -143,6 +143,15 @@ Things to know:
- After conversion the importer can no longer add decks, code or transcripts to that course (it only edits
`<data>/courses`). Add them in the studio.
- `<data>/media` is fixed under the data directory (there is no separate setting).
- **Discovery skips dot-folders.** Every course directory (`STC_COURSE_DIRS`, `<data>/courses`, `<data>/worktrees`)
is scanned to depth 2 for `stcourse.yaml`, and folders whose name starts with `.` are never entered. Do not give a
course folder a name that starts with a dot. The conversion's `.converting-<id>` and `.converted-<id>.<n>` folders
rely on this.
- **A failed conversion.** If the media move fails after the commit (disk full, or a copy that fails its checksum),
learners are switched to the studio course right away; its media are hard links into the hidden import folder.
The course's studio page shows **Conversion incomplete** with a **Retry** button for the owner. A retry (or the
next server start) finishes the move. A failure before the commit is rolled back, and learners keep the import
unchanged.
## Memory budget
@@ -181,6 +181,7 @@ Phase 0 (section 10) pins down the exact schema. These principles are fixed:
- The two reference courses must load by adding only a manifest and front matter, without moving or rewriting their existing files.
- Their existing tooling (test scripts, `tools/*.mjs`) must keep working.
- **Derived data lives in a cache directory that can be rebuilt from the files:** search index, slide images and text, transcripts, video posters and AI embeddings.
- **Discovery** (v1.1 note): the platform finds `stcourse.yaml` at depth 0–2 under each course directory and skips folders whose name starts with `.` (and `node_modules`). A course folder must therefore not be a dot-folder. The studio relies on this to hide an import folder while it is converted (`.converting-<id>`, `.converted-<id>.<n>`).
## 6. Accounts, roles and visibility
+26 -10
View File
@@ -6,7 +6,7 @@ import { ManifestSchema, parseManifest, resolveInside, type LoadedCourse } from
import { aclRepo } from "@stc/db";
import { createDraftWriter, type DraftWriter } from "./draft-writer";
import { storeDirs, studioWorktreesDir } from "./paths";
import { checkRelPath, createStudioStore, MANIFEST, ownerRequiredFor, StudioError, type FileWrite, type StudioStore } from "./store";
import { checkRelPath, createStudioStore, MANIFEST, ownerRequiredFor, StudioError, type FileWrite, type StudioStore, type StudioStoreOptions } from "./store";
import { createPublishedWatcher, type PublishedWatcher } from "./watch";
export const OUTSIDE_MESSAGE = "This course is managed outside STCourses";
@@ -58,6 +58,8 @@ export interface StudioFeatureOptions {
/** Poll the published worktrees for external commits (default true). */
watch?: boolean;
watchIntervalMs?: number;
/** Tests only: conversion failure injection (see StudioStoreOptions.testHooks). */
storeTestHooks?: StudioStoreOptions["testHooks"];
}
const json = (body: unknown, status = 200) => Response.json(body, { status });
@@ -76,7 +78,7 @@ function manifestToYaml(m: unknown): string {
}
export function studioFeature(ctx: ServerContext, opts: StudioFeatureOptions = {}): StudioFeature {
const store = createStudioStore(storeDirs(ctx.config));
const store = createStudioStore({ ...storeDirs(ctx.config), testHooks: opts.storeTestHooks });
const watcher = createPublishedWatcher(store, ctx.courses, { intervalMs: opts.watchIntervalMs });
const acl = aclRepo(ctx.db);
const wtDir = resolve(ctx.config.studioWorktreesDir ?? studioWorktreesDir(ctx.config.dataDir));
@@ -117,18 +119,27 @@ export function studioFeature(ctx: ServerContext, opts: StudioFeatureOptions = {
const p = (j.payload ?? {}) as { courseId?: unknown; options?: { courseId?: unknown } };
return p.courseId === courseId || p.options?.courseId === courseId;
});
const IMPORT_RUNNING = "an import job for this course is still running; make it editable once it has finished";
/** 409 text naming the blocking importer jobs (id and kind), so the owner can find them on the import page. */
const importRunning = (jobs: { id: string; kind: string }[]) =>
`an import job for this course is still running (${jobs.map((j) => `${j.kind} ${j.id}`).join(", ")}); make it editable once it has finished`;
/** The conversion job: payload `{ courseId, actor }`, checked again by the store under the course lock. */
const convertJob: JobHandler = async (payload, jc) => {
await ready;
const p = (payload ?? {}) as { courseId?: unknown; actor?: { id?: unknown; name?: unknown; email?: unknown } };
const courseId = typeof p.courseId === "string" ? p.courseId : "";
if ((await pendingImports(courseId)).length) throw new StudioError(IMPORT_RUNNING, 409);
const blocking = await pendingImports(courseId);
if (blocking.length) throw new StudioError(importRunning(blocking), 409);
const actor = { name: String(p.actor?.name ?? "STCourses"), email: String(p.actor?.email ?? "[email protected]") };
const r = await store.convertImported(courseId, actor, { onProgress: (f, m) => jc.reportProgress(f, m) });
await afterPublishedChange(courseId);
return { courseId, ...r };
try {
const r = await store.convertImported(courseId, actor, { onProgress: (f, m) => jc.reportProgress(f, m) });
return { courseId, ...r };
} finally {
// Success or failure, learners must get whatever is on disk now: the intact import (failure before the commit,
// rolled back) or the published worktree (after it), never a course whose folder has moved away.
await ctx.courses.reload(courseId).catch((e) => console.error("[studio] reload after conversion", e));
if (store.isManaged(courseId)) await store.publishedHead(courseId).then((h) => watcher.note(courseId, h), () => {});
}
};
const afterPublishedChange = async (courseId: string) => {
@@ -184,6 +195,8 @@ export function studioFeature(ctx: ServerContext, opts: StudioFeatureOptions = {
}
return {
readOnly: false,
conversionIncomplete: store.conversionIncomplete(courseId),
canRetryConversion: isOwner(user),
course: { id: courseId, title: (manifestRaw as { title?: string } | null)?.title ?? courseId, managed: true },
status,
manifestText: m?.content ?? "",
@@ -257,14 +270,17 @@ export function studioFeature(ctx: ServerContext, opts: StudioFeatureOptions = {
POST: guard(async (_req, rc, user) => {
const courseId = rc.params.courseId ?? "";
if (!isOwner(user)) return fail(403, "only the owner can make an imported course editable in the studio");
if (store.isManaged(courseId)) return fail(409, `course "${courseId}" is already a studio course`);
if (!store.isConvertibleImport(courseId)) {
// An incomplete conversion (failed after its commit) is finished by a new job: it recovers first.
const incomplete = store.conversionIncomplete(courseId);
if (store.isManaged(courseId) && !incomplete) return fail(409, `course "${courseId}" is already a studio course`);
if (!incomplete && !store.isConvertibleImport(courseId)) {
const c = ctx.courses.get(courseId);
return c ? fail(409, `${readOnlyMessage(c) === IMPORTED_MESSAGE ? "this imported course" : OUTSIDE_MESSAGE}: only imported courses in ${importedRoot} can be made editable`) : fail(404, "no such imported course");
}
const pending = (await ctx.jobs.list(CONVERT_JOB)).find((j) => (j.payload as { courseId?: string } | null)?.courseId === courseId && (j.status === "queued" || j.status === "running"));
if (pending) return fail(409, "this course is already being made editable");
if ((await pendingImports(courseId)).length) return fail(409, IMPORT_RUNNING);
const blocking = await pendingImports(courseId);
if (blocking.length) return fail(409, importRunning(blocking));
const jobId = await ctx.jobs.enqueue(CONVERT_JOB, { courseId, actor: { id: user.id, name: who(user).name, email: who(user).email } });
return json({ jobId }, 202);
}),
-12
View File
@@ -55,15 +55,3 @@ export async function gitOk(cwd: string, args: string[], who?: Identity): Promis
export function splitZ(out: string): string[] {
return out.split("\0").filter(Boolean);
}
const locks = new Map<string, Promise<unknown>>();
/** Serialises git work per key (the course's bare repo path) across every store instance in the process. */
export function withLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
const prev = locks.get(key) ?? Promise.resolve();
const next = prev.then(fn, fn);
const tail = next.catch(() => {});
locks.set(key, tail);
tail.then(() => { if (locks.get(key) === tail) locks.delete(key); });
return next;
}
+21 -3
View File
@@ -146,8 +146,12 @@ export interface StudioStoreOptions {
publishedBranch?: string;
/** Tests only: allow local-path remotes (the HTTP feature never sets this). */
allowLocalRemotes?: boolean;
/** Tests only: simulate a crash at a named conversion step, or a cross-filesystem media move. */
testHooks?: { crashAt?: string; forceExdev?: boolean };
/**
* Tests only, read on every use (a test may change them between calls): simulate a crash (`crashAt`) or an ordinary
* failure (`failAt`) at a named conversion step, a cross-filesystem media move (`forceExdev`), or a copy whose
* bytes do not match MEDIA_REFS (`corruptStaging`).
*/
testHooks?: { crashAt?: string; failAt?: string; forceExdev?: boolean; corruptStaging?: boolean };
}
export function createStudioStore(opts: StudioStoreOptions) {
@@ -188,7 +192,10 @@ export function createStudioStore(opts: StudioStoreOptions) {
const lock = <T>(courseId: string, fn: () => Promise<T>) => { paths(courseId); return withCourseLock(courseId, fn); };
const convertingDir = (courseId: string) => join(importedDir, `.converting-${courseId}`);
const listDir = (dir: string): string[] => { try { return readdirSync(dir); } catch { return []; } };
const crash = async (step: string) => { if (opts.testHooks?.crashAt === step) throw new SimulatedCrash(step); };
const crash = async (step: string) => {
if (opts.testHooks?.crashAt === step) throw new SimulatedCrash(step);
if (opts.testHooks?.failAt === step) throw new Error(`injected failure at ${step}`);
};
const rename = async (from: string, to: string) => fsRename(from, to);
const rev = async (cwd: string, ref: string) => (await gitOk(cwd, ["rev-parse", ref])).trim();
@@ -380,6 +387,7 @@ export function createStudioStore(opts: StudioStoreOptions) {
const staging = join(mediaDir, `.staging-${courseId}.${Bun.randomUUIDv7()}`);
try {
await cp(from, staging, { recursive: true, verbatimSymlinks: true, errorOnExist: true, force: false });
if (opts.testHooks?.corruptStaging && refs[0]) await writeFile(join(staging, refs[0].path), "corrupted copy");
await crash("staging-copied");
await fsyncTree(staging);
if (!(await mediaComplete(staging, refs, true))) throw new Error(`the copied media in ${staging} do not match ${MEDIA_REFS}`);
@@ -545,6 +553,16 @@ export function createStudioStore(opts: StudioStoreOptions) {
}
},
/**
* A conversion that got past its commit but did not finish (the media move failed or was interrupted): the course
* is served from the published worktree, and `convertImported` (which recovers first) completes it.
*/
conversionIncomplete(courseId: string): boolean {
if (!ID_RE.test(courseId)) return false;
if (lexists(convertingDir(courseId))) return true;
return listDir(mediaDir).some((n) => n.startsWith(`.staging-${courseId}.`)) || listDir(importedDir).some((n) => n.startsWith(`.converted-${courseId}.`));
},
/** Re-links referenced media into the published worktree (after an outside checkout removed them, say). */
repairMedia: (courseId: string) => lock(courseId, async () => {
const p = requireManaged(courseId);
@@ -34,8 +34,8 @@ export function ConvertPanelView({ phase, onConvert }: { phase: ConvertPhase; on
);
}
/** Starts the conversion job and polls it; calls `onDone` once the course is a studio course. */
export function ConvertPanel({ courseId, onDone }: { courseId: string; onDone: () => void }) {
/** Starts the conversion job (`POST …/convert`) and polls it; calls `onDone` once it has finished. */
function useConvertJob(courseId: string, onDone: () => void) {
const [phase, setPhase] = useState<ConvertPhase>({ kind: "idle" });
const live = useRef(true);
useEffect(() => () => { live.current = false; }, []);
@@ -54,9 +54,37 @@ export function ConvertPanel({ courseId, onDone }: { courseId: string; onDone: (
setPhase({ kind: "running", progress: j.data.progress ?? 0, message: j.data.message });
}
};
return { phase, start };
}
export function ConvertPanel({ courseId, onDone }: { courseId: string; onDone: () => void }) {
const { phase, start } = useConvertJob(courseId, onDone);
return <ConvertPanelView phase={phase} onConvert={start} />;
}
/** A conversion that failed after its commit: the course is served, but its media move did not finish. */
export function IncompleteConversionView({ phase, canRetry, onRetry }: { phase: ConvertPhase; canRetry: boolean; onRetry: () => void }) {
return (
<div className="studio-note studio-note--warn" role="status">
<strong>Conversion incomplete.</strong> Learners see this course, but moving its imported media into place did not
finish. {canRetry ? "Retry to complete it." : "Ask the owner to retry it."}
{phase.kind === "running" ? (
<div className="studio-convert-progress" aria-live="polite" style={{ marginTop: 8 }}>
<ProgressBar value={phase.progress} label="Conversion progress" />
<span className="faint">{phase.message ?? "Waiting to start…"}</span>
</div>
) : null}
{phase.kind === "failed" ? <p className="studio-errors" role="alert">{phase.error}</p> : null}
{canRetry ? <div className="studio-actions" style={{ marginTop: 8 }}><Pill disabled={phase.kind === "running"} onClick={onRetry}>Retry</Pill></div> : null}
</div>
);
}
export function IncompleteConversion({ courseId, canRetry, onDone }: { courseId: string; canRetry: boolean; onDone: () => void }) {
const { phase, start } = useConvertJob(courseId, onDone);
return <IncompleteConversionView phase={phase} canRetry={canRetry} onRetry={start} />;
}
/** What the studio shows for a course it does not manage. */
export function ReadOnlyView({ courseId, info, onConverted }: { courseId: string; info: ReadOnlyInfo; onConverted: () => void }) {
if (info.imported) {
@@ -3,7 +3,7 @@ import { Link, useParams, useSearchParams } from "react-router-dom";
import { CourseCover, Pill } from "@stc/ui";
import { api, coursePath, uniqueId, type Diagnostic, type ManifestLesson } from "./api";
import { AccessMessage } from "./StudioHome";
import { ReadOnlyView } from "./ConvertPanel";
import { IncompleteConversion, ReadOnlyView } from "./ConvertPanel";
import { clone, useWorkspace, type Workspace } from "./workspace";
import { CourseSettings } from "./editors/CourseSettings";
import { LessonEditor } from "./editors/LessonEditor";
@@ -107,6 +107,8 @@ function Workbench({ ws }: { ws: Workspace }) {
<Pill variant="primary" disabled={busy || st.merging || !st.ahead || hasErrors} title={hasErrors ? "Fix the errors in the draft first" : undefined} onClick={publish}>Publish</Pill>
</div>
{ws.state.conversionIncomplete ? <IncompleteConversion courseId={ws.courseId} canRetry={!!ws.state.canRetryConversion} onDone={ws.reload} /> : null}
{notice ? (
<div className={`studio-note studio-note--${notice.kind}`} role="status">
{notice.text}
+3 -1
View File
@@ -42,7 +42,9 @@ export interface DraftStatus {
export interface Diagnostic { level: "error" | "warning"; lessonId?: string; path?: string; message: string }
export interface CourseState {
readOnly: false; course: { id: string; title: string }; status: DraftStatus; manifestText: string; manifestHash: string | null;
readOnly: false; course: { id: string; title: string }; status: DraftStatus;
/** A conversion from an import that did not finish its media move (v1.1 S); the owner may retry it. */
conversionIncomplete?: boolean; canRetryConversion?: boolean; manifestText: string; manifestHash: string | null;
manifestRaw: ManifestDoc | null; manifestErrors: string[]; diagnostics: Diagnostic[]; files: string[];
}
export interface ReadOnlyState { readOnly: true; message: string; imported?: boolean; convertible?: boolean; course: { id: string; title: string; root: string }; diagnostics: Diagnostic[] }
+3 -3
View File
@@ -3,7 +3,7 @@ import type { AppConfig, AuthUser, JobQueue, ServerContext } from "@stc/contract
import { createApp, createJobQueue, resolveConfig } from "@stc/core";
import { createCourseRepository } from "@stc/course-format";
import { connect, migrate } from "@stc/db";
import { studioFeature } from "../src/server/feature";
import { studioFeature, type StudioFeatureOptions } from "../src/server/feature";
import { studioCourseDirs } from "../src/server/paths";
import { tempDir } from "./helpers";
@@ -15,7 +15,7 @@ export const users: Record<string, AuthUser> = {
};
/** A server context like main.ts builds, with header-selected users (`x-user: ada`). */
export async function setupApp(extraCourseDirs: string[] = [], dbUrl = "sqlite://:memory:", prepare?: (dataDir: string) => void, configOverrides: Partial<AppConfig> = {}) {
export async function setupApp(extraCourseDirs: string[] = [], dbUrl = "sqlite://:memory:", prepare?: (dataDir: string) => void, configOverrides: Partial<AppConfig> = {}, featureOpts: StudioFeatureOptions = {}) {
const dataDir = tempDir();
prepare?.(dataDir);
let dropSchema = async () => {};
@@ -37,7 +37,7 @@ export async function setupApp(extraCourseDirs: string[] = [], dbUrl = "sqlite:/
config, db, courses, jobs: null as unknown as JobQueue,
auth: { userFromRequest: async (req: Request) => users[req.headers.get("x-user") ?? ""] ?? null },
} satisfies ServerContext;
const feature = studioFeature(ctx, { watch: false });
const feature = studioFeature(ctx, { watch: false, ...featureOpts });
// the real queue, as composeServer builds it from the features' jobs
const queue = createJobQueue(db, feature.jobs ?? {}, { pollMs: 20 });
ctx.jobs = queue;
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, test } from "bun:test";
import { existsSync, readFileSync } from "node:fs";
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { studioFeature } from "../src/server/feature";
import { SimulatedCrash } from "../src/server/convert";
@@ -84,9 +84,11 @@ describe.each(dbs)("conversion and importer jobs [%s]", (_name, dbUrl) => {
env = await setupApp([], dbUrl, (d) => makeImportedCourse(d));
// no importer feature in this app: its jobs stay queued
await env.ctx.jobs.enqueue("import.code-repo", { kind: "code-repo", path: "/x", options: { courseId: "imp" } });
const blocker = await env.ctx.jobs.list("import.code-repo");
const r = await env.call("owner", "POST", "/api/studio/courses/imp/convert");
expect(r.status).toBe(409);
expect(r.json.error).toContain("an import job for this course is still running");
expect(r.json.error).toContain(`import.code-repo ${blocker[0]!.id}`);
// a convert job that was queued anyway (before the import was) fails cleanly and changes nothing
const id = await env.ctx.jobs.enqueue("studio.convert-import", { courseId: "imp", actor: { id: "u-own", name: "O", email: "o@x" } });
const j = await env.waitJob(id);
@@ -122,6 +124,61 @@ describe.each(dbs)("conversion and importer jobs [%s]", (_name, dbUrl) => {
});
});
describe.each(dbs)("a conversion that fails without a restart [%s]", (_name, dbUrl) => {
const media = (as: string) => env.app.fetch(new Request(`http://x/api/media/imp/${VIDEO.split("/").map(encodeURIComponent).join("/")}`, { headers: { "x-user": as, range: "bytes=10-19" } }));
const learnerView = async (where: "import" | "studio") => {
const all = env.ctx.courses.list().filter((c) => c.id === "imp");
expect(all).toHaveLength(1);
expect(all[0]!.status).toBe("ok");
expect(all[0]!.root).toBe(join(env.dataDir, where === "import" ? "courses/imp" : "worktrees/imp/published"));
const r = await media("lea");
expect(r.status).toBe(206);
expect(new Uint8Array(await r.arrayBuffer())).toEqual(videoBytes(4096, 7).slice(10, 20));
};
const cases: [string, { forceExdev?: boolean; failAt?: string; corruptStaging?: boolean }, "import" | "studio", RegExp][] = [
["the cross-filesystem copy fails (disk full)", { forceExdev: true, failAt: "staging-copied" }, "studio", /injected failure at staging-copied/],
["the copied media fail their checksum", { forceExdev: true, corruptStaging: true }, "studio", /do not match/],
["the checkout fails before the commit point", { failAt: "worktree" }, "import", /injected failure at worktree/],
];
test.each(cases)("%s: learners keep a working course and the owner's retry completes", async (_c, hooks, after, error) => {
env = await setupApp([], dbUrl, (d) => makeImportedCourse(d), {}, { storeTestHooks: hooks });
await env.db.sql`INSERT INTO course_acl (course_id, principal_type, principal_id) VALUES ('imp', 'user', 'u-lea')`;
await learnerView("import");
const r = await env.call("owner", "POST", "/api/studio/courses/imp/convert");
expect(r.status).toBe(202);
const j = await env.waitJob(r.json.jobId);
expect(j.status).toBe("failed");
expect(j.error).toMatch(error);
// no restart: learners already get the intact import or the finished studio course
await learnerView(after);
if (after === "studio") {
const st = (await env.call("owner", "GET", "/api/studio/courses/imp")).json;
expect(st).toMatchObject({ readOnly: false, conversionIncomplete: true, canRetryConversion: true });
expect((await env.call("ada", "GET", "/api/studio/courses/imp")).json).toMatchObject({ conversionIncomplete: true, canRetryConversion: false });
} else {
expect((await env.call("owner", "GET", "/api/studio/courses/imp")).json).toMatchObject({ readOnly: true, convertible: true });
}
// the cause goes away (space freed, disk fixed); the owner retries through the same route
delete hooks.failAt;
delete hooks.corruptStaging;
expect((await env.call("ada", "POST", "/api/studio/courses/imp/convert")).status).toBe(403);
const again = await env.call("owner", "POST", "/api/studio/courses/imp/convert");
expect(again.status).toBe(202);
const j2 = await env.waitJob(again.json.jobId);
expect(j2.error).toBeNull();
expect(j2.status).toBe("done");
await learnerView("studio");
expect((await env.call("owner", "GET", "/api/studio/courses/imp")).json.conversionIncomplete).toBe(false);
expect(readdirSync(join(env.dataDir, "courses"))).toEqual([]);
expect(readdirSync(join(env.dataDir, "media"))).toEqual(["imp"]);
expect((await env.call("owner", "POST", "/api/studio/courses/imp/convert")).status).toBe(409);
});
});
describe("convert route refusals", () => {
test("only the owner; cross-site requests are refused; nothing changes", async () => {
env = await setupApp([], undefined, (d) => makeImportedCourse(d));
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test";
import { renderToString } from "react-dom/server";
import { ConvertPanelView, ReadOnlyView } from "../src/ui/ConvertPanel";
import { ConvertPanelView, IncompleteConversionView, ReadOnlyView } from "../src/ui/ConvertPanel";
const noop = () => {};
@@ -36,3 +36,16 @@ test("read-only view: imported for the owner offers the action; for others expla
expect(outside).toContain("/repos/o");
expect(outside).not.toContain("<button");
});
test("an incomplete conversion: the owner can retry, others are told who can", () => {
const owner = renderToString(<IncompleteConversionView phase={{ kind: "idle" }} canRetry onRetry={noop} />);
expect(owner).toContain("Conversion incomplete");
expect(owner).toContain(">Retry</button>");
const author = renderToString(<IncompleteConversionView phase={{ kind: "idle" }} canRetry={false} onRetry={noop} />);
expect(author).toContain("Conversion incomplete");
expect(author).not.toContain("<button");
expect(author).toContain("Ask the owner");
const failed = renderToString(<IncompleteConversionView phase={{ kind: "failed", error: "disk full" }} canRetry onRetry={noop} />);
expect(failed).toContain('role="alert"');
expect(failed).toContain("disk full");
});