cloud_gallery: fix build, 100% coverage, aggressive lint; whitelist dufs_client

- dufs-client.test.ts: type fetch mocks as vi.fn<typeof fetch>() (fixes the
  TS2493/TS2352 tuple/undefined build errors that broke `pnpm build`)
- dufs-client.ts: explicit method arg drops the unreachable `?? "GET"` branch
- gallery/grid: -1 viewer-index sentinel + pass media entry, removing three
  unreachable defensive guards; onOpenMedia now takes the entry
- use-hash-path: extract serverPath() so the SSR snapshot is covered
- eslint: strict+stylistic type-checked, react-hooks, reportUnusedDisable
  Directives; drop eslint-plugin-react (incompatible with ESLint 10)
- add grid.test.tsx + use-listing.test.ts; coverage thresholds 100/100/100/100
  (62 tests: statements/branches/functions/lines all 100%)
- phone_focus_mode/config.sh: whitelist com.kuhy.dufs_client

Deployed live via setup_cloud_gallery.sh (render-spa); fixed ffmpeg by
renaming a stale /usr/local/bin shadow linking the removed libvpx.so.11;
regenerated thumbnails (10 made, 0 failed). APK installed + launch-verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0126QvdMrv2WNeUVShFiLTc3
This commit is contained in:
Krzysztof Rudnicki 2026-07-12 17:29:39 +02:00
parent 76d4e8e0cf
commit c6f1b85c78
18 changed files with 495 additions and 1309 deletions

View File

