Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit e89a8cad6bbc1faddd7b942add0dedc92afa658d
parent 9081189448cd7045f6eeddc5839f901e280bd799
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 31 Jul 2026 16:27:02 -0400

Give the widget a "Needs cleaning" section that can actually reclaim the disk

The widget could act on one backlog and only report the other. ActionableStrip
lists channels with videos to download/transcribe and, under controls=1, gives
each card the same per-channel InlineActionButtons as /actionable. CleanableStrip
showed one global number — "4.2 GB reclaimable" — with nothing to click; to act
on it you left the widget for /cleanup or /actionable.

New opt-in section (cleanlist=1, default off) lists the channels holding that
audio, each with a Clean audio button when controls=1. A pinned interactive
widget now clears disk pressure the way it already clears a download backlog.

This is also the first surface where a channel that is fully downloaded AND
transcribed but still holding reclaimable audio is reachable at all: the
needs-work list comes from /api/widget/actionable, which has a
download/transcribe precondition, so such a channel never appeared there. The
new endpoint has no precondition.

Deliberately a SEPARATE section rather than a Clean button on the needs-work
cards. Widening the /api/widget/actionable filter was the first design, and it
leaks: app/components/dashboard/NeedsWorkPanel.tsx is a near-duplicate of
ActionableStrip consuming the same payload, and it renders only down/pencil/
diamond badges and Download/Transcribe buttons — so any channel added to that
payload without a matching badge renders as an EMPTY CARD on the dashboard. The
shared route and the dashboard are untouched here.

No new job kind, server action, or button variant: cleanTranscribedAudio is
already an InlineActionButton variant ("Clean audio", window.confirm-guarded,
calls cleanAudioAction). Its confirm is the destructive gate — no second flag.

