The deployment owner adds folders of photos and videos in the media settings. A per-folder background job indexes them (relative path, kind, size, capture date, dimensions, duration), re-indexes every six hours, and drops files that are gone. The owner's own runs get two tools: search_media (words in folder and file names, kind, capture date range, newest first) and import_media (copies one file into the conversation as an artifact). Files are never changed; hidden entries and symlinks are skipped, and an import must resolve inside its folder. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs
236 lines
9.2 KiB
TypeScript
236 lines
9.2 KiB
TypeScript
import type { Page } from "@playwright/test";
|
|
import { expect, test } from "@playwright/test";
|
|
import { captureScreenshot, completeOnboarding, openUserSettings, signup } from "./helpers";
|
|
|
|
type Draft = {
|
|
comfyui: {
|
|
baseUrl: string;
|
|
apiKey?: string;
|
|
imageModel?: string;
|
|
imageWorkflow?: string;
|
|
videoWorkflow?: string;
|
|
} | null;
|
|
transcription: { baseUrl: string; model?: string; apiKey?: string } | null;
|
|
};
|
|
|
|
/** The server keeps keys; the settings screen only learns whether one is saved. */
|
|
function view(draft: Draft, previous: { comfyui: boolean; transcription: boolean }) {
|
|
const hasKey = (key: string | undefined, had: boolean) => (key === undefined ? had : key !== "");
|
|
const text = (value: string | undefined) => (value ? value : undefined);
|
|
return {
|
|
comfyui: draft.comfyui
|
|
? {
|
|
baseUrl: draft.comfyui.baseUrl,
|
|
hasApiKey: hasKey(draft.comfyui.apiKey, previous.comfyui),
|
|
imageModel: text(draft.comfyui.imageModel),
|
|
imageWorkflow: text(draft.comfyui.imageWorkflow),
|
|
videoWorkflow: text(draft.comfyui.videoWorkflow),
|
|
}
|
|
: null,
|
|
transcription: draft.transcription
|
|
? {
|
|
baseUrl: draft.transcription.baseUrl,
|
|
model: text(draft.transcription.model),
|
|
hasApiKey: hasKey(draft.transcription.apiKey, previous.transcription),
|
|
}
|
|
: null,
|
|
};
|
|
}
|
|
|
|
type Source = {
|
|
id: string;
|
|
path: string;
|
|
status: "idle" | "syncing" | "failed";
|
|
error: string | null;
|
|
assetCount: number;
|
|
lastSyncedAt: string | null;
|
|
};
|
|
|
|
/** Library folders on the fake server: a new folder indexes on its next list. */
|
|
async function fakeLibrary(page: Page, initial: Source[] = []) {
|
|
let sources = initial;
|
|
const calls: { proc: string; input: unknown }[] = [];
|
|
await page.route("**/rpc/media/library/**", (route) => {
|
|
const proc = new URL(route.request().url()).pathname.split("/").at(-1) ?? "";
|
|
const input = (route.request().postDataJSON() as { json?: unknown } | null)?.json;
|
|
calls.push({ proc, input });
|
|
if (proc === "list") {
|
|
const listed = sources;
|
|
sources = sources.map((source) =>
|
|
source.status === "syncing"
|
|
? { ...source, status: "idle", assetCount: 1284, lastSyncedAt: new Date().toISOString() }
|
|
: source,
|
|
);
|
|
return route.fulfill({ json: { json: listed } });
|
|
}
|
|
if (proc === "add") {
|
|
const { path } = input as { path: string };
|
|
if (!path.startsWith("/")) {
|
|
return route.fulfill({
|
|
status: 400,
|
|
json: {
|
|
json: {
|
|
defined: false,
|
|
code: "BAD_REQUEST",
|
|
status: 400,
|
|
message: "Use an absolute folder path.",
|
|
},
|
|
},
|
|
});
|
|
}
|
|
const source: Source = {
|
|
id: `source-${sources.length + 1}`,
|
|
path,
|
|
status: "syncing",
|
|
error: null,
|
|
assetCount: 0,
|
|
lastSyncedAt: null,
|
|
};
|
|
sources = [...sources, source];
|
|
return route.fulfill({ json: { json: source } });
|
|
}
|
|
if (proc === "remove") {
|
|
const { sourceId } = input as { sourceId: string };
|
|
sources = sources.filter((source) => source.id !== sourceId);
|
|
return route.fulfill({ json: { json: { ok: true } } });
|
|
}
|
|
return route.fallback();
|
|
});
|
|
return calls;
|
|
}
|
|
|
|
async function asDeploymentOwner(page: Page, isDeploymentOwner: boolean) {
|
|
await page.route("**/rpc/bootstrap", async (route) => {
|
|
const response = await route.fetch();
|
|
const body = await response.json();
|
|
await route.fulfill({
|
|
response,
|
|
json: { json: { ...body.json, me: { ...body.json.me, isDeploymentOwner } } },
|
|
});
|
|
});
|
|
}
|
|
|
|
test("the deployment owner points media at their own servers", async ({ page }, testInfo) => {
|
|
await asDeploymentOwner(page, true);
|
|
await fakeLibrary(page);
|
|
let current: ReturnType<typeof view> = { comfyui: null, transcription: null };
|
|
const saved: Draft[] = [];
|
|
await page.route("**/rpc/media/settings/get", (route) =>
|
|
route.fulfill({ json: { json: current } }),
|
|
);
|
|
await page.route("**/rpc/media/settings/update", (route) => {
|
|
const draft = (route.request().postDataJSON() as { json: Draft }).json;
|
|
saved.push(draft);
|
|
current = view(draft, {
|
|
comfyui: Boolean(current.comfyui?.hasApiKey),
|
|
transcription: Boolean(current.transcription?.hasApiKey),
|
|
});
|
|
return route.fulfill({ json: { json: current } });
|
|
});
|
|
|
|
await signup(page, `media-settings-${Date.now()}@rakazo.test`, "password12", "Media Owner");
|
|
await completeOnboarding(page);
|
|
const settings = await openUserSettings(page);
|
|
await settings.getByTestId("settings-nav-media").click();
|
|
const panel = settings.getByTestId("media-settings");
|
|
await expect(panel).toBeVisible();
|
|
|
|
const comfy = panel.locator("section").filter({ hasText: "ComfyUI" });
|
|
const transcription = panel.locator("section").filter({ hasText: "Transcription" });
|
|
await comfy.getByLabel("Server URL", { exact: true }).fill("http://127.0.0.1:8188");
|
|
await comfy.getByLabel("API key", { exact: true }).fill("fake-comfy-key");
|
|
await transcription.getByLabel("Server URL", { exact: true }).fill("http://127.0.0.1:8080/v1");
|
|
await transcription.getByLabel("Model", { exact: true }).fill("whisper-1");
|
|
// The ComfyUI model and workflows stay folded away until asked for.
|
|
await expect(comfy.getByLabel("Image model", { exact: true })).toBeHidden();
|
|
await comfy.scrollIntoViewIfNeeded();
|
|
await captureScreenshot(page, testInfo, "media-settings");
|
|
await comfy.getByText("Advanced", { exact: true }).click();
|
|
await comfy.getByLabel("Image model", { exact: true }).fill("sd_xl_base_1.0.safetensors");
|
|
|
|
await panel.getByRole("button", { name: "Save", exact: true }).click();
|
|
await expect(panel.getByText("Saved", { exact: true })).toBeVisible();
|
|
expect(saved).toEqual([
|
|
{
|
|
comfyui: {
|
|
baseUrl: "http://127.0.0.1:8188",
|
|
apiKey: "fake-comfy-key",
|
|
imageModel: "sd_xl_base_1.0.safetensors",
|
|
imageWorkflow: "",
|
|
videoWorkflow: "",
|
|
},
|
|
transcription: { baseUrl: "http://127.0.0.1:8080/v1", model: "whisper-1" },
|
|
},
|
|
]);
|
|
// The key never comes back; the field shows that one is saved and offers to remove it.
|
|
const comfyKey = comfy.getByLabel("API key", { exact: true });
|
|
await expect(comfyKey).toHaveValue("");
|
|
await expect(comfyKey).toHaveAttribute("placeholder", "Saved");
|
|
|
|
await comfy.getByRole("button", { name: "Remove", exact: true }).click();
|
|
await expect(comfyKey).toBeFocused();
|
|
await transcription.getByLabel("Server URL", { exact: true }).fill("");
|
|
await panel.getByRole("button", { name: "Save", exact: true }).click();
|
|
await expect.poll(() => saved.length).toBe(2);
|
|
expect(saved[1]).toMatchObject({ comfyui: { apiKey: "" }, transcription: null });
|
|
await expect(comfyKey).toHaveAttribute("placeholder", "Optional");
|
|
});
|
|
|
|
test("the deployment owner adds a photo folder to the library", async ({ page }, testInfo) => {
|
|
await asDeploymentOwner(page, true);
|
|
const calls = await fakeLibrary(page, [
|
|
{
|
|
id: "source-0",
|
|
path: "/srv/media/Videos",
|
|
status: "failed",
|
|
error: "Folder not found.",
|
|
assetCount: 0,
|
|
lastSyncedAt: null,
|
|
},
|
|
]);
|
|
await page.route("**/rpc/media/settings/get", (route) =>
|
|
route.fulfill({ json: { json: { comfyui: null, transcription: null } } }),
|
|
);
|
|
|
|
await signup(page, `media-library-${Date.now()}@rakazo.test`, "password12", "Library Owner");
|
|
await completeOnboarding(page);
|
|
const settings = await openUserSettings(page);
|
|
await settings.getByTestId("settings-nav-media").click();
|
|
const library = settings.getByTestId("media-library");
|
|
await expect(library.getByText("Folder not found.")).toBeVisible();
|
|
|
|
const folder = library.getByLabel("Folder", { exact: true });
|
|
await folder.fill("photos");
|
|
await library.getByRole("button", { name: "Add", exact: true }).click();
|
|
await expect(library.getByRole("alert")).toHaveText("Use an absolute folder path.");
|
|
|
|
await folder.fill("/srv/media/Photos");
|
|
await library.getByRole("button", { name: "Add", exact: true }).click();
|
|
await expect(folder).toHaveValue("");
|
|
await expect(library.getByText("/srv/media/Photos")).toBeVisible();
|
|
// The new folder indexes in the background; the list catches up on its own.
|
|
await expect(library.getByText("1,284 files")).toBeVisible({ timeout: 10_000 });
|
|
await captureScreenshot(page, testInfo, "media-library");
|
|
|
|
await library
|
|
.getByRole("listitem")
|
|
.filter({ hasText: "/srv/media/Videos" })
|
|
.getByRole("button", { name: "Remove folder" })
|
|
.click();
|
|
await expect(library.getByText("/srv/media/Videos")).toHaveCount(0);
|
|
expect(calls.filter((call) => call.proc !== "list")).toEqual([
|
|
{ proc: "add", input: { path: "photos" } },
|
|
{ proc: "add", input: { path: "/srv/media/Photos" } },
|
|
{ proc: "remove", input: { sourceId: "source-0" } },
|
|
]);
|
|
});
|
|
|
|
test("members who do not own the deployment have no media settings", async ({ page }) => {
|
|
await asDeploymentOwner(page, false);
|
|
await signup(page, `media-member-${Date.now()}@rakazo.test`, "password12", "Media Member");
|
|
await completeOnboarding(page);
|
|
const settings = await openUserSettings(page);
|
|
await expect(settings.getByTestId("settings-nav-voice")).toBeVisible();
|
|
await expect(settings.getByTestId("settings-nav-media")).toHaveCount(0);
|
|
});
|