@ -2,12 +2,23 @@ import js from "@eslint/js";
import reactHooksPlugin from "eslint-plugin-react-hooks";
import tseslint from "typescript-eslint";
// Aggressive, type-aware config: everything typescript-eslint's strict +
// stylistic *type-checked* presets flag is an error, plus the React Hooks
// rules. Unused disable directives are themselves errors, so every
// `eslint-disable` must be load-bearing.
//
// eslint-plugin-react is intentionally omitted: 7.37.x still calls the
// `context.getFilename()` API that ESLint 10 removed, so it crashes the run.
// TypeScript already covers prop typing, and react-hooks (v10-ready) covers the
// hook-specific rules that TS cannot.
export default tseslint.config(
{ ignores: ["dist", "coverage"] },
{ linterOptions: { reportUnusedDisableDirectives: "error" } },
js.configs.recommended,
...tseslint.configs.strictTypeChecked,
...tseslint.configs.stylisticTypeChecked,
{
files: ["**/*.{ts,tsx}"],
plugins: { "react-hooks": reactHooksPlugin },
languageOptions: {
parserOptions: {
@ -23,6 +34,12 @@ export default tseslint.config(
],
},
},
{
// Asserting on a vitest mock method (e.g. `expect(client.remove)`) trips
// unbound-method even though the reference is safe; disable it in tests.
files: ["**/*.test.{ts,tsx}", "src/test/**"],
rules: { "@typescript-eslint/unbound-method": "off" },
},
{
files: ["*.js", "*.mjs", "*.cjs"],
...tseslint.configs.disableTypeChecked,

View File

@ -30,7 +30,6 @@
"@vitejs/plugin-react": "^4.5.2",
"@vitest/coverage-v8": "^4.1.9",
"eslint": "^10.6.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.1.1",
"jsdom": "^29.1.1",
"typescript": "~5.8.3",

File diff suppressed because it is too large Load Diff

View File

@ -47,6 +47,12 @@ describe("parsePropfind", () => {
expect(entry?.size).toBe(0);
expect(entry?.mtimeMs).toBe(0);
});
it("coerces an unparsable size/mtime to zero", () => {
const xml = `<D:multistatus xmlns:D="DAV:"><D:response><D:href>/y.bin</D:href><D:propstat><D:prop><D:resourcetype/><D:getcontentlength>not-a-number</D:getcontentlength><D:getlastmodified>never</D:getlastmodified></D:prop></D:propstat></D:response></D:multistatus>`;
const [entry] = parsePropfind(xml, "/");
expect(entry?.size).toBe(0);
expect(entry?.mtimeMs).toBe(0);
});
});
describe("sortEntries", () => {
@ -83,17 +89,19 @@ function jsonResponse(body: string, ok = true, status = 200): Response {
describe("createDufsClient", () => {
it("list() PROPFINDs, sorts, and returns entries", async () => {
const fetchImpl = vi.fn(() => Promise.resolve(jsonResponse(PROPFIND_XML)));
const client = createDufsClient(fetchImpl as unknown as typeof fetch);
const fetchImpl = vi.fn<typeof fetch>(() =>
Promise.resolve(jsonResponse(PROPFIND_XML)),
);
const client = createDufsClient(fetchImpl);
const entries = await client.list("/Media/2026/07");
const [url, init] = fetchImpl.mock.calls[0] ?? [];
expect(url).toBe("/Media/2026/07");
expect((init as RequestInit).method).toBe("PROPFIND");
const firstCall = fetchImpl.mock.calls.at(0);
expect(firstCall?.[0]).toBe("/Media/2026/07");
expect(firstCall?.[1]?.method).toBe("PROPFIND");
expect(entries.length).toBe(2);
});
it("builds file and thumbnail URLs (encoded)", () => {
const client = createDufsClient(vi.fn() as unknown as typeof fetch);
const client = createDufsClient(vi.fn<typeof fetch>());
expect(client.fileUrl("/Media/a b.jpg")).toBe("/Media/a%20b.jpg");
expect(client.thumbUrl("/Media/a b.jpg")).toBe(
"/.thumbs/Media/a%20b.jpg.jpg",
@ -101,26 +109,26 @@ describe("createDufsClient", () => {
});
it("upload PUTs to dir/name, remove DELETEs, writeText PUTs, readText GETs", async () => {
const fetchImpl = vi.fn(() => Promise.resolve(jsonResponse("content")));
const client = createDufsClient(fetchImpl as unknown as typeof fetch);
const fetchImpl = vi.fn<typeof fetch>(() =>
Promise.resolve(jsonResponse("content")),
);
const client = createDufsClient(fetchImpl);
const file = new File(["x"], "n.txt");
await client.upload("/dir", file);
await client.remove("/dir/n.txt");
await client.writeText("/dir/n.txt", "hi");
const text = await client.readText("/dir/n.txt");
expect(text).toBe("content");
const methods = fetchImpl.mock.calls.map(
(c) => (c[1] as RequestInit).method,
);
const methods = fetchImpl.mock.calls.map((c) => c[1]?.method);
expect(methods).toEqual(["PUT", "DELETE", "PUT", "GET"]);
expect(fetchImpl.mock.calls[0]?.[0]).toBe("/dir/n.txt");
expect(fetchImpl.mock.calls.at(0)?.[0]).toBe("/dir/n.txt");
});
it("throws on non-ok responses", async () => {
const fetchImpl = vi.fn(() =>
const fetchImpl = vi.fn<typeof fetch>(() =>
Promise.resolve(jsonResponse("", false, 404)),
);
const client = createDufsClient(fetchImpl as unknown as typeof fetch);
const client = createDufsClient(fetchImpl);
await expect(client.remove("/nope")).rejects.toThrow("404");
});
});

View File

@ -64,21 +64,28 @@ export function sortEntries(entries: readonly DirEntry[]): DirEntry[] {
}
export function createDufsClient(fetchImpl: typeof fetch = fetch): DufsClient {
async function request(path: string, init: RequestInit): Promise<Response> {
// `method` is always supplied by callers, so the error message and the fetch
// options never need to fall back — keeping it a required arg avoids an
// unreachable default branch.
async function request(
method: string,
path: string,
init: RequestInit = {},
): Promise<Response> {
const res = await fetchImpl(encodePath(path), {
method,
credentials: "same-origin",
...init,
});
if (!res.ok) {
throw new Error(`${init.method ?? "GET"} ${path} → ${res.status}`);
throw new Error(`${method} ${path} → ${res.status}`);
}
return res;
}
return {
async list(dirPath) {
const res = await request(dirPath, {
method: "PROPFIND",
const res = await request("PROPFIND", dirPath, {
headers: { Depth: "1" },
});
return sortEntries(parsePropfind(await res.text(), dirPath));
@ -90,19 +97,16 @@ export function createDufsClient(fetchImpl: typeof fetch = fetch): DufsClient {
return encodePath(`/.thumbs${normalize(path)}.jpg`);
},
async upload(dirPath, file) {
await request(joinPath(dirPath, file.name), {
method: "PUT",
body: file,
});
await request("PUT", joinPath(dirPath, file.name), { body: file });
},
async remove(path) {
await request(path, { method: "DELETE" });
await request("DELETE", path);
},
async readText(path) {
return (await request(path, { method: "GET" })).text();
return (await request("GET", path)).text();
},
async writeText(path, content) {
await request(path, { method: "PUT", body: content });
await request("PUT", path, { body: content });
},
};
}

View File

@ -156,4 +156,101 @@ describe("Gallery", () => {
render(<Gallery client={client} />);
expect(await screen.findByText(/boom/)).toBeInTheDocument();
});
it("navigates via a breadcrumb", async () => {
render(<Gallery client={makeClient()} />);
await screen.findByText("pic.jpg");
await userEvent.click(screen.getByRole("button", { name: "cloud" }));
expect(window.location.hash).toBe("#/");
});
it("opens the file picker from the Upload button", async () => {
render(<Gallery client={makeClient()} />);
await screen.findByText("pic.jpg");
// Should not throw; exercises the hidden-input click path.
await userEvent.click(screen.getByRole("button", { name: "Upload" }));
expect(screen.getByRole("button", { name: "Upload" })).toBeInTheDocument();
});
it("steps to the previous media with ArrowLeft (wrapping)", async () => {
render(<Gallery client={makeClient()} />);
await userEvent.click(await screen.findByText("pic.jpg"));
await waitFor(() => {
expect(document.querySelector(".viewer-img")).not.toBeNull();
});
// media = [pic.jpg, clip.mp4]; from index 0, prev wraps to the video.
fireEvent.keyDown(window, { key: "ArrowLeft" });
await waitFor(() => {
expect(document.querySelector("video")).not.toBeNull();
});
});
it("shows an empty-folder message", async () => {
const client = makeClient({ list: vi.fn(() => Promise.resolve([])) });
render(<Gallery client={client} />);
expect(await screen.findByText("This folder is empty.")).toBeInTheDocument();
});
it("renders breadcrumb separators for nested paths", async () => {
window.location.hash = "#/Media/2026";
render(<Gallery client={makeClient()} />);
await screen.findByText("pic.jpg");
expect(screen.getByRole("button", { name: "Media" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "2026" })).toBeInTheDocument();
expect(document.querySelectorAll(".crumb-sep").length).toBeGreaterThan(0);
});
it("ignores an upload with no files selected", async () => {
const client = makeClient();
const { container } = render(<Gallery client={client} />);
await screen.findByText("pic.jpg");
const input = container.querySelector<HTMLInputElement>(
'input[type="file"]',
);
if (input) fireEvent.change(input, { target: { files: null } });
expect(client.upload).not.toHaveBeenCalled();
});
it("stringifies a non-Error upload rejection", async () => {
const client = makeClient({
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
upload: vi.fn(() => Promise.reject("plain-up")),
});
const { container } = render(<Gallery client={client} />);
await screen.findByText("pic.jpg");
const input = container.querySelector<HTMLInputElement>(
'input[type="file"]',
);
if (input) {
fireEvent.change(input, {
target: { files: [new File(["x"], "u.png")] },
});
}
expect(await screen.findByText("plain-up")).toBeInTheDocument();
});
it("stringifies a non-Error delete rejection", async () => {
const client = makeClient({
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
remove: vi.fn(() => Promise.reject("plain-del")),
});
render(<Gallery client={client} />);
await screen.findByText("pic.jpg");
await userEvent.click(screen.getByLabelText("Delete pic.jpg"));
await userEvent.click(screen.getByRole("button", { name: "Delete" }));
expect(await screen.findByText("plain-del")).toBeInTheDocument();
});
it("closes the text editor and reloads", async () => {
const client = makeClient();
render(<Gallery client={client} />);
await userEvent.click(await screen.findByText("notes.txt"));
await screen.findByDisplayValue("hello world");
await userEvent.click(screen.getByRole("button", { name: "Close" }));
await waitFor(() => {
expect(screen.queryByDisplayValue("hello world")).toBeNull();
});
// reload() re-invokes list (initial + after close).
expect((client.list as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThan(1);
});
});

View File

@ -12,7 +12,8 @@ import { ConfirmDialog } from "./confirm-dialog.tsx";
export function Gallery({ client }: { readonly client: DufsClient }): React.JSX.Element {
const [path, navigate] = useHashPath();
const { entries, loading, error, reload } = useListing(client, path);
const [viewerIndex, setViewerIndex] = useState<number | null>(null);
// -1 means "no viewer open" (avoids a null branch in step()).
const [viewerIndex, setViewerIndex] = useState(-1);
const [editEntry, setEditEntry] = useState<DirEntry | null>(null);
const [deleteEntry, setDeleteEntry] = useState<DirEntry | null>(null);
const [busy, setBusy] = useState<string | null>(null);
@ -23,18 +24,13 @@ export function Gallery({ client }: { readonly client: DufsClient }): React.JSX.
[entries],
);
function openMediaAt(entriesIndex: number): void {
const entry = entries[entriesIndex];
if (entry === undefined) return;
const mi = media.findIndex((m) => m.path === entry.path);
if (mi >= 0) setViewerIndex(mi);
// Grid only calls this for media entries, so the entry is always in `media`.
function openMedia(entry: DirEntry): void {
setViewerIndex(media.findIndex((m) => m.path === entry.path));
}
function step(delta: number): void {
setViewerIndex((cur) => {
if (cur === null || media.length === 0) return cur;
return (cur + delta + media.length) % media.length;
});
setViewerIndex((cur) => (cur + delta + media.length) % media.length);
}
function onUploadPicked(files: FileList | null): void {
@ -54,9 +50,7 @@ export function Gallery({ client }: { readonly client: DufsClient }): React.JSX.
})();
}
function confirmDelete(): void {
const entry = deleteEntry;
if (entry === null) return;
function confirmDelete(entry: DirEntry): void {
setDeleteEntry(null);
setBusy(`Deleting ${entry.name}…`);
client
@ -70,7 +64,7 @@ export function Gallery({ client }: { readonly client: DufsClient }): React.JSX.
});
}
const viewerEntry = viewerIndex !== null ? media[viewerIndex] : null;
const viewerEntry = viewerIndex >= 0 ? media[viewerIndex] : null;
return (
<div className="gallery">
@ -126,7 +120,7 @@ export function Gallery({ client }: { readonly client: DufsClient }): React.JSX.
client={client}
entries={entries}
onOpenDir={navigate}
onOpenMedia={openMediaAt}
onOpenMedia={openMedia}
onEditText={setEditEntry}
onDelete={setDeleteEntry}
/>
@ -139,7 +133,7 @@ export function Gallery({ client }: { readonly client: DufsClient }): React.JSX.
entry={viewerEntry}
url={client.fileUrl(viewerEntry.path)}
onClose={() => {
setViewerIndex(null);
setViewerIndex(-1);
}}
onPrev={() => {
step(-1);
@ -163,7 +157,9 @@ export function Gallery({ client }: { readonly client: DufsClient }): React.JSX.
<ConfirmDialog
message={`Delete "${deleteEntry.name}"? This cannot be undone.`}
confirmLabel="Delete"
onConfirm={confirmDelete}
onConfirm={() => {
confirmDelete(deleteEntry);
}}
onCancel={() => {
setDeleteEntry(null);
}}

View File

@ -0,0 +1,80 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Grid } from "./grid.tsx";
import type { DufsClient } from "../api/dufs-client.ts";
import type { DirEntry } from "../api/types.ts";
function entry(name: string, kind: "dir" | "file"): DirEntry {
return { name, path: `/${name}`, kind, size: 42, mtimeMs: 0 };
}
function makeClient(): DufsClient {
return {
list: vi.fn(),
fileUrl: (p: string) => p,
thumbUrl: (p: string) => `/.thumbs${p}.jpg`,
upload: vi.fn(),
remove: vi.fn(),
readText: vi.fn(),
writeText: vi.fn(),
};
}
const entries: DirEntry[] = [
entry("Media", "dir"),
entry("pic.jpg", "file"),
entry("clip.mp4", "file"),
entry("notes.txt", "file"),
entry("data.bin", "file"),
];
function renderGrid(over: Partial<Parameters<typeof Grid>[0]> = {}) {
const props = {
client: makeClient(),
entries,
onOpenDir: vi.fn(),
onOpenMedia: vi.fn(),
onEditText: vi.fn(),
onDelete: vi.fn(),
...over,
};
render(<Grid {...props} />);
return props;
}
describe("Grid", () => {
it("activates dir, media and text entries via their tiles", async () => {
const onOpenDir = vi.fn();
const onOpenMedia = vi.fn();
const onEditText = vi.fn();
renderGrid({ onOpenDir, onOpenMedia, onEditText });
await userEvent.click(screen.getByText("Media"));
await userEvent.click(screen.getByText("pic.jpg"));
await userEvent.click(screen.getByText("notes.txt"));
// Non-media, non-text file does nothing on activate.
await userEvent.click(screen.getByText("data.bin"));
expect(onOpenDir).toHaveBeenCalledWith("/Media");
expect(onOpenMedia).toHaveBeenCalledWith(entries[1]);
expect(onEditText).toHaveBeenCalledWith(entries[3]);
});
it("renders a download link and a delete button for files", async () => {
const onDelete = vi.fn();
renderGrid({ onDelete });
const download = screen.getByLabelText("Download pic.jpg");
expect(download).toHaveAttribute("href", "/pic.jpg");
await userEvent.click(screen.getByLabelText("Delete pic.jpg"));
expect(onDelete).toHaveBeenCalledWith(entries[1]);
});
it("falls back to an icon when a thumbnail fails to load", () => {
renderGrid();
// Image thumbnail breaks -> picture fallback glyph.
fireEvent.error(screen.getByAltText("pic.jpg"));
expect(screen.getByText("🖼")).toBeInTheDocument();
// Video thumbnail breaks -> film fallback glyph.
fireEvent.error(screen.getByAltText("clip.mp4"));
expect(screen.getByText("🎞")).toBeInTheDocument();
});
});

View File

@ -7,7 +7,7 @@ interface GridProps {
readonly client: DufsClient;
readonly entries: readonly DirEntry[];
readonly onOpenDir: (path: string) => void;
readonly onOpenMedia: (index: number) => void;
readonly onOpenMedia: (entry: DirEntry) => void;
readonly onEditText: (entry: DirEntry) => void;
readonly onDelete: (entry: DirEntry) => void;
}
@ -46,11 +46,11 @@ export function Grid({
}: GridProps): React.JSX.Element {
return (
<ul className="grid">
{entries.map((entry, index) => {
{entries.map((entry) => {
const media = isImage(entry.name) || isVideo(entry.name);
const activate = (): void => {
if (entry.kind === "dir") onOpenDir(entry.path);
else if (media) onOpenMedia(index);
else if (media) onOpenMedia(entry);
else if (isText(entry.name)) onEditText(entry);
};
return (

View File

@ -25,17 +25,22 @@ describe("MediaViewer", () => {
expect(img.className).toContain("zoomed");
});
it("renders a <video> for video files", () => {
it("renders a <video> for video files and does not close on video click", () => {
const onClose = vi.fn();
render(
<MediaViewer
entry={mk("v.mp4")}
url="/v.mp4"
onClose={vi.fn()}
onClose={onClose}
onPrev={vi.fn()}
onNext={vi.fn()}
/>,
);
expect(document.querySelector("video")).not.toBeNull();
const video = document.querySelector("video");
expect(video).not.toBeNull();
if (video) fireEvent.click(video);
// stopPropagation keeps the stage's onClose from firing.
expect(onClose).not.toHaveBeenCalled();
});
it("offers a download for non-media files", () => {

View File

@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TextEditor } from "./text-editor.tsx";
import type { DufsClient } from "../api/dufs-client.ts";
@ -23,7 +23,7 @@ function client(over: Partial<DufsClient>): DufsClient {
readText: vi.fn(() => Promise.resolve("body")),
writeText: vi.fn(() => Promise.resolve()),
...over,
} as DufsClient;
};
}
describe("TextEditor", () => {
@ -56,6 +56,26 @@ describe("TextEditor", () => {
expect(await screen.findByText("wfail")).toBeInTheDocument();
});
it("stringifies a non-Error load rejection", async () => {
const c = client({
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
readText: vi.fn(() => Promise.reject("plain-load-failure")),
});
render(<TextEditor client={c} entry={entry} onClose={vi.fn()} />);
expect(await screen.findByText("plain-load-failure")).toBeInTheDocument();
});
it("stringifies a non-Error save rejection", async () => {
const c = client({
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
writeText: vi.fn(() => Promise.reject("plain-save-failure")),
});
render(<TextEditor client={c} entry={entry} onClose={vi.fn()} />);
await screen.findByDisplayValue("body");
await userEvent.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByText("plain-save-failure")).toBeInTheDocument();
});
it("closes", async () => {
const onClose = vi.fn();
render(<TextEditor client={client({})} entry={entry} onClose={onClose} />);
@ -63,4 +83,36 @@ describe("TextEditor", () => {
await userEvent.click(screen.getByRole("button", { name: "Close" }));
expect(onClose).toHaveBeenCalledOnce();
});
it("ignores a load that resolves after unmount", async () => {
let settle: (v: string) => void = () => undefined;
const pending = new Promise<string>((resolve) => {
settle = resolve;
});
const c = client({ readText: vi.fn(() => pending) });
const { unmount } = render(
<TextEditor client={c} entry={entry} onClose={vi.fn()} />,
);
unmount();
await act(async () => {
settle("late");
await pending;
});
});
it("ignores a load that rejects after unmount", async () => {
let fail: (e: unknown) => void = () => undefined;
const pending = new Promise<string>((_, reject) => {
fail = reject;
});
const c = client({ readText: vi.fn(() => pending) });
const { unmount } = render(
<TextEditor client={c} entry={entry} onClose={vi.fn()} />,
);
unmount();
await act(async () => {
fail(new Error("late"));
await pending.catch(() => undefined);
});
});
});

View File

@ -0,0 +1,99 @@
import { describe, it, expect, vi } from "vitest";
import { renderHook, waitFor, act } from "@testing-library/react";
import { useListing } from "./use-listing.ts";
import type { DufsClient } from "../api/dufs-client.ts";
import type { DirEntry } from "../api/types.ts";
function entry(name: string, kind: "dir" | "file"): DirEntry {
return { name, path: `/${name}`, kind, size: 0, mtimeMs: 0 };
}
function mkClient(list: DufsClient["list"]): DufsClient {
return {
list,
fileUrl: (p: string) => p,
thumbUrl: (p: string) => p,
upload: vi.fn(),
remove: vi.fn(),
readText: vi.fn(),
writeText: vi.fn(),
};
}
describe("useListing", () => {
it("loads entries and hides app-owned files", async () => {
const client = mkClient(() =>
Promise.resolve([
entry("index.html", "file"),
entry(".thumbs", "dir"),
entry("real.jpg", "file"),
]),
);
const { result } = renderHook(() => useListing(client, "/"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.entries.map((e) => e.name)).toEqual(["real.jpg"]);
expect(result.current.error).toBeNull();
});
it("exposes an Error message", async () => {
const client = mkClient(() => Promise.reject(new Error("boom")));
const { result } = renderHook(() => useListing(client, "/"));
await waitFor(() => {
expect(result.current.error).toBe("boom");
});
});
it("stringifies a non-Error rejection", async () => {
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
const client = mkClient(() => Promise.reject("weird"));
const { result } = renderHook(() => useListing(client, "/"));
await waitFor(() => {
expect(result.current.error).toBe("weird");
});
});
it("reload re-fetches the current path", async () => {
const list = vi.fn(() => Promise.resolve([entry("a.jpg", "file")]));
const client = mkClient(list);
const { result } = renderHook(() => useListing(client, "/"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
act(() => {
result.current.reload();
});
await waitFor(() => {
expect(list).toHaveBeenCalledTimes(2);
});
});
it("ignores a resolution that arrives after unmount", async () => {
let settle: (v: DirEntry[]) => void = () => undefined;
const pending = new Promise<DirEntry[]>((resolve) => {
settle = resolve;
});
const client = mkClient(() => pending);
const { unmount } = renderHook(() => useListing(client, "/"));
unmount();
await act(async () => {
settle([entry("late.jpg", "file")]);
await pending;
});
});
it("ignores a rejection that arrives after unmount", async () => {
let fail: (e: unknown) => void = () => undefined;
const pending = new Promise<DirEntry[]>((_, reject) => {
fail = reject;
});
const client = mkClient(() => pending);
const { unmount } = renderHook(() => useListing(client, "/"));
unmount();
await act(async () => {
fail(new Error("late"));
await pending.catch(() => undefined);
});
});
});

View File

@ -11,6 +11,11 @@ function readHash(): string {
return normalize(raw === "" ? "/" : raw);
}
/** Server-render snapshot: there is no hash off the client, so default to root. */
export function serverPath(): string {
return "/";
}
function subscribe(onChange: () => void): () => void {
window.addEventListener("hashchange", onChange);
return () => {
@ -19,7 +24,7 @@ function subscribe(onChange: () => void): () => void {
}
export function useHashPath(): readonly [string, (path: string) => void] {
const path = useSyncExternalStore(subscribe, readHash, () => "/");
const path = useSyncExternalStore(subscribe, readHash, serverPath);
const navigate = useCallback((next: string) => {
window.location.hash = normalize(next);
}, []);

View File

@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useHashPath } from "./lib/use-hash-path.ts";
import { serverPath, useHashPath } from "./lib/use-hash-path.ts";
import { ConfirmDialog } from "./components/confirm-dialog.tsx";
import { App } from "./app.tsx";
@ -38,6 +38,9 @@ describe("useHashPath", () => {
expect(screen.getByTestId("p").textContent).toBe("/x/y");
});
});
it("defaults to root for the server snapshot", () => {
expect(serverPath()).toBe("/");
});
});
describe("ConfirmDialog", () => {
@ -72,9 +75,7 @@ describe("ConfirmDialog", () => {
describe("App", () => {
it("mounts the gallery", async () => {
globalThis.fetch = vi.fn(() =>
Promise.reject(new Error("offline")),
) as unknown as typeof fetch;
globalThis.fetch = vi.fn(() => Promise.reject(new Error("offline")));
render(<App />);
expect(
await screen.findByRole("button", { name: "Upload" }),

View File

@ -16,7 +16,7 @@ export default defineConfig({
provider: "v8",
include: ["src/**/*.{ts,tsx}"],
exclude: ["src/main.tsx", "src/test/**", "src/**/*.d.ts"],
thresholds: { statements: 90, branches: 85, functions: 90, lines: 90 },
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
},
});

View File

@ -0,0 +1,18 @@
{
"title": "cloud_gallery: green build, 100% coverage, aggressive lint, live deploy + mobile app install",
"objective": "Make the cloud_gallery SPA production-ready and deployed: the Vite build must pass, the lint must be the most aggressive type-checked ruleset the toolchain supports, and vitest coverage must be a real 100% across statements/branches/functions/lines. Then deploy the SPA live via setup_cloud_gallery.sh, restore working ffmpeg so video posters generate, and whitelist + install the com.kuhy.dufs_client mobile app so focus mode does not kill it.",
"acceptance_criteria": [
"pnpm build (tsc -b && vite build) succeeds — the prior TS2493/TS2352 tuple errors in dufs-client.test.ts are gone.",
"eslint config uses typescript-eslint strict + stylistic type-checked, react-hooks, and reportUnusedDisableDirectives=error; pnpm lint passes with zero errors.",
"pnpm coverage passes with thresholds set to 100/100/100/100 and reports 100% on all four metrics.",
"setup_cloud_gallery.sh deploys the SPA and dufs serves it with render-spa on 127.0.0.1:5000 (401 unauthenticated confirms it is up and auth-protected).",
"ffmpeg runs (stale /usr/local/bin shadow removed) and generate_thumbnails.sh produces video posters with 0 failures.",
"com.kuhy.dufs_client is in phone_focus_mode WHITELIST, the APK installs, launches, and survives the focus daemon."
],
"out_of_scope": [
"eslint-plugin-react (crashes under ESLint 10; TypeScript covers prop typing)",
"Changes to KeePass sync or media-sync scripts",
"Authenticated end-to-end browser walkthrough of the live URL (user does this at login)"
],
"verifier": "pnpm build + pnpm lint + pnpm coverage (62 tests, 100% all metrics); setup_cloud_gallery.sh run + local dufs :5000 checks; generate_thumbnails.sh (10 made, 0 failed); adb install -r + monkey launch + pidof (RUNNING); documented in docs/superpowers/evidence/cloud-gallery-coverage-lint-deploy-2026-07-12.json"
}

View File

@ -0,0 +1,53 @@
{
"intent": "Fix the cloud_gallery production build, harden its lint to the most aggressive type-checked ruleset, reach 100% test coverage, deploy the SPA live, and whitelist+install the dufs_client mobile app so focus mode does not kill it.",
"scope": [
"cloud_gallery/ React SPA (source, tests, eslint/vite/package config)",
"phone_focus_mode/config.sh (WHITELIST entry only)",
"Non-goal: no changes to KeePass sync or media-sync scripts"
],
"changes": [
"dufs-client.test.ts: typed the fetch mocks as vi.fn<typeof fetch>() so mock.calls carry the real [input, init?] tuple — removed the empty-tuple/undefined-cast TS2493/TS2352 build errors.",
"dufs-client.ts: made request() take an explicit method arg, deleting the unreachable `?? \"GET\"` default branch.",
"gallery.tsx/grid.tsx: replaced the nullable viewer index with a -1 sentinel and pass the media entry (not an index) so three unreachable defensive guards disappear; onOpenMedia signature changed to (entry) => void.",
"use-hash-path.ts: extracted serverPath() so the SSR snapshot is testable.",
"eslint.config.js: strict + stylistic type-checked presets, react-hooks, reportUnusedDisableDirectives:error, test-file unbound-method override; dropped eslint-plugin-react (incompatible with ESLint 10).",
"Added grid.test.tsx and use-listing.test.ts; extended existing suites — coverage thresholds raised to 100/100/100/100.",
"Deployed via setup_cloud_gallery.sh (render-spa live), fixed ffmpeg by renaming a stale /usr/local/bin/ffmpeg shadow that linked the removed libvpx.so.11, regenerated thumbnails (10 made, 0 failed).",
"phone_focus_mode/config.sh: added com.kuhy.dufs_client to WHITELIST."
],
"verification": [
{
"command": "pnpm run lint (tsc --noEmit && eslint src)",
"result": "pass",
"evidence": "no errors"
},
{
"command": "pnpm run coverage",
"result": "pass",
"evidence": "62 tests passed; Statements 100% Branches 100% Functions 100% Lines 100%"
},
{
"command": "pnpm run build (tsc -b && vite build)",
"result": "pass",
"evidence": "dist/assets/index-*.js 204.49 kB (gzip 64.38 kB); built ok"
},
{
"command": "setup_cloud_gallery.sh + curl 127.0.0.1:5000",
"result": "pass",
"evidence": "dufs render-spa live on :5000, returns 401 unauthenticated (up + auth-protected); index.html references built assets; 10 thumbnails on disk"
},
{
"command": "adb -s BL9000EEA0000102 install -r app-debug.apk; monkey launch",
"result": "pass",
"evidence": "Success; pidof com.kuhy.dufs_client RUNNING 4s after launch (not killed by focus daemon)"
}
],
"risks": [
"render-spa restart briefly interrupts the live dufs service (KeePass file GET/PUT/?hash unaffected).",
"The renamed /usr/local/bin/ffmpeg.broken-libvpx11.bak shadow — if any workflow relied on that static build, it now resolves to the pacman ffmpeg instead."
],
"rollback": [
"git revert this commit and re-run setup_cloud_gallery.sh to redeploy the prior SPA; remove com.kuhy.dufs_client from WHITELIST and redeploy focus mode.",
"Restore ffmpeg shadow: sudo mv /usr/local/bin/ffmpeg.broken-libvpx11.bak /usr/local/bin/ffmpeg (not recommended — it is broken)."
]
}

View File

@ -434,6 +434,10 @@ com.xiaomi.smarthome
# linux_configuration/scripts/single_use/features/setup_wireguard_ssh.sh) ---
com.zaneschepke.wireguardautotunnel
org.connectbot
# --- Self-hosted cloud client (dufs; browse/upload media, see
# ~/dufs_client and setup_cloud_gallery.sh) ---
com.kuhy.dufs_client
"
# ============================================================