cleanableTotalBytes is now a sum over the new cleanableChannels(), so the
excludeFromCleanup rule and the lean snapshot-only read (no failed-list reads,
which is the whole reason this doesn't call loadCleanupSummary) live in one
place, and the strip and the section are computed from the same array in the
same request — they cannot disagree. Dropping rows with no bytes and no videos
leaves the total unchanged. Rows sort by bytes descending.

/api/widget/cleanable carries the per-channel rows off the snapshot read it was
already doing: no extra I/O, and one poll serves both sections (gate is now
cleanable || cleanChannels). Also deletes the local `type CleanablePayload =
{ bytes: number }` MonitorWidget re-declared instead of importing the route's
exported one, matching how the same file already imports WidgetActionablePayload.

Row aria-label is `needs cleaning <slug>` — deliberately not a string containing
"clean audio", so it cannot collide with the button's derived
`clean audio <slug>` locator. keepWhenIdle needs no change: it already includes
config.controls and the button only exists with controls=1, so an actionable
Clean section can never be hidden by hideIdle.

Scope is the primary sweep only. cleanExtraFormats, removeWrongFormat and the
failed-list clears stay on /cleanup and /actionable — the sweeps overlap and are
not additive, so restricting to "clean audio" is what lets this section's numbers
agree exactly with the Cleanable-data strip above it.

Verified in default dev mode (E2E_MODE=start serves the last build and would
miss all of this): widget 14/14 including the two new tests, dashboard 5/5 for
the untouched NeedsWorkPanel, cleanup-actionable + cleanup-page 5/5 for the
cleanableTotalBytes refactor — cleanup-page covers excludeFromCleanup end to
end. tsc --noEmit clean in editor/.

Two things not done. `pnpm lint` in editor/ cannot pass: there is no eslint
config in editor/ or at the repo root (only export/ and homepage/ have one), so
eslint 9 exits 2 regardless of the code — pre-existing, so tsc is the static
gate here. And the section was not eyeballed against the real corpus: booting an
editor there runs instrumentation.ts, which arms the sync heartbeat, starts the
auto-download/transcribe runners and RESUMES THE GPU-WEEKS DIGEST SWEEP, which
is not an acceptable side effect of a display check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Diffstat:
Meditor/CHANGELOG.md | 1+
Meditor/app/api/widget/cleanable/route.ts | 22+++++++++++++++-------
Meditor/app/cleanup/lib/loadCleanup.ts | 44+++++++++++++++++++++++++++++++++++---------
Meditor/app/widget/components/MonitorWidget.tsx | 91++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Meditor/app/widget/components/WidgetConfigForm.tsx | 5+++++
Meditor/app/widget/lib/config.ts | 8++++++++
Meditor/e2e/widget.spec.ts | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
7 files changed, 234 insertions(+), 21 deletions(-)

diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **The monitor widget can now reclaim disk, not just report it.** The widget's cleanable-data strip showed a single global number ("4.2 GB reclaimable") with nothing to act on — reclaiming it meant leaving the widget for `/cleanup` or `/actionable`. A new opt-in **"Needs cleaning"** section (URL flag `cleanlist=1`, plus a **Channels needing cleanup** checkbox in the builder and the in-widget gear) lists the channels actually holding that audio, each with its reclaim estimate (`⌫ 2.5 MB`, the video count in the tooltip), capped at 6 channels with a `+N more` line like the needs-work list. With `controls=1` each row gains the same per-channel **Clean audio** button as the `/actionable` page — the existing `window.confirm` still guards the delete — so a pinned interactive widget clears disk pressure the way it already clears a download backlog. This is also the first surface on which a channel that is *fully downloaded and transcribed* but still holding reclaimable audio is actionable: the needs-work list is fed by a backlog route with a download/transcribe precondition, so such a channel never appeared there. It costs no extra polling — the per-channel rows come from the same `/api/widget/cleanable` snapshot read that already backed the total, and the total is now a sum over those rows so the section and the strip above it can't disagree. The dashboard's needs-work panel and its shared route are untouched. See `editor/app/cleanup/lib/loadCleanup.ts` (`cleanableChannels`), `editor/app/api/widget/cleanable/route.ts`, `editor/app/widget/{lib/config.ts,components/{MonitorWidget,WidgetConfigForm}.tsx}`, and `editor/e2e/widget.spec.ts`. - **A corpus-wide digest backfill can now be started, left alone, and watched.** The digest layer could generate, but only one channel at a time from that channel's own page — a full-archive pass meant 63 manual launches, and a server restart silently ended it with nothing to say so. There is now a **Start Digest Sweep** control on the dashboard that walks every channel in turn, **heaviest first by remaining audio-hours** (cost is audio, not videos: one VOD channel outweighs every duplicate mirror in the archive combined), and it **survives a restart** — the sweep is re-armed at boot the way the auto-download and auto-transcribe runners already were. It stores no work-list, so a resumed sweep re-does nothing: what still needs generating is re-derived from disk every time, which also means a transcript that finishes mid-sweep, or a duplicate cluster you confirm, is simply picked up on the next pass. A separate **Pause Digests** control holds a running sweep at zero without ending it (the pause flag has existed since the digest layer shipped and nothing could set it). **The digest lane now yields the GPU to transcription**: the two were deliberately on separate queues so they wouldn't serialise, whose unintended consequence was that the model and whisper competed for the same card — measured at 90 seconds per audio-hour against the 27 an idle machine managed. While transcription is working the digest lane steps aside and resumes when the card is free; it can be turned off in Settings. Coverage is now visible — a digest instrument on the dashboard with the corpus percentage (to two decimals, because rounding 0.13% up to 1% flatters an 80-day job), a **No digest** column on the channels table, and a per-channel count on the needs-work rows. Progress bars also **work during a regeneration** for the first time: they re-counted digest files from disk, and a regenerated digest is rewritten in place, so a job that was working sat at 0% for its whole run. Time-remaining estimates for digest work are now computed in **seconds per audio-hour** rather than by averaging videos, which for this archive is wrong by more than an order of magnitude between a VOD channel and a shorts channel. New `common/bin/digest-plan.ts` prices the whole backfill in audio-hours before you commit hardware to it. - **Duplicate clusters awaiting review can finally be confirmed or rejected.** A cluster whose evidence is only a shared title and runtime shares no AI digests and reaches no built site until a human confirms it — and the code to record that confirmation existed, complete, with **no way to reach it from anywhere in the app**. Roughly 166 clusters were therefore stuck permanently. `/actionable` duplicate cards now carry **Confirm** / **Not a duplicate** / **Undo**, badges that show what has already been decided, and confirming immediately shares the canonical copy's digest to any aligned member rather than making you wait for its next sweep. - **Digest passes that produced nothing now leave evidence.** When every chapter a model proposed was rejected by a guard — or when it proposed nothing at all — the generator deliberately wrote no digest, so the video would be retried. The side effect was that the **worst** outputs recorded their warnings nowhere but a job log that rotates after 30 days, exactly the videos a review pass most needs to find. Those failures are now recorded on the artifact, and they distinguish *"the model proposed nothing"* from *"the model proposed chapters and every one was rejected"* — which look identical from outside and need opposite fixes. A new **digest warnings** section on `/actionable` and a matching **Digest warnings** filter on the channel video list surface them. diff --git a/editor/app/api/widget/cleanable/route.ts b/editor/app/api/widget/cleanable/route.ts @@ -1,15 +1,23 @@ import { NextResponse } from "next/server"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; -import { cleanableTotalBytes } from "../../../cleanup/lib/loadCleanup"; +import { cleanableChannels } from "../../../cleanup/lib/loadCleanup"; export const dynamic = "force-dynamic"; -export type CleanablePayload = { bytes: number }; +export type CleanableChannel = { slug: string; count: number; bytes: number }; -// Backs the monitor widget's optional cleanable-data indicator. Sums the -// primary "clean audio" reclaim across channels not excluded from the cleanup -// total (honors the per-channel excludeFromCleanup flag). +export type CleanablePayload = { + bytes: number; + channels: CleanableChannel[]; +}; + +// Backs the monitor widget's cleanable-data indicator AND its "Needs cleaning" +// channel list — the per-channel rows come from the same snapshot read, so +// serving both costs no extra I/O and no second poll. Covers the primary "clean +// audio" reclaim across channels not excluded from the cleanup total (honors the +// per-channel excludeFromCleanup flag); `bytes` is their sum. export async function GET() { - const bytes = await cleanableTotalBytes(getPaths()); - return NextResponse.json({ bytes } satisfies CleanablePayload); + const channels = await cleanableChannels(getPaths()); + const bytes = channels.reduce((sum, c) => sum + c.bytes, 0); + return NextResponse.json({ bytes, channels } satisfies CleanablePayload); } diff --git a/editor/app/cleanup/lib/loadCleanup.ts b/editor/app/cleanup/lib/loadCleanup.ts @@ -118,18 +118,44 @@ export async function loadCleanupSummary( }; } -// Lean total for the sidebar badge and the widget API — sums only the primary -// "clean audio" reclaim over channels NOT excluded from the cleanup total. Reads -// the small snapshot JSONs (no failed-list reads), so it's cheap on the -// AutoRefresh cadence / per widget poll. -export async function cleanableTotalBytes(paths: Paths): Promise<number> { +// One channel's reclaimable-audio row for the sidebar badge and the widget API. +export type CleanableChannelRow = { + slug: string; + // Videos in the transcribed-with-audio bucket (the sweep's targets). + count: number; + // Reclaim estimate for the primary "clean audio" sweep, net of protection. + bytes: number; +}; + +// Lean per-channel breakdown for the sidebar badge and the widget API — the +// primary "clean audio" reclaim over channels NOT excluded from the cleanup +// total. Reads only the small snapshot JSONs (unlike loadCleanupSummary, which +// also reads both failed-lists per channel), so it's cheap on the AutoRefresh +// cadence / per widget poll. Sorted by reclaim, descending. +export async function cleanableChannels( + paths: Paths, +): Promise<CleanableChannelRow[]> { const channels = await listChannels(paths); - const perChannel = await Promise.all( + const rows = await Promise.all( channels.map(async (channel) => { - if (channel.config.excludeFromCleanup) return 0; + if (channel.config.excludeFromCleanup) return null; const snap = await readChannelSnapshot(paths, channel.slug); - return transcribedBytesOf(snap); + const bytes = transcribedBytesOf(snap); + const count = snap?.buckets.transcribedWithAudio?.length ?? 0; + // Nothing to reclaim and nothing to sweep — drop the row. Zero-byte rows + // contribute nothing to the total, so dropping them keeps it unchanged. + if (bytes <= 0 && count <= 0) return null; + return { slug: channel.slug, count, bytes }; }), ); - return perChannel.reduce((a, b) => a + b, 0); + return rows + .filter((r): r is CleanableChannelRow => r !== null) + .sort((a, b) => b.bytes - a.bytes); +} + +// The headline reclaim number — a sum over the same rows, so the +// excludeFromCleanup rule and the lean read live in exactly one place. +export async function cleanableTotalBytes(paths: Paths): Promise<number> { + const rows = await cleanableChannels(paths); + return rows.reduce((sum, r) => sum + r.bytes, 0); } diff --git a/editor/app/widget/components/MonitorWidget.tsx b/editor/app/widget/components/MonitorWidget.tsx @@ -17,6 +17,10 @@ import { jobKindLabel } from "../../jobs/jobKindLabels"; import type { WorkersPayload, WorkerView } from "../../workers/components/WorkersView"; import { InlineActionButton } from "../../actionable/components/InlineActionButton"; import type { WidgetActionablePayload } from "../../api/widget/actionable/route"; +import type { + CleanablePayload, + CleanableChannel, +} from "../../api/widget/cleanable/route"; import type { WidgetSyncPayload } from "../../api/widget/sync/route"; import { buildWidgetQuery, type WidgetConfig } from "../lib/config"; import { WidgetConfigForm } from "./WidgetConfigForm"; @@ -62,9 +66,11 @@ export function MonitorWidget({ pollMs, initialWorkers, ); + // One poll serves both cleanable sections: the total-bytes strip and the + // per-channel "Needs cleaning" list come from the same payload. const { data: cleanablePayload } = usePolledPayload<CleanablePayload>( "/api/widget/cleanable", - config.cleanable, + config.cleanable || config.cleanChannels, pollMs, null, ); @@ -107,6 +113,7 @@ export function MonitorWidget({ const disk = jobsPayload?.disk; const actionableChannels = actionablePayload?.channels ?? []; + const cleanableChannels = cleanablePayload?.channels ?? []; const nothingActive = (!config.jobs || jobs.length === 0) && @@ -170,6 +177,13 @@ export function MonitorWidget({ {config.cleanable && cleanablePayload && ( <CleanableStrip bytes={cleanablePayload.bytes} /> )} + {config.cleanChannels && ( + <CleanableChannelsStrip + channels={cleanableChannels} + showTitle={config.showTitles} + interactive={config.controls} + /> + )} {config.workers && ( <WorkersStrip workers={workers} @@ -388,8 +402,6 @@ function SchedulerStrip({ ); } -type CleanablePayload = { bytes: number }; - function CleanableStrip({ bytes }: { bytes: number }) { return ( <section @@ -500,6 +512,79 @@ function ActionableStrip({ ); } +// Compact "needs cleaning" list: the channels holding the reclaimable audio the +// Cleanable-data strip totals (same sweep, so the rows sum to that number). +// Read-only bytes by default; when the widget is interactive (controls=1) each +// row gains the same per-channel Clean audio button as the /actionable page — +// its own window.confirm covers the destructive step. +const CLEANABLE_LIMIT = 6; + +function CleanableChannelsStrip({ + channels, + showTitle, + interactive, +}: { + channels: CleanableChannel[]; + showTitle: boolean; + interactive: boolean; +}) { + const shown = channels.slice(0, CLEANABLE_LIMIT); + const overflow = channels.length - shown.length; + return ( + <section aria-label="Needs cleaning" className="flex flex-col gap-1.5"> + {showTitle && ( + <div className="flex items-baseline gap-2"> + <h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> + Needs cleaning + </h2> + <span className="text-xs text-muted-foreground"> + {channels.length} {channels.length === 1 ? "channel" : "channels"} + </span> + </div> + )} + {channels.length === 0 ? ( + <p className="text-xs text-muted-foreground">Nothing to reclaim.</p> + ) : ( + <ul className="flex flex-col gap-1.5"> + {shown.map((c) => ( + <li + key={c.slug} + // Deliberately not a string containing "clean audio", so this row + // can't collide with the button's derived `clean audio <slug>`. + aria-label={`needs cleaning ${c.slug}`} + className="flex flex-col gap-1 rounded border border-border bg-card px-2 py-1" + > + <div className="flex items-center justify-between gap-2"> + <span className="font-mono text-xs truncate" title={c.slug}> + {c.slug} + </span> + <span + title={`${c.count} ${c.count === 1 ? "video" : "videos"} with reclaimable audio`} + className="shrink-0 rounded px-1.5 py-0.5 text-[10px] tabular-nums bg-warning-soft text-warning" + > + ⌫ {formatBytes(c.bytes)} + </span> + </div> + {interactive && ( + <div className="flex flex-wrap items-center gap-1.5"> + <InlineActionButton + variant={{ kind: "cleanTranscribedAudio", slug: c.slug }} + /> + </div> + )} + </li> + ))} + {overflow > 0 && ( + <li className="text-xs text-muted-foreground"> + +{overflow} more {overflow === 1 ? "channel" : "channels"} + </li> + )} + </ul> + )} + </section> + ); +} + function ActiveJobsStrip({ jobs, showTitle, diff --git a/editor/app/widget/components/WidgetConfigForm.tsx b/editor/app/widget/components/WidgetConfigForm.tsx @@ -33,6 +33,11 @@ export function WidgetConfigForm({ checked={config.actionable} onChange={(v) => onChange({ actionable: v })} /> + <Check + label="Channels needing cleanup" + checked={config.cleanChannels} + onChange={(v) => onChange({ cleanChannels: v })} + /> </fieldset> <fieldset className="flex flex-col gap-2"> diff --git a/editor/app/widget/lib/config.ts b/editor/app/widget/lib/config.ts @@ -39,6 +39,10 @@ export type WidgetConfig = { // Show the "Needs work" strip — a compact list of channels with videos to // download or transcribe. Off by default (mirrors the opt-in cleanable strip). actionable: boolean; + // Show the "Needs cleaning" strip — the channels holding the reclaimable + // audio the cleanable indicator totals, each with a Clean audio button when + // `controls` is on. Off by default (mirrors the opt-in cleanable strip). + cleanChannels: boolean; // Show the in-widget settings gear that opens the config overlay in place. On // by default; turn off to bake a locked-down shared/embedded link. settings: boolean; @@ -75,6 +79,7 @@ export const WIDGET_DEFAULTS: WidgetConfig = { workerLabels: true, controls: false, actionable: false, + cleanChannels: false, settings: true, sync: false, lastSync: false, @@ -125,6 +130,7 @@ export function parseWidgetConfig(params: RawParams): WidgetConfig { workerLabels: parseBool(params.wnames, WIDGET_DEFAULTS.workerLabels), controls: parseBool(params.controls, WIDGET_DEFAULTS.controls), actionable: parseBool(params.act, WIDGET_DEFAULTS.actionable), + cleanChannels: parseBool(params.cleanlist, WIDGET_DEFAULTS.cleanChannels), settings: parseBool(params.gear, WIDGET_DEFAULTS.settings), sync: parseBool(params.sync, WIDGET_DEFAULTS.sync), lastSync: parseBool(params.lastsync, WIDGET_DEFAULTS.lastSync), @@ -164,6 +170,8 @@ export function buildWidgetQuery(config: WidgetConfig): string { sp.set("controls", config.controls ? "1" : "0"); if (config.actionable !== WIDGET_DEFAULTS.actionable) sp.set("act", config.actionable ? "1" : "0"); + if (config.cleanChannels !== WIDGET_DEFAULTS.cleanChannels) + sp.set("cleanlist", config.cleanChannels ? "1" : "0"); if (config.settings !== WIDGET_DEFAULTS.settings) sp.set("gear", config.settings ? "1" : "0"); if (config.sync !== WIDGET_DEFAULTS.sync) diff --git a/editor/e2e/widget.spec.ts b/editor/e2e/widget.spec.ts @@ -3,11 +3,53 @@ // command palette) is stripped on exactly /widget. It is read-only by default // (aside from an in-place settings gear), but `controls=1` opts into interactive // Pause/Resume + Drain + Retry buttons, `act=1` shows a "needs work" channel -// list, and the two combine into per-channel Download/Transcribe actions. The +// list, `cleanlist=1` shows a "needs cleaning" one, and each combines with +// `controls=1` into per-channel Download/Transcribe / Clean audio actions. The // builder lives inside the normal shell and composes links. +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; import { test, expect } from "@playwright/test"; -import { resetData, writeSettings } from "./helpers"; +import { resetData, resolvePath, writeSettings } from "./helpers"; +import { baseUrl } from "./baseUrl"; + +const CLEAN_SLUG = "test-transcribe"; + +// Seed the transcribed-with-audio cleanup bucket for CLEAN_SLUG — the same +// pattern cleanup-actionable.spec.ts uses. 2,621,440 bytes → "2.5 MB". +async function seedCleanableChannel(): Promise<void> { + const full = resolvePath( + `test-transcripts/channels/${CLEAN_SLUG}/snapshot.json`, + ); + await mkdir(dirname(full), { recursive: true }); + await writeFile( + full, + JSON.stringify( + { + generatedAt: "2026-05-27T12:00:00.000Z", + totals: { videos: 3, transcribed: 2, downloaded: 3 }, + buckets: { + noTranscript: [], + downloadedNoTranscript: [], + untranscoded: [], + multipleAudioFormats: [], + transcribedWithAudio: ["vidA", "vidB"], + untranscribable: [], + noMetadata: [], + failedListed: [], + missingFromArchive: [], + duplicateDirs: [], + partialDownloads: [], + }, + undownloadedIds: [], + cleanupBytes: { transcribedWithAudio: 2_621_440 }, + }, + null, + 2, + ) + "\n", + ); + await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {}); +} const TWO_WORKERS = { workers: [ @@ -178,6 +220,40 @@ test("act=1&controls=1 gives the list per-channel Download actions", async ({ ).toBeVisible({ timeout: 10_000 }); }); +test("cleanlist=1 shows a needs-cleaning list of channels", async ({ page }) => { + await resetData("one-transcribe-channel-with-audio"); + await seedCleanableChannel(); + + await page.goto("/widget?cleanlist=1"); + const list = page.getByRole("region", { name: "Needs cleaning" }); + // The section arrives on the first poll, not with the SSR'd markup. + await expect(list).toBeVisible({ timeout: 10_000 }); + await expect(list).toContainText(CLEAN_SLUG); + // 2,621,440 bytes of reclaimable audio → 2.5 MB. + await expect(list).toContainText("2.5 MB"); +}); + +test("cleanlist=1&controls=1 gives the list per-channel Clean audio actions", async ({ + page, +}) => { + await resetData("one-transcribe-channel-with-audio"); + await seedCleanableChannel(); + + await page.goto("/widget?cleanlist=1&controls=1"); + const clean = page.getByRole("button", { + name: `clean audio ${CLEAN_SLUG}`, + }); + await expect(clean).toBeVisible({ timeout: 10_000 }); + + // Reuses the /actionable inline action, confirm dialog included: accepting + // queues a clean-audio-transcribed job. + page.on("dialog", (d) => d.accept()); + await clean.click(); + await expect(page.getByLabel(`clean audio ${CLEAN_SLUG} job`)).toBeVisible({ + timeout: 10_000, + }); +}); + test("the builder keeps the shell and composes a link", async ({ page }) => { await page.goto("/widget/builder"); @@ -238,6 +314,10 @@ test("display toggles serialize into the link and preview", async ({ page }) => await expect(url).toHaveValue(/[?&]act=1(&|$)/); await expect(preview).toHaveAttribute("src", /[?&]act=1(&|$)/); + await page.getByRole("checkbox", { name: "Channels needing cleanup" }).check(); + await expect(url).toHaveValue(/[?&]cleanlist=1(&|$)/); + await expect(preview).toHaveAttribute("src", /[?&]cleanlist=1(&|$)/); + await page.getByRole("checkbox", { name: "Show settings gear" }).uncheck(); await expect(url).toHaveValue(/[?&]gear=0(&|$)/);