fix(extraction): block stale multipart runs in v2.0.66

Resolve complete child multipart selections as a single extraction set and report missing or incomplete local parts precisely. Stop manual, hybrid, and normal automatic extraction before invoking a backend when persisted completion no longer matches disk state, while retaining the explicit startup recovery path for intentionally cleaned sources. Keep never-started parent package statuses empty without hiding real retry or lifecycle activity, and stabilize the visual pointer test against its existing readiness budget.
This commit is contained in:
Sucukdeluxe
2026-08-23 17:15:33 +02:00
parent c46f9a1d7a
commit 50d7748986
6 changed files with 445 additions and 28 deletions
+11
View File
@@ -25,6 +25,17 @@ All notable changes to Multi-Debrid Downloader are documented in this file.
- Treat invalid, missing, blocked, banned, server-restricted, and IP-restricted credentials as terminal authentication failures without provider cooldowns.
- Preserve official AllDebrid error codes alongside their messages so retry and cooldown decisions remain precise.
### Archive extraction reliability
- Resolve multiple selected child rows from the same complete multipart archive as one extraction set.
- Stop before launching an extractor when a completed archive part is missing, incomplete, or no longer matches the persisted download state.
- Report the affected archive file instead of returning a generic no-extractable-set error for stale completed selections.
- Preserve startup recovery for packages whose source archives were intentionally cleaned after a completed extraction.
### Download list clarity
- Keep the status column empty for parent packages that have never started while retaining meaningful queued, active, and terminal statuses after the first run.
## [2.0.65] - 2026-08-23
### Extract now behavior
+100 -11
View File
@@ -9102,7 +9102,8 @@ export class DownloadManager extends EventEmitter {
pkg: PackageEntry,
items: DownloadItem[],
signal: AbortSignal | undefined,
scope: "hybrid" | "full"
scope: "hybrid" | "full",
allowMissingSourceRecovery = false
): Promise<void> {
const archiveItems = items.filter((item) =>
item.status === "completed" && isArchiveLikePath(item.targetPath || item.fileName || "")
@@ -9215,6 +9216,17 @@ export class DownloadManager extends EventEmitter {
waitMs: settleMs,
pending: lastPending || "none"
});
const hasAnySourceArchive = archiveItems.some((item) => inspectPackageItemDiskState(pkg, item).exists);
if (!hasAnySourceArchive && allowMissingSourceRecovery) {
const sourceDirectoryExists = await fs.promises.stat(pkg.outputDir)
.then((stat) => stat.isDirectory(), () => false);
const hasExtractedOutput = this.getPackageOutputScope(pkg).completeFiles()
.some((filePath) => isPathInsideDir(filePath, pkg.extractDir) && fs.existsSync(filePath));
if (!sourceDirectoryExists || hasExtractedOutput) {
return;
}
}
throw new Error(`Archivdateien nicht bereit: ${lastPending || "unbekannt"}`);
}
private fixDuplicateSuffixFiles(): void {
@@ -9382,7 +9394,10 @@ export class DownloadManager extends EventEmitter {
this.emitState(true);
}
private runPackagePostProcessing(packageId: string): Promise<void> {
private runPackagePostProcessing(
packageId: string,
options: { allowMissingSourceRecovery?: boolean } = {}
): Promise<void> {
this.trackPackagePostProcessResult(packageId);
const existing = this.packagePostProcessTasks.get(packageId);
if (existing) {
@@ -9416,7 +9431,11 @@ export class DownloadManager extends EventEmitter {
this.hybridExtractRequeue.delete(packageId);
const roundStart = nowMs();
try {
await this.handlePackagePostProcessing(packageId, abortController.signal);
await this.handlePackagePostProcessing(
packageId,
abortController.signal,
options.allowMissingSourceRecovery === true
);
} catch (error) {
if (this.isExpectedPostProcessAbort(error, abortController.signal)) {
logger.info(`Post-Processing für Paket abgebrochen: ${compactErrorText(error)}`);
@@ -9564,7 +9583,7 @@ export class DownloadManager extends EventEmitter {
}
}
changed = true;
void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (recoverPostProcessing): ${compactErrorText(err)}`));
void this.runPackagePostProcessing(packageId, { allowMissingSourceRecovery: true }).catch((err) => logger.warn(`runPackagePostProcessing Fehler (recoverPostProcessing): ${compactErrorText(err)}`));
} else if (pkg.status !== "completed") {
pkg.status = "completed";
pkg.updatedAt = nowMs();
@@ -9754,6 +9773,62 @@ export class DownloadManager extends EventEmitter {
};
}
private describeManualExtractionDiskFailure(
packageId: string,
selectedItemIds?: ReadonlySet<string>
): string | null {
const pkg = this.session.packages[packageId];
if (!pkg) {
return null;
}
const packageItems = pkg.itemIds
.map((itemId) => this.session.items[itemId])
.filter((item): item is DownloadItem => Boolean(item));
const seeds = selectedItemIds
? packageItems.filter((item) => selectedItemIds.has(item.id))
: packageItems;
const pathlessItems = packageItems.filter((item) => !String(item.targetPath || "").trim());
const pathlessNameCounts = new Map<string, number>();
for (const item of pathlessItems) {
const key = path.basename(item.fileName || "").toLocaleLowerCase("en-US");
pathlessNameCounts.set(key, (pathlessNameCounts.get(key) || 0) + 1);
}
const relatedItems = new Map<string, DownloadItem>();
for (const seed of seeds) {
const filePath = String(seed.targetPath || seed.fileName || "").trim();
if (!isArchiveLikePath(filePath)) {
continue;
}
const qualified = resolveArchiveItemsFromList(path.basename(filePath), packageItems, seed.targetPath || "");
const legacy = resolveArchiveItemsFromList(path.basename(filePath), pathlessItems)
.filter((item) => (pathlessNameCounts.get(path.basename(item.fileName || "").toLocaleLowerCase("en-US")) || 0) === 1);
const resolved = [...new Map([...qualified, ...legacy].map((item) => [item.id, item])).values()];
for (const item of resolved.length > 0 ? resolved : [seed]) {
relatedItems.set(item.id, item);
}
}
for (const item of relatedItems.values()) {
if (item.status !== "completed") {
continue;
}
const state = inspectPackageItemDiskState(pkg, item);
if (state.reason === "ok") {
continue;
}
const fileName = path.basename(item.targetPath || item.fileName || item.id);
if (state.reason === "missing_file" || state.reason === "missing_path") {
return `Archivdatei fehlt: ${fileName}`;
}
if (state.reason === "too_small") {
return `Archivdatei unvollständig: ${fileName} (${humanSize(state.size)} von mindestens ${humanSize(state.minBytes)})`;
}
if (state.reason === "persisted_shortfall") {
return `Archivstatus unvollständig: ${fileName}`;
}
}
return null;
}
private isManualExtractionPlanCurrent(plan: ManualExtractionPlan): boolean {
const pkg = this.session.packages[plan.packageId];
if (!pkg || pkg.cancelled || this.getPackageResultGeneration(plan.packageId) !== plan.generation) {
@@ -9861,18 +9936,27 @@ export class DownloadManager extends EventEmitter {
}
}
const plans: ManualExtractionPlan[] = [];
const rejectionDetails: string[] = [];
for (const packageId of packageIds) {
const plan = await this.resolveManualExtractionPlan(packageId);
if (plan) plans.push(plan);
else rejected += 1;
else {
rejected += 1;
const detail = this.describeManualExtractionDiskFailure(packageId);
if (detail) rejectionDetails.push(detail);
}
}
for (const [packageId, itemIds] of itemIdsByPackage) {
const plan = await this.resolveManualExtractionPlan(packageId, itemIds);
if (plan) plans.push(plan);
else rejected += 1;
else {
rejected += 1;
const detail = this.describeManualExtractionDiskFailure(packageId, itemIds);
if (detail) rejectionDetails.push(detail);
}
}
if (plans.length === 0) {
throw new Error("Kein entpackbarer Archivsatz ausgewählt");
throw new Error(rejectionDetails[0] || "Kein entpackbarer Archivsatz ausgewählt");
}
if (rejected > 0) {
logger.info(`Jetzt entpacken: ${plans.length} Entpackvorgang/Vorgänge bereit, ${rejected} Auswahl(en) ohne vollständigen Archivsatz übersprungen`);
@@ -13994,8 +14078,8 @@ export class DownloadManager extends EventEmitter {
const partsOnDisk = collectArchiveCleanupTargets(candidate, dirFiles);
const allPartsCompleted = partsOnDisk.every((part) => completedPaths.has(pathKey(part)));
const candidateStem = path.basename(candidate).toLowerCase();
const hasUnreadyPendingPart = packageItems.some((item) => item.status !== "completed"
&& this.looksLikeArchivePart(path.basename(item.targetPath || item.fileName || "").toLowerCase(), candidateStem)
const hasUnreadyPendingPart = packageItems.some((item) =>
this.looksLikeArchivePart(path.basename(item.targetPath || item.fileName || "").toLowerCase(), candidateStem)
&& inspectPackageItemDiskState(pkg, item).reason !== "ok");
if (hasUnreadyPendingPart) {
continue;
@@ -14509,7 +14593,11 @@ export class DownloadManager extends EventEmitter {
return 0;
}
private async handlePackagePostProcessing(packageId: string, signal?: AbortSignal): Promise<void> {
private async handlePackagePostProcessing(
packageId: string,
signal?: AbortSignal,
allowMissingSourceRecovery = false
): Promise<void> {
const handleStart = nowMs();
const postProcessVersion = this.getPackagePostProcessVersion(packageId);
const pkg = this.session.packages[packageId];
@@ -14752,7 +14840,8 @@ export class DownloadManager extends EventEmitter {
pkg,
completedItems,
extractAbortController.signal,
"full"
"full",
allowMissingSourceRecovery && !manualExtraction
);
if (extractAbortController.signal.aborted) {
throw new Error(String(extractAbortController.signal.reason || "aborted:extract"));
@@ -166,6 +166,29 @@ export function buildPackagePresentation(row: DownloadPackageRow): PackagePresen
status = "Entpacken";
}
const hasLifecycleTimestamp = [
row.package.downloadStartedAt,
row.package.downloadCompletedAt,
row.package.downloadEndedAt,
row.package.postProcessQueuedAt,
row.package.postProcessStartedAt,
row.package.postProcessCompletedAt,
row.package.terminalAt
].some((value) => Number(value || 0) > 0);
const hasItemActivity = row.allItems.some((item) =>
item.status !== "queued"
|| Number(item.attempts || 0) > 0
|| Number(item.retries || 0) > 0
|| Number(item.downloadedBytes || 0) > 0
|| Number(item.progressPercent || 0) > 0
);
const neverStarted = (row.package.status === "queued" || row.package.status === "paused")
&& !hasLifecycleTimestamp
&& !hasItemActivity;
if (neverStarted) {
status = "";
}
return {
progress: { done, failed, cancelled, total, value },
status,
+230
View File
@@ -2689,6 +2689,91 @@ describe("download manager", () => {
expect([...filter].map((filePath) => path.basename(filePath).toLowerCase())).toEqual(["episode.e01.part1.rar"]);
});
it("extractNow accepts every selected child of one complete multipart archive", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-selected-multipart-"));
tempDirs.push(root);
const session = emptySession();
const packageId = "extract-selected-multipart";
const outputDir = path.join(root, "downloads", "Switched at Birth");
const extractDir = path.join(root, "extract", "Switched at Birth");
fs.mkdirSync(outputDir, { recursive: true });
const createdAt = Date.now();
const specs = [
["part-1", "tvs-sab-dd51-dl-7p-azhd-avc-405.part1.rar", 128],
["part-2", "tvs-sab-dd51-dl-7p-azhd-avc-405.part2.rar", 128]
] as const;
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "Switched.at.Birth.S04.German.DD+51.DL.720p.AmazonHD.AVC-TVS",
outputDir,
extractDir,
status: "failed",
itemIds: specs.map(([id]) => id),
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
for (const [id, fileName, totalBytes] of specs) {
const targetPath = path.join(outputDir, fileName);
fs.writeFileSync(targetPath, Buffer.alloc(128, 3));
session.items[id] = {
id,
packageId,
url: `https://example.invalid/${fileName}`,
provider: "alldebrid",
status: "completed",
retries: 0,
speedBps: 0,
downloadedBytes: totalBytes,
totalBytes,
progressPercent: 100,
fileName,
targetPath,
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Fertig",
createdAt,
updatedAt: createdAt
};
}
const manager = new DownloadManager(
{ ...defaultSettings(), token: "rd-token", outputDir, extractDir, autoExtract: false, hybridExtract: true },
session,
createStoragePaths(path.join(root, "state"))
);
const postProcess = vi.fn(async () => {});
const internal = manager as any;
internal.runPackagePostProcessing = postProcess;
internal.ensureScheduler = vi.fn(async () => {});
session.running = true;
session.paused = false;
const candidates = [...await internal.findReadyArchiveSets(session.packages[packageId])];
expect(candidates.map((filePath) => path.basename(filePath))).toEqual([
"tvs-sab-dd51-dl-7p-azhd-avc-405.part1.rar"
]);
const resolved = resolveSelectedArchiveSetsFromCandidates(
candidates,
specs.map(([id]) => session.items[id]),
new Set(["part-1", "part-2"])
);
expect([...resolved.itemIds].sort()).toEqual(["part-1", "part-2"]);
await manager.extractNow({ packageIds: [], itemIds: ["part-1", "part-2"] });
expect(postProcess).toHaveBeenCalledWith(packageId);
expect(internal.ensureScheduler).not.toHaveBeenCalled();
expect(session.items["part-1"].fullStatus).toBe("Entpacken - Ausstehend");
expect(session.items["part-2"].fullStatus).toBe("Entpacken - Ausstehend");
const filter = internal.manualExtractArchiveFilters.get(packageId) as Set<string>;
expect([...filter].map((filePath) => path.basename(filePath).toLowerCase())).toEqual([
"tvs-sab-dd51-dl-7p-azhd-avc-405.part1.rar"
]);
});
it("extractNow item selection runs only the selected archive through real post-processing", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-selected-real-"));
tempDirs.push(root);
@@ -17161,6 +17246,151 @@ describe("post-processing lifecycle audit", () => {
return { manager, session };
}
it("extractNow reports the missing file behind a stale completed multipart selection", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-stale-multipart-"));
tempDirs.push(root);
const { manager, session } = createCompletedFileManager(root, [
{ packageId: "stale-multipart", itemId: "stale-part-1", fileName: "show.part1.rar" },
{ packageId: "stale-multipart", itemId: "stale-part-2", fileName: "show.part2.rar" }
]);
fs.rmSync(session.items["stale-part-2"].targetPath);
await expect(manager.extractNow({
packageIds: [],
itemIds: ["stale-part-1", "stale-part-2"]
})).rejects.toThrow("Archivdatei fehlt: show.part2.rar");
});
it("extractNow reports a pathless missing sibling behind one selected multipart child", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-stale-pathless-multipart-"));
tempDirs.push(root);
const { manager, session } = createCompletedFileManager(root, [
{ packageId: "stale-pathless", itemId: "pathless-part-1", fileName: "show.part1.rar" },
{ packageId: "stale-pathless", itemId: "pathless-part-2", fileName: "show.part2.rar" }
]);
fs.rmSync(session.items["pathless-part-2"].targetPath);
session.items["pathless-part-2"].targetPath = "";
await expect(manager.extractNow({
packageIds: [],
itemIds: ["pathless-part-1"]
})).rejects.toThrow("Archivdatei fehlt: show.part2.rar");
});
it("stops before extraction when a completed archive file never stabilizes on disk", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-settle-missing-"));
tempDirs.push(root);
const { manager, session } = createCompletedFileManager(root, [
{ packageId: "settle-missing", itemId: "settle-part-1", fileName: "show.part1.rar" },
{ packageId: "settle-missing", itemId: "settle-part-2", fileName: "show.part2.rar" }
]);
fs.rmSync(session.items["settle-part-2"].targetPath);
await expect((manager as any).waitForCompletedArchiveFilesToSettle(
session.packages["settle-missing"],
[session.items["settle-part-1"], session.items["settle-part-2"]],
undefined,
"full"
)).rejects.toThrow("Archivdateien nicht bereit: show.part2.rar:missing_file");
}, 8_000);
it("does not invoke the extractor after a multipart source disappears during post-processing", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-postprocess-missing-"));
tempDirs.push(root);
const { manager, session } = createCompletedFileManager(root, [
{ packageId: "postprocess-missing", itemId: "postprocess-part-1", fileName: "show.part1.rar" },
{ packageId: "postprocess-missing", itemId: "postprocess-part-2", fileName: "show.part2.rar" }
]);
fs.rmSync(session.items["postprocess-part-2"].targetPath);
const internal = manager as any;
const runCoordinatedExtraction = vi.fn(async () => ({ extracted: 1, failed: 0, lastError: "" }));
internal.runCoordinatedExtraction = runCoordinatedExtraction;
internal.manualExtractPackages.add("postprocess-missing");
await internal.handlePackagePostProcessing("postprocess-missing");
expect(runCoordinatedExtraction).not.toHaveBeenCalled();
expect(session.packages["postprocess-missing"].status).toBe("failed");
expect(session.items["postprocess-part-1"].fullStatus).toBe("Entpack-Fehler: Teilarchiv fehlt oder ist nicht lesbar");
expect(session.items["postprocess-part-2"].fullStatus).toBe("Entpack-Fehler: Teilarchiv fehlt oder ist nicht lesbar");
}, 8_000);
it("does not treat a fully vanished manual multipart source as a successful extraction", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-postprocess-all-missing-"));
tempDirs.push(root);
const { manager, session } = createCompletedFileManager(root, [
{ packageId: "postprocess-all-missing", itemId: "all-missing-part-1", fileName: "show.part1.rar" },
{ packageId: "postprocess-all-missing", itemId: "all-missing-part-2", fileName: "show.part2.rar" }
]);
fs.rmSync(session.items["all-missing-part-1"].targetPath);
fs.rmSync(session.items["all-missing-part-2"].targetPath);
const internal = manager as any;
const runCoordinatedExtraction = vi.fn(async () => ({ extracted: 1, failed: 0, lastError: "" }));
internal.runCoordinatedExtraction = runCoordinatedExtraction;
internal.manualExtractPackages.add("postprocess-all-missing");
await internal.handlePackagePostProcessing("postprocess-all-missing");
expect(runCoordinatedExtraction).not.toHaveBeenCalled();
expect(session.packages["postprocess-all-missing"].status).toBe("failed");
expect(session.items["all-missing-part-1"].fullStatus).toBe("Entpack-Fehler: Teilarchiv fehlt oder ist nicht lesbar");
expect(session.items["all-missing-part-2"].fullStatus).toBe("Entpack-Fehler: Teilarchiv fehlt oder ist nicht lesbar");
}, 8_000);
it("does not treat a fully vanished automatic runtime source as startup recovery", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-runtime-all-missing-"));
tempDirs.push(root);
const { manager, session } = createCompletedFileManager(root, [
{ packageId: "runtime-all-missing", itemId: "runtime-missing-part-1", fileName: "show.part1.rar" },
{ packageId: "runtime-all-missing", itemId: "runtime-missing-part-2", fileName: "show.part2.rar" }
]);
fs.rmSync(session.items["runtime-missing-part-1"].targetPath);
fs.rmSync(session.items["runtime-missing-part-2"].targetPath);
fs.rmSync(session.packages["runtime-all-missing"].outputDir, { recursive: true });
const internal = manager as any;
const runCoordinatedExtraction = vi.fn(async () => ({ extracted: 1, failed: 0, lastError: "" }));
internal.runCoordinatedExtraction = runCoordinatedExtraction;
internal.settings.autoExtract = true;
await internal.handlePackagePostProcessing("runtime-all-missing");
expect(runCoordinatedExtraction).not.toHaveBeenCalled();
expect(session.packages["runtime-all-missing"].status).toBe("failed");
expect(session.items["runtime-missing-part-1"].fullStatus).toBe("Entpack-Fehler: Teilarchiv fehlt oder ist nicht lesbar");
expect(session.items["runtime-missing-part-2"].fullStatus).toBe("Entpack-Fehler: Teilarchiv fehlt oder ist nicht lesbar");
}, 8_000);
it("allows startup recovery after every source archive was intentionally cleaned", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-settle-cleaned-"));
tempDirs.push(root);
const { manager, session } = createCompletedFileManager(root, [
{ packageId: "settle-cleaned", itemId: "cleaned-part-1", fileName: "show.part1.rar" },
{ packageId: "settle-cleaned", itemId: "cleaned-part-2", fileName: "show.part2.rar" }
]);
fs.rmSync(session.items["cleaned-part-1"].targetPath);
fs.rmSync(session.items["cleaned-part-2"].targetPath);
fs.writeFileSync(path.join(session.packages["settle-cleaned"].outputDir, "release.nfo"), "release");
const extractedPath = path.join(session.packages["settle-cleaned"].extractDir, "episode.mkv");
fs.mkdirSync(path.dirname(extractedPath), { recursive: true });
fs.writeFileSync(extractedPath, "video");
(manager as any).getPackageOutputScope(session.packages["settle-cleaned"]).add({
version: 1,
archivePath: session.items["cleaned-part-1"].targetPath,
entryPath: "episode.mkv",
outputPath: extractedPath,
state: "complete",
disposition: "written"
});
await expect((manager as any).waitForCompletedArchiveFilesToSettle(
session.packages["settle-cleaned"],
[session.items["cleaned-part-1"], session.items["cleaned-part-2"]],
undefined,
"full",
true
)).resolves.toBeUndefined();
}, 8_000);
it("requeues an interrupted integrity check and clears transient package progress on restart", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-restart-integrity-"));
tempDirs.push(root);
+64
View File
@@ -46,6 +46,70 @@ function row(items: DownloadItem[], overrides: Partial<PackageEntry> = {}): Down
}
describe("download package presentation", () => {
it("keeps the status empty for an over-package that has never started", () => {
const presentation = buildPackagePresentation(row([
item("queued", "Wartet", {
status: "queued",
attempts: 0,
retries: 0,
downloadedBytes: 0,
progressPercent: 0
})
], {
status: "queued",
downloadStartedAt: 0,
downloadCompletedAt: 0,
downloadEndedAt: 0,
postProcessQueuedAt: 0,
postProcessStartedAt: 0,
postProcessCompletedAt: 0,
terminalAt: 0
}));
expect(presentation.status).toBe("");
});
it("keeps the status empty for a disabled never-started over-package", () => {
const presentation = buildPackagePresentation(row([
item("queued", "Paket gestoppt", {
status: "queued",
attempts: 0,
retries: 0,
downloadedBytes: 0,
progressPercent: 0
})
], { status: "paused", enabled: false }));
expect(presentation.status).toBe("");
});
it("keeps retry activity visible when an older queued package lacks lifecycle timestamps", () => {
const presentation = buildPackagePresentation(row([
item("queued", "Link-Umwandlung erneut, Versuch 1/...", {
status: "queued",
attempts: 1,
retries: 1,
downloadedBytes: 0,
progressPercent: 0
})
], { status: "queued", downloadStartedAt: 0 }));
expect(presentation.status).toBe("Link-Umwandlung erneut");
});
it("keeps a queued status visible after the over-package has started once", () => {
const presentation = buildPackagePresentation(row([
item("queued", "Link-Umwandlung erneut, Versuch 1/...", {
status: "queued",
retries: 1,
downloadedBytes: 0,
progressPercent: 0
})
], { status: "queued", downloadStartedAt: 1 }));
expect(presentation.status).toBe("Link-Umwandlung erneut");
});
it("reserves 90 percent for completed downloads and 10 percent for extraction", () => {
expect(buildPackagePresentation(row([
item("a", "Entpack-Fehler: Passwort"),
@@ -301,7 +301,7 @@ describe("download disclosure in the headless visual harness", () => {
expect(ascending.ariaSort).toBe("ascending");
expect(descending.names.length).toBeGreaterThan(1);
expect(ascending.names).toEqual([...descending.names].reverse());
});
}, 25_000);
async function measureDisclosure(action: "einklappen" | "ausklappen"): Promise<DisclosureSample[]> {
if (!client) throw new Error("Chrome DevTools client is missing");