diff --git a/.gitignore b/.gitignore index 56d8b4a6..40f5936a 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,7 @@ public/docs/ # TanStack Router generated routeTree.gen.ts +apps/server/.tanstack/ # Config config.json diff --git a/apps/server/package.json b/apps/server/package.json index d7b291ab..c40b4a25 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -22,10 +22,13 @@ "lint": "biome lint ./src", "format": "biome format --fix ./src", "check": "biome check --fix --unsafe ./src", - "test": "bun run test:unit && bun run test:integration", + "test": "bun run test:unit && bun run test:integration && bun run test:e2e", "test:unit": "vp test run -c vitest.unit.config.ts", "test:integration": "vp test run -c vitest.integration.config.ts", - "test:e2e": "playwright test", + "test:e2e": "bun scripts/run-e2e.ts --mode=all", + "test:e2e:dev": "bun scripts/run-e2e.ts --mode=dev", + "test:e2e:production": "bun scripts/run-e2e.ts --mode=production", + "test:e2e:playwright": "playwright test", "typecheck": "sh -c 'tsc --noEmit --project ./tsconfig.json'", "gen:spec": "bun scripts/generate-swagger-spec.ts", "docs:generate": "typedoc" diff --git a/apps/server/playwright.config.ts b/apps/server/playwright.config.ts index 82eece64..c6f374df 100644 --- a/apps/server/playwright.config.ts +++ b/apps/server/playwright.config.ts @@ -1,25 +1,118 @@ import { defineConfig, devices } from "@playwright/test"; +type E2eMode = "dev" | "production"; + +function getE2eMode(): E2eMode { + const mode = process.env.E2E_MODE; + if (mode === "dev" || mode === "production") { + return mode; + } + throw new Error( + "E2E_MODE must be set to dev or production. Use bun run test:e2e:dev, test:e2e:production, or test:e2e.", + ); +} + +function getEnvironment(): Record { + return Object.fromEntries( + Object.entries(process.env).flatMap(([key, value]) => + value === undefined ? [] : [[key, value]], + ), + ); +} + +const mode = getE2eMode(); +const runtimeDir = process.env.E2E_RUNTIME_DIR; +const port = process.env.E2E_PORT; + +if (!(runtimeDir && port)) { + throw new Error( + "E2E_RUNTIME_DIR and E2E_PORT must be set by the isolated E2E runner.", + ); +} + +const baseURL = `http://127.0.0.1:${port}`; + export default defineConfig({ - testDir: "./src/tests/e2e", - fullyParallel: true, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : undefined, - reporter: "html", - use: { - baseURL: "http://localhost:3000", - trace: "on-first-retry", - }, - projects: [ - { - name: "chromium", - use: { ...devices["Desktop Chrome"] }, - }, - ], - webServer: { - command: "bun run start", - url: "http://localhost:3000", - reuseExistingServer: !process.env.CI, - }, + testDir: "./src/tests/e2e", + fullyParallel: false, + forbidOnly: true, + retries: 0, + workers: 1, + timeout: 120_000, + expect: { + timeout: 15_000, + }, + reporter: [["line"], ["html", { open: "never" }]], + use: { + baseURL, + trace: "retain-on-failure", + screenshot: "only-on-failure", + video: "retain-on-failure", + actionTimeout: 10_000, + navigationTimeout: 30_000, + }, + projects: [ + { + name: "desktop", + testIgnore: "**/*.responsive.spec.ts", + use: { + ...devices["Desktop Chrome"], + viewport: { width: 1440, height: 900 }, + }, + }, + { + name: "responsive-desktop", + testMatch: "**/*.responsive.spec.ts", + use: { + ...devices["Desktop Chrome"], + viewport: { width: 1440, height: 900 }, + }, + }, + { + name: "responsive-320", + testMatch: "**/*.responsive.spec.ts", + use: { + ...devices["Pixel 5"], + viewport: { width: 320, height: 720 }, + isMobile: true, + hasTouch: true, + }, + }, + { + name: "responsive-375", + testMatch: "**/*.responsive.spec.ts", + use: { + ...devices["Pixel 5"], + viewport: { width: 375, height: 812 }, + isMobile: true, + hasTouch: true, + }, + }, + { + name: "responsive-768", + testMatch: "**/*.responsive.spec.ts", + use: { + ...devices["Desktop Chrome"], + viewport: { width: 768, height: 1024 }, + hasTouch: true, + }, + }, + ], + webServer: { + command: "bun scripts/e2e-server.ts", + cwd: process.cwd(), + env: { + ...getEnvironment(), + E2E: "1", + E2E_MODE: mode, + E2E_PORT: port, + E2E_RUNTIME_DIR: runtimeDir, + }, + url: baseURL, + timeout: mode === "production" ? 240_000 : 120_000, + reuseExistingServer: false, + gracefulShutdown: { signal: "SIGTERM", timeout: 10_000 }, + stdout: "pipe", + stderr: "pipe", + }, }); diff --git a/apps/server/scripts/e2e-server.ts b/apps/server/scripts/e2e-server.ts new file mode 100644 index 00000000..52fe12c0 --- /dev/null +++ b/apps/server/scripts/e2e-server.ts @@ -0,0 +1,320 @@ +import { lstat, mkdir, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { PGlite } from "@electric-sql/pglite"; +import { defaultAppConfig } from "@solid-imager/core/domain/config/config-schema"; +import { mediaGenerationInfo, medias, mediaSources } from "@solid-imager/db/schema"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; +import sharp from "sharp"; +import { + E2E_PRIMARY_FILE_NAME, + E2E_PRIMARY_MEDIA_ID, + E2E_SIMILAR_FILE_NAME, + E2E_SIMILAR_MEDIA_ID, + E2E_SOURCE_ID, + E2E_SOURCE_NAME, + getE2eMediaDir, + getE2eRuntimeDir, + getFixtureMediaPath, +} from "../src/tests/e2e/support/fixture"; + +type E2eMode = "dev" | "production"; + +const appRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); +const allowedRuntimeRoot = path.join(tmpdir(), "solid-imager-e2e"); + +function getMode(): E2eMode { + const mode = process.env.E2E_MODE; + if (mode === "dev" || mode === "production") { + return mode; + } + throw new Error("E2E_MODE must be either 'dev' or 'production'"); +} + +function getPort(): string { + const port = process.env.E2E_PORT; + if (!port) { + throw new Error("E2E_PORT must be set by the E2E runner"); + } + return port; +} + +function assertSafeRuntimeDir(runtimeDir: string): void { + const resolvedRoot = path.resolve(allowedRuntimeRoot); + const relative = path.relative(resolvedRoot, runtimeDir); + if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error( + `E2E_RUNTIME_DIR must be a child of ${resolvedRoot}, received ${runtimeDir}`, + ); + } +} + +function createImageSvg(accentColor: string, backgroundColor: string): Buffer { + return Buffer.from(` + + + + + + `); +} + +async function seedMediaFixtures(runtimeDir: string): Promise { + const mediaDir = getE2eMediaDir(); + const thumbnailDir = path.join(runtimeDir, "thumbnails", E2E_SOURCE_ID); + await mkdir(mediaDir, { recursive: true }); + await mkdir(thumbnailDir, { recursive: true }); + + const primaryPath = getFixtureMediaPath(E2E_PRIMARY_FILE_NAME); + const similarPath = getFixtureMediaPath(E2E_SIMILAR_FILE_NAME); + await Promise.all([ + sharp(createImageSvg("#4f46e5", "#e0e7ff")).png().toFile(primaryPath), + sharp(createImageSvg("#6366f1", "#eef2ff")).png().toFile(similarPath), + ]); + + await Promise.all([ + sharp(primaryPath) + .resize({ width: 512, height: 512, fit: "inside" }) + .webp() + .toFile(path.join(thumbnailDir, `${E2E_PRIMARY_MEDIA_ID}.webp`)), + sharp(similarPath) + .resize({ width: 512, height: 512, fit: "inside" }) + .webp() + .toFile(path.join(thumbnailDir, `${E2E_SIMILAR_MEDIA_ID}.webp`)), + ]); + + const [primaryStats, similarStats] = await Promise.all([ + stat(primaryPath), + stat(similarPath), + ]); + const pgliteDir = path.join(runtimeDir, "pglite"); + const client = new PGlite(pgliteDir); + const db = drizzle(client); + + try { + await migrate(db, { migrationsFolder: path.join(appRoot, "drizzle") }); + const seededAt = new Date(); + await db.insert(mediaSources).values({ + id: E2E_SOURCE_ID, + name: E2E_SOURCE_NAME, + description: "Isolated media source for browser E2E tests", + type: "local", + connectionInfo: { path: mediaDir }, + createdAt: seededAt, + updatedAt: seededAt, + }); + await db.insert(medias).values([ + { + id: E2E_PRIMARY_MEDIA_ID, + mediaSourceId: E2E_SOURCE_ID, + filePath: E2E_PRIMARY_FILE_NAME, + fileName: E2E_PRIMARY_FILE_NAME, + mediaType: "image", + width: 256, + height: 256, + fileSize: primaryStats.size, + description: "Primary E2E fixture media", + createdAt: primaryStats.birthtime, + modifiedAt: primaryStats.mtime, + indexedAt: seededAt, + status: "active", + }, + { + id: E2E_SIMILAR_MEDIA_ID, + mediaSourceId: E2E_SOURCE_ID, + filePath: E2E_SIMILAR_FILE_NAME, + fileName: E2E_SIMILAR_FILE_NAME, + mediaType: "image", + width: 256, + height: 256, + fileSize: similarStats.size, + description: "Similarity candidate E2E fixture media", + createdAt: similarStats.birthtime, + modifiedAt: similarStats.mtime, + indexedAt: seededAt, + status: "active", + }, + ]); + await db.insert(mediaGenerationInfo).values([ + { + mediaId: E2E_PRIMARY_MEDIA_ID, + metadata: { fixture: "e2e-primary" }, + }, + { + mediaId: E2E_SIMILAR_MEDIA_ID, + metadata: { fixture: "e2e-similar" }, + }, + ]); + } finally { + await client.close(); + } +} + +async function createRouteTreePlaceholder(runtimeDir: string): Promise { + const frozenRouteTreePath = path.join(runtimeDir, "routeTree.e2e.ts"); + + // The Vite router plugin must replace this before application modules are + // transformed. A placeholder makes generation failures fail this isolated + // run instead of silently falling back to a checked-in route tree. + await writeFile( + frozenRouteTreePath, + "export const __e2eRouteTreePlaceholder = true;\n", + ); + return frozenRouteTreePath; +} + +async function prepareE2eEnvironment(runtimeDir: string): Promise { + await rm(runtimeDir, { recursive: true, force: true }); + await mkdir(runtimeDir, { recursive: true }); + + const config = { + ...defaultAppConfig, + jobs: { + ...defaultAppConfig.jobs, + concurrency: 1, + aiConcurrency: 1, + pollIntervalMs: 100, + enableAutoTagging: false, + enableAutoCcipExtraction: false, + }, + storage: { + ...defaultAppConfig.storage, + thumbnailDir: path.join(runtimeDir, "thumbnails"), + }, + lancedb: { + ...defaultAppConfig.lancedb, + autoFullSync: false, + cacheDir: path.join(runtimeDir, "lancedb-cache"), + ccipVectorDir: path.join(runtimeDir, "lancedb-ccip"), + }, + }; + await writeFile( + path.join(runtimeDir, "config.json"), + JSON.stringify(config, null, 2), + ); + await seedMediaFixtures(runtimeDir); + return await createRouteTreePlaceholder(runtimeDir); +} + +function serverEnvironment( + runtimeDir: string, + routeTreePath: string, +): Record { + const inherited = Object.fromEntries( + Object.entries(process.env).flatMap(([key, value]) => { + if (value === undefined || key.startsWith("CONFIG_")) { + return []; + } + return [[key, value]]; + }), + ); + const port = getPort(); + return { + ...inherited, + E2E: "1", + E2E_MODE: getMode(), + E2E_PORT: port, + E2E_RUNTIME_DIR: runtimeDir, + E2E_ROUTE_TREE_PATH: routeTreePath, + E2E_OUTPUT_DIR: path.join(runtimeDir, "output"), + DB_HOST: "pglite", + PGLITE_DATA_DIR: path.join(runtimeDir, "pglite"), + CONFIG_PATH: path.join(runtimeDir, "config.json"), + NITRO_HOST: "127.0.0.1", + NITRO_PORT: port, + PORT: port, + }; +} + +async function runCommand( + command: string[], + environment: Record, +): Promise { + const childProcess = Bun.spawn(command, { + cwd: appRoot, + env: environment, + stdout: "inherit", + stderr: "inherit", + stdin: "inherit", + }); + const exitCode = await childProcess.exited; + if (exitCode !== 0) { + throw new Error(`Command failed with exit code ${exitCode}: ${command.join(" ")}`); + } +} + +async function linkProductionDependencies(outputDir: string): Promise { + // Nitro bundles most dependencies, but LanceDB dynamically resolves + // apache-arrow at runtime. An output rooted in /tmp has no ancestor + // node_modules directory, unlike the application's normal .output. + const targetPath = path.join(outputDir, "node_modules"); + try { + await lstat(targetPath); + return; + } catch (error) { + if ( + typeof error !== "object" || + error === null || + !("code" in error) || + error.code !== "ENOENT" + ) { + throw error; + } + } + await symlink( + path.join(appRoot, "node_modules"), + targetPath, + "dir", + ); +} + +async function startServer( + mode: E2eMode, + environment: Record, +): Promise { + const outputDir = environment.E2E_OUTPUT_DIR; + if (!outputDir) { + throw new Error("E2E_OUTPUT_DIR must be set by the E2E runner"); + } + if (mode === "production") { + await runCommand([process.execPath, "run", "build"], environment); + await linkProductionDependencies(outputDir); + } + + const childProcess = Bun.spawn( + mode === "dev" + ? [process.execPath, "run", "dev"] + : [process.execPath, path.join(outputDir, "server", "index.mjs")], + { + cwd: appRoot, + env: environment, + stdout: "inherit", + stderr: "inherit", + stdin: "inherit", + }, + ); + let shutdownRequested = false; + const stop = () => { + shutdownRequested = true; + childProcess.kill(); + }; + process.once("SIGINT", stop); + process.once("SIGTERM", stop); + const exitCode = await childProcess.exited; + process.off("SIGINT", stop); + process.off("SIGTERM", stop); + if (exitCode !== 0 && !shutdownRequested) { + throw new Error(`E2E ${mode} server exited with code ${exitCode}`); + } +} + +const mode = getMode(); +const runtimeDir = getE2eRuntimeDir(); +assertSafeRuntimeDir(runtimeDir); +const routeTreePath = await prepareE2eEnvironment(runtimeDir); +await startServer(mode, serverEnvironment(runtimeDir, routeTreePath)); diff --git a/apps/server/scripts/run-e2e.ts b/apps/server/scripts/run-e2e.ts new file mode 100644 index 00000000..c466008e --- /dev/null +++ b/apps/server/scripts/run-e2e.ts @@ -0,0 +1,109 @@ +import { rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { createServer } from "node:net"; +import { fileURLToPath } from "node:url"; + +type E2eMode = "dev" | "production"; + +const appRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); +const runtimeRoot = path.join(tmpdir(), "solid-imager-e2e"); +const playwrightArguments = process.argv.slice(2).filter( + (value) => !value.startsWith("--mode="), +); + +function getRequestedMode(): E2eMode | "all" { + const argument = process.argv.find((value) => value.startsWith("--mode=")); + const mode = argument?.slice("--mode=".length) ?? "all"; + if (mode === "dev" || mode === "production" || mode === "all") { + return mode; + } + throw new Error("--mode must be dev, production, or all"); +} + +async function findAvailablePort(): Promise { + return await new Promise((resolve, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen({ host: "127.0.0.1", port: 0 }, () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Failed to allocate an E2E port")); + return; + } + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(String(address.port)); + }); + }); + }); +} + +async function createEnvironment( + mode: E2eMode, + runtimeDir: string, +): Promise> { + const inherited = Object.fromEntries( + Object.entries(process.env).flatMap(([key, value]) => + value === undefined ? [] : [[key, value]], + ), + ); + const [port, hmrPort] = await Promise.all([ + findAvailablePort(), + findAvailablePort(), + ]); + return { + ...inherited, + E2E_MODE: mode, + E2E_PORT: port, + E2E_HMR_PORT: hmrPort, + E2E_RUNTIME_DIR: runtimeDir, + }; +} + +async function runMode(mode: E2eMode): Promise { + const runtimeDir = path.join(runtimeRoot, `${mode}-${randomUUID()}`); + const environment = await createEnvironment(mode, runtimeDir); + const childProcess = Bun.spawn( + [ + process.execPath, + "run", + "test:e2e:playwright", + "--", + ...playwrightArguments, + ], + { + cwd: appRoot, + env: environment, + stdout: "inherit", + stderr: "inherit", + stdin: "inherit", + }, + ); + const exitCode = await childProcess.exited; + if (exitCode !== 0) { + console.error(`E2E ${mode} failed. Runtime data kept at ${runtimeDir}`); + process.exitCode = exitCode; + return; + } + await rm(runtimeDir, { recursive: true, force: true }); +} + +const requestedMode = getRequestedMode(); +const modes: E2eMode[] = + requestedMode === "all" ? ["dev", "production"] : [requestedMode]; + +for (const mode of modes) { + await runMode(mode); + if (process.exitCode) { + break; + } +} diff --git a/apps/server/src/components/media/media-sidebar.tsx b/apps/server/src/components/media/media-sidebar.tsx index d5419555..ff3355e8 100644 --- a/apps/server/src/components/media/media-sidebar.tsx +++ b/apps/server/src/components/media/media-sidebar.tsx @@ -12,6 +12,7 @@ import { createMemo, createSignal, For, + on, onCleanup, Show, } from "solid-js"; @@ -110,19 +111,18 @@ export function MediaSidebar(props: MediaSidebarProps) { const [isCcipJobPending, setIsCcipJobPending] = createSignal(false); const [isExtractingCcip, setIsExtractingCcip] = createSignal(false); const [ccipMissingStatusCount, setCcipMissingStatusCount] = createSignal(0); - let ccipAbortController: AbortController | null = null; + let ccipStatusRequestId = 0; const refreshCcipStatus = async () => { - ccipAbortController?.abort(); - ccipAbortController = new AbortController(); - const { signal } = ccipAbortController; + const requestId = ccipStatusRequestId + 1; + ccipStatusRequestId = requestId; const activeJobIdAtRequest = activeCcipJobId(); try { const result = await getCcipVectorStatus( props.media.mediaSourceId, props.media.id, ); - if (signal.aborted) return; + if (ccipStatusRequestId !== requestId) return; if ( result.status === "missing" && activeJobIdAtRequest && @@ -142,7 +142,7 @@ export function MediaSidebar(props: MediaSidebarProps) { setActiveCcipJobId(result.jobId ?? null); setIsCcipJobPending(result.status === "processing"); } catch { - if (signal.aborted) return; + if (ccipStatusRequestId !== requestId) return; if (activeCcipJobId()) return; setCcipStatus("failed"); setActiveCcipJobId(null); @@ -150,16 +150,16 @@ export function MediaSidebar(props: MediaSidebarProps) { } }; - createEffect(() => { - props.media.id; - props.media.mediaSourceId; - setIsExtractingCcip(false); - setIsCcipJobPending(false); - setCcipStatus("missing"); - setActiveCcipJobId(null); - setCcipMissingStatusCount(0); - void refreshCcipStatus(); - }); + createEffect( + on([() => props.media.id, () => props.media.mediaSourceId], () => { + setIsExtractingCcip(false); + setIsCcipJobPending(false); + setCcipStatus("missing"); + setActiveCcipJobId(null); + setCcipMissingStatusCount(0); + void refreshCcipStatus(); + }), + ); const handleCcipExtraction = async () => { setIsExtractingCcip(true); diff --git a/apps/server/src/infrastructure/ai/lancedb-ccip-vector-store.ts b/apps/server/src/infrastructure/ai/lancedb-ccip-vector-store.ts index bbf9fddd..c4d95b6e 100644 --- a/apps/server/src/infrastructure/ai/lancedb-ccip-vector-store.ts +++ b/apps/server/src/infrastructure/ai/lancedb-ccip-vector-store.ts @@ -72,7 +72,12 @@ export class LanceDbCcipVectorStore implements ICcipVectorStore { if (!this.connectionPromise) { this.connectionPromise = import("@lancedb/lancedb") .then((lancedb) => - lancedb.connect(path.resolve(process.cwd(), this.directory)), + lancedb.connect(path.resolve(process.cwd(), this.directory), { + // Vite HMR can leave the job worker and API handler with + // different Table handles. Check the latest committed version on + // every read so a completed extraction is immediately visible. + readConsistencyInterval: 0, + }), ) .catch((err) => { this.connectionPromise = null; diff --git a/apps/server/src/infrastructure/api/routers/ai-router.ts b/apps/server/src/infrastructure/api/routers/ai-router.ts index b2090992..df8272cf 100644 --- a/apps/server/src/infrastructure/api/routers/ai-router.ts +++ b/apps/server/src/infrastructure/api/routers/ai-router.ts @@ -419,35 +419,50 @@ export const aiRouter = { ) .output(ccipVectorStatusSchema) .handler(async ({ input }) => { - const status = await ccipVectorService.getStatus( - input.mediaSourceId, - input.mediaId, - ); - const latestJob = await db.query.jobs.findFirst({ - where: and( - eq(jobs.type, "extract_ccip_vector"), - eq(jobs.mediaSourceId, input.mediaSourceId), - sql`${jobs.payload}->>'mediaId' = ${input.mediaId}`, - ), - orderBy: desc(jobs.createdAt), - }); - if (status.status === "ready" || status.status === "stale") { + try { + const status = await ccipVectorService.getStatus( + input.mediaSourceId, + input.mediaId, + ); + const latestJob = await db.query.jobs.findFirst({ + where: and( + eq(jobs.type, "extract_ccip_vector"), + eq(jobs.mediaSourceId, input.mediaSourceId), + sql`${jobs.payload}->>'mediaId' = ${input.mediaId}`, + ), + orderBy: desc(jobs.createdAt), + }); + if (status.status === "ready" || status.status === "stale") { + return status; + } + if ( + latestJob?.status === "pending" || + latestJob?.status === "in_progress" + ) { + return { status: "processing" as const, jobId: latestJob.id }; + } + if (latestJob?.status === "failed") { + return { + status: "failed" as const, + jobId: latestJob.id, + error: latestJob.error ?? "CCIP vector extraction failed", + }; + } return status; + } catch (error) { + const isError = error instanceof Error; + logger.error( + { + errorName: isError ? error.name : typeof error, + errorMessage: isError ? error.message : String(error), + errorStack: isError ? error.stack : undefined, + mediaSourceId: input.mediaSourceId, + mediaId: input.mediaId, + }, + "CCIP vector status lookup failed", + ); + throw error; } - if ( - latestJob?.status === "pending" || - latestJob?.status === "in_progress" - ) { - return { status: "processing" as const, jobId: latestJob.id }; - } - if (latestJob?.status === "failed") { - return { - status: "failed" as const, - jobId: latestJob.id, - error: latestJob.error ?? "CCIP vector extraction failed", - }; - } - return status; }), startCcipExtraction: os diff --git a/apps/server/src/router.tsx b/apps/server/src/router.tsx index 79546aaa..b6976d20 100644 --- a/apps/server/src/router.tsx +++ b/apps/server/src/router.tsx @@ -10,8 +10,8 @@ import { NotFoundScreen } from "@solid-imager/ui/screens/not-found-screen"; import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"; import { createRouter as createTanStackRouter } from "@tanstack/solid-router"; import { isServer } from "solid-js/web"; +import { routeTree } from "#route-tree"; import type { logger as LoggerInstance } from "./infrastructure/logger"; -import { routeTree } from "./routeTree.gen"; function createAppQueryClient(): QueryClient { return new QueryClient(createAppQueryClientConfig(isTransientApiError)); diff --git a/apps/server/src/routes/config.tsx b/apps/server/src/routes/config.tsx index 4465f429..61588d00 100644 --- a/apps/server/src/routes/config.tsx +++ b/apps/server/src/routes/config.tsx @@ -2,7 +2,7 @@ import { configQueryKeys } from "@solid-imager/ui/query-options"; import { toQueryUiState } from "@solid-imager/ui/query-state"; import { ConfigScreen } from "@solid-imager/ui/screens/config-screen"; import { createQuery, useQueryClient } from "@tanstack/solid-query"; -import { createFileRoute } from "@tanstack/solid-router"; +import { ClientOnly, createFileRoute } from "@tanstack/solid-router"; import { Show } from "solid-js"; import { isServer } from "solid-js/web"; import { orpc } from "~/infrastructure/api-clients/orpc-client"; @@ -15,6 +15,14 @@ export const Route = createFileRoute("/config")({ }); function ConfigPage() { + return ( + + + + ); +} + +function ConfigPageContent() { const configQuery = createQuery(() => ({ ...configQueryOptions(), enabled: !isServer, diff --git a/apps/server/src/routes/manager.tsx b/apps/server/src/routes/manager.tsx index 5d335046..a1bf1af6 100644 --- a/apps/server/src/routes/manager.tsx +++ b/apps/server/src/routes/manager.tsx @@ -1,7 +1,7 @@ import { useManagerPage } from "@solid-imager/ui/hooks/use-manager-page"; import { ManagerScreen } from "@solid-imager/ui/screens/manager-screen"; import { useQueryClient } from "@tanstack/solid-query"; -import { createFileRoute } from "@tanstack/solid-router"; +import { ClientOnly, createFileRoute } from "@tanstack/solid-router"; import { useBatchJobEvents } from "~/hooks/use-batch-job-events"; import { scanBatchCcipTargets, @@ -67,6 +67,14 @@ export const Route = createFileRoute("/manager")({ }); function ManagerPage() { + return ( + + + + ); +} + +function ManagerPageContent() { const queryClient = useQueryClient(); const manager = useManagerPage({ diff --git a/apps/server/src/routes/search.tsx b/apps/server/src/routes/search.tsx index 11e2ceb6..15621706 100644 --- a/apps/server/src/routes/search.tsx +++ b/apps/server/src/routes/search.tsx @@ -4,6 +4,7 @@ import { createPresetClient } from "@solid-imager/ui/preset-client"; import { SearchScreen } from "@solid-imager/ui/screens/search-screen"; import { useQueryClient } from "@tanstack/solid-query"; import { createFileRoute } from "@tanstack/solid-router"; +import { createSignal, onMount, Show } from "solid-js"; import { MediaGridItem } from "~/components/media/media-grid-item"; import { useCurrentSearchPersistence } from "~/hooks/use-current-search-persistence"; import { useMediaSourceEvents } from "~/hooks/use-media-source-events"; @@ -28,8 +29,10 @@ import { } from "~/presentation/store/search-store"; export const Route = createFileRoute("/search")({ - ssr: false, - pendingComponent: () => null, + // SearchScreen is client-only, but this route itself must render on the + // server so its static fallback hydrates against the same component tree. + ssr: true, + pendingComponent: SearchRouteFallback, component: SearchRoute, }); @@ -38,6 +41,35 @@ const SEARCH_RESULTS_REFRESH_DEBOUNCE_MS = 300; const PresetClient = createPresetClient(rawPresetClient); function SearchRoute() { + const [isMounted, setIsMounted] = createSignal(false); + + onMount(() => { + setIsMounted(true); + }); + + return ( + } when={isMounted()}> + {(_mounted) => } + + ); +} + +function SearchRouteFallback() { + return ( +
+
+

メディア検索

+

検索画面を準備しています...

+
+
+ ); +} + +function SearchRouteContent() { const queryClient = useQueryClient(); useCurrentSearchPersistence("all", PresetClient); diff --git a/apps/server/src/routes/sources/$mediaSourceId/$mediaId/index.tsx b/apps/server/src/routes/sources/$mediaSourceId/$mediaId/index.tsx index 9274283b..05c90ec0 100644 --- a/apps/server/src/routes/sources/$mediaSourceId/$mediaId/index.tsx +++ b/apps/server/src/routes/sources/$mediaSourceId/$mediaId/index.tsx @@ -1,26 +1,65 @@ import { MediaDetailScreen } from "@solid-imager/ui/screens/media-detail-screen"; -import { createFileRoute, useParams } from "@tanstack/solid-router"; +import { ClientOnly, createFileRoute, useParams } from "@tanstack/solid-router"; +import { createSignal, onMount, Show } from "solid-js"; import { MediaSidebar } from "~/components/media/media-sidebar"; import { MediaViewer } from "~/components/media/media-viewer"; import { createServerTransport } from "~/hooks/use-media-source-events"; import { mediaDetailsQueryOptions } from "~/infrastructure/api-clients/queries"; export const Route = createFileRoute("/sources/$mediaSourceId/$mediaId/")({ - ssr: false, - pendingComponent: () => null, + // The detail data remains client-fetched, but render a server-safe static + // fallback so direct navigation never starts with an empty page. + ssr: true, + pendingComponent: MediaRouteFallback, component: Media, }); function Media() { + const [isMounted, setIsMounted] = createSignal(false); + + onMount(() => { + setIsMounted(true); + }); + + return ( + } when={isMounted()}> + {(_mounted) => } + + ); +} + +function MediaRouteFallback() { + return ( +
+
+

メディア詳細

+

メディア詳細を準備しています...

+
+
+ ); +} + +function MediaRouteContent() { const params = useParams({ from: "/sources/$mediaSourceId/$mediaId/" }); const mediaSourceId = () => params().mediaSourceId; const mediaId = () => params().mediaId; + return ( + }> + + + ); +} +function MediaContent(props: { mediaId: string; mediaSourceId: string }) { return ( ( )} renderMediaViewer={(media) => } - transport={createServerTransport(mediaSourceId)} + transport={createServerTransport(() => props.mediaSourceId)} /> ); } diff --git a/apps/server/src/routes/sources/index.tsx b/apps/server/src/routes/sources/index.tsx index 518865c0..5b5051e4 100644 --- a/apps/server/src/routes/sources/index.tsx +++ b/apps/server/src/routes/sources/index.tsx @@ -8,7 +8,7 @@ import { SourceCard } from "@solid-imager/ui/source-card"; import { SourceDeleteModal } from "@solid-imager/ui/source-delete-modal"; import { SourceFormModal } from "@solid-imager/ui/source-form-modal"; import { createQuery, useQueryClient } from "@tanstack/solid-query"; -import { createFileRoute } from "@tanstack/solid-router"; +import { ClientOnly, createFileRoute } from "@tanstack/solid-router"; import { isServer } from "solid-js/web"; import { orpc } from "~/infrastructure/api-clients/orpc-client"; import { mediaSourcesQueryOptions } from "~/infrastructure/api-clients/queries"; @@ -33,6 +33,14 @@ function registerSourceEvents(handler: RawEventHandler): () => void { } function SourcesRoute() { + return ( + + + + ); +} + +function SourcesRouteContent() { const queryClient = useQueryClient(); const mediaSources = createQuery(() => ({ ...mediaSourcesQueryOptions(), diff --git a/apps/server/src/tests/e2e/ccip-flow.spec.ts b/apps/server/src/tests/e2e/ccip-flow.spec.ts new file mode 100644 index 00000000..6ef7bd2d --- /dev/null +++ b/apps/server/src/tests/e2e/ccip-flow.spec.ts @@ -0,0 +1,131 @@ +import { + E2E_PRIMARY_FILE_NAME, + E2E_PRIMARY_MEDIA_ID, + E2E_SIMILAR_FILE_NAME, + E2E_SIMILAR_MEDIA_ID, + mediaPath, +} from "./support/fixture"; +import { expect, test } from "./support/test"; + +const startCcipExtractionEndpoint = + /\/api\/rpc\/ai\/startCcipExtraction(?:\?|$)/; +const ccipVectorStatusEndpoint = /\/api\/rpc\/ai\/ccipVectorStatus(?:\?|$)/; + +test("extracts real CCIP vectors and finds a similar seeded image", async ({ + page, + browserHealth, +}) => { + test.setTimeout(180_000); + // The test intentionally aborts the browser request after the server has + // accepted it, in order to reload while extraction remains pending. + browserHealth.allowRequestFailure(startCcipExtractionEndpoint); + + let releaseStartRequest: () => void = () => {}; + const startRequestGate = new Promise((resolve) => { + releaseStartRequest = resolve; + }); + await page.route(startCcipExtractionEndpoint, async (route) => { + await startRequestGate; + await route.continue(); + }); + + const initialStatusResponse = page.waitForResponse((response) => + ccipVectorStatusEndpoint.test(new URL(response.url()).pathname), + ); + await page.goto(mediaPath(E2E_SIMILAR_MEDIA_ID)); + await initialStatusResponse; + + let releaseStatusRequest: () => void = () => {}; + const statusRequestGate = new Promise((resolve) => { + releaseStatusRequest = resolve; + }); + let markPostSubmitStatusRequest: () => void = () => {}; + const postSubmitStatusRequest = new Promise((resolve) => { + markPostSubmitStatusRequest = resolve; + }); + await page.route(ccipVectorStatusEndpoint, async (route) => { + markPostSubmitStatusRequest(); + await statusRequestGate; + await route.continue(); + }); + + await page + .getByRole("button", { name: "Extract CCIP Vector", exact: true }) + .click(); + await expect( + page.getByRole("button", { + name: "Extract CCIP Vector", + exact: true, + }), + ).toBeDisabled(); + releaseStartRequest(); + await postSubmitStatusRequest; + await expect( + page.getByRole("button", { + name: "Extracting CCIP Vector...", + exact: true, + }), + ).toBeDisabled(); + releaseStatusRequest(); + await expect( + page.getByRole("button", { + name: "Re-extract CCIP Vector", + exact: true, + }), + ).toBeEnabled({ timeout: 90_000 }); + + await page.unroute(startCcipExtractionEndpoint); + await page.unroute(ccipVectorStatusEndpoint); + + let markQueuedExtraction: () => void = () => {}; + const queuedExtraction = new Promise((resolve) => { + markQueuedExtraction = resolve; + }); + let abortPendingBrowserRequest: () => void = () => {}; + const pendingBrowserRequest = new Promise((resolve) => { + abortPendingBrowserRequest = resolve; + }); + await page.route(startCcipExtractionEndpoint, async (route) => { + // Queue the real job, but hold its response so F5 happens while this + // client still considers extraction pending. This reproduces the original + // stale-status regression without faking the server-side CCIP work. + await route.fetch(); + markQueuedExtraction(); + await pendingBrowserRequest; + await route.abort("aborted"); + }); + + await page.goto(mediaPath(E2E_PRIMARY_MEDIA_ID)); + await page + .getByRole("button", { name: "Extract CCIP Vector", exact: true }) + .click(); + await queuedExtraction; + const reload = page.reload(); + abortPendingBrowserRequest(); + await reload; + await expect( + page.getByRole("button", { + name: "Re-extract CCIP Vector", + exact: true, + }), + ).toBeEnabled({ timeout: 90_000 }); + await expect( + page.getByRole("button", { name: "Find Similar", exact: true }), + ).toBeVisible(); + + const similarityResponse = page.waitForResponse( + (response) => + new URL(response.url()).pathname === "/api/rpc/media/searchSimilar" && + response.status() === 200, + ); + await page.getByRole("button", { name: "Find Similar", exact: true }).click(); + await similarityResponse; + + await expect(page).toHaveURL(/\/search$/); + await expect( + page.getByRole("link", { name: new RegExp(E2E_SIMILAR_FILE_NAME) }), + ).toBeVisible({ timeout: 30_000 }); + await expect( + page.getByRole("link", { name: new RegExp(E2E_PRIMARY_FILE_NAME) }), + ).toHaveCount(0); +}); diff --git a/apps/server/src/tests/e2e/loading-recovery.spec.ts b/apps/server/src/tests/e2e/loading-recovery.spec.ts new file mode 100644 index 00000000..ea31dbb3 --- /dev/null +++ b/apps/server/src/tests/e2e/loading-recovery.spec.ts @@ -0,0 +1,225 @@ +import { E2E_PRIMARY_FILE_NAME, mediaPath } from "./support/fixture"; +import { expect, test } from "./support/test"; + +const searchEndpoint = /\/api\/rpc\/media\/search(?:\?|$)/; +const mediaDetailsEndpoint = /\/api\/rpc\/media\/getDetails(?:\?|$)/; +const networkFailures = [ + { + name: "connection failed", + errorCode: "connectionfailed", + consoleMessage: "net::ERR_CONNECTION_FAILED", + }, + { + name: "connection refused", + errorCode: "connectionrefused", + consoleMessage: "net::ERR_CONNECTION_REFUSED", + }, + { + name: "timed out", + errorCode: "timedout", + consoleMessage: "net::ERR_TIMED_OUT", + }, + { + name: "connection reset", + errorCode: "connectionreset", + consoleMessage: "net::ERR_CONNECTION_RESET", + }, +] as const; + +test.describe("loading and recovery", () => { + test("keeps the app shell visible while the initial search response is delayed", async ({ + page, + }) => { + let releaseRequest: () => void = () => {}; + const requestGate = new Promise((resolve) => { + releaseRequest = resolve; + }); + await page.route(searchEndpoint, async (route) => { + await requestGate; + await route.continue(); + }); + + const navigation = page.goto("/search", { waitUntil: "commit" }); + await expect(page.getByRole("link", { name: "Home" })).toBeVisible(); + await expect( + page.getByText("検索画面を準備しています...", { exact: true }), + ).toBeVisible(); + await expect( + page.getByText("APIの応答を待っています...", { exact: true }), + ).toBeVisible(); + + releaseRequest(); + await navigation; + await expect( + page.getByRole("link", { name: new RegExp(E2E_PRIMARY_FILE_NAME) }), + ).toBeVisible(); + }); + + test("keeps existing results and form input during a background refresh", async ({ + page, + }) => { + let holdBackgroundRequest = false; + let releaseRequest: () => void = () => {}; + const backgroundGate = new Promise((resolve) => { + releaseRequest = resolve; + }); + await page.route(searchEndpoint, async (route) => { + if (holdBackgroundRequest) { + await backgroundGate; + } + await route.continue(); + }); + + await page.goto("/search"); + const primaryLink = page.getByRole("link", { + name: new RegExp(E2E_PRIMARY_FILE_NAME), + }); + await expect(primaryLink).toBeVisible(); + + holdBackgroundRequest = true; + const searchInput = page.getByPlaceholder("ファイル名を入力..."); + await searchInput.fill(E2E_PRIMARY_FILE_NAME); + await expect( + page.getByText("検索結果を更新中...", { exact: true }), + ).toBeVisible(); + await expect(searchInput).toHaveValue(E2E_PRIMARY_FILE_NAME); + await expect(primaryLink).toBeVisible(); + + releaseRequest(); + await expect( + page.getByText("検索結果を更新中...", { exact: true }), + ).toHaveCount(0); + }); + + test("keeps a media-detail status visible while its initial response is delayed", async ({ + page, + }) => { + let releaseRequest: () => void = () => {}; + const requestGate = new Promise((resolve) => { + releaseRequest = resolve; + }); + await page.route(mediaDetailsEndpoint, async (route) => { + await requestGate; + await route.continue(); + }); + + await page.goto(mediaPath(), { waitUntil: "commit" }); + await expect(page.getByRole("link", { name: "Home" })).toBeVisible(); + await expect( + page.getByText("メディア詳細を準備しています...", { exact: true }), + ).toBeVisible(); + + releaseRequest(); + await expect( + page.getByRole("heading", { name: E2E_PRIMARY_FILE_NAME, exact: true }), + ).toBeVisible(); + }); + + test("shows a recoverable error and reload recovery when media detail is unavailable", async ({ + page, + browserHealth, + }) => { + browserHealth.allowResponseFailure("/api/rpc/media/getDetails"); + browserHealth.allowConsole( + "Failed to load resource: the server responded with a status of 503", + ); + await page.route(mediaDetailsEndpoint, (route) => + route.fulfill({ + status: 503, + contentType: "text/plain", + body: "Service Unavailable", + }), + ); + + await page.goto(mediaPath()); + await expect(page.getByRole("link", { name: "Home" })).toBeVisible(); + await expect(page.getByRole("alert")).toContainText("Error:"); + + await page.unroute(mediaDetailsEndpoint); + const recoveryResponse = await page.reload(); + expect(recoveryResponse?.ok()).toBeTruthy(); + await expect( + page.getByRole("heading", { name: E2E_PRIMARY_FILE_NAME, exact: true }), + ).toBeVisible(); + }); + + for (const failure of networkFailures) { + test(`shows a recoverable error when search ${failure.name}`, async ({ + page, + browserHealth, + }) => { + browserHealth.allowRequestFailure("/api/rpc/media/search"); + browserHealth.allowConsole(failure.consoleMessage); + await page.route(searchEndpoint, (route) => + route.abort(failure.errorCode), + ); + + await page.goto("/search"); + await expect( + page.getByText("検索結果を取得できませんでした", { exact: true }), + ).toBeVisible(); + + await page.unroute(searchEndpoint); + await page.reload(); + await expect( + page.getByRole("link", { name: new RegExp(E2E_PRIMARY_FILE_NAME) }), + ).toBeVisible(); + }); + } + + test("shows a recoverable error when the search service rejects the request", async ({ + page, + browserHealth, + }) => { + browserHealth.allowResponseFailure("/api/rpc/media/search"); + browserHealth.allowConsole( + "Failed to load resource: the server responded with a status of 503", + ); + await page.route(searchEndpoint, (route) => + route.fulfill({ + status: 503, + contentType: "text/plain", + body: "Service Unavailable", + }), + ); + + await page.goto("/search"); + await expect( + page.getByText("検索結果を取得できませんでした", { exact: true }), + ).toBeVisible(); + + await page.unroute(searchEndpoint); + await page.reload(); + await expect( + page.getByRole("link", { name: new RegExp(E2E_PRIMARY_FILE_NAME) }), + ).toBeVisible(); + }); + + test("shows and clears the offline API status without replacing existing content", async ({ + page, + }) => { + await page.goto("/search"); + await expect( + page.getByRole("link", { name: new RegExp(E2E_PRIMARY_FILE_NAME) }), + ).toBeVisible(); + + await page.evaluate(() => window.dispatchEvent(new Event("offline"))); + await expect( + page.getByText( + "APIに接続できません。ネットワーク接続を確認してください。", + { exact: true }, + ), + ).toBeVisible(); + await expect( + page.getByRole("link", { name: new RegExp(E2E_PRIMARY_FILE_NAME) }), + ).toBeVisible(); + + await page.evaluate(() => window.dispatchEvent(new Event("online"))); + await expect( + page.getByText( + "APIに接続できません。ネットワーク接続を確認してください。", + { exact: true }, + ), + ).toHaveCount(0); + }); +}); diff --git a/apps/server/src/tests/e2e/pages.spec.ts b/apps/server/src/tests/e2e/pages.spec.ts deleted file mode 100644 index 31e359a0..00000000 --- a/apps/server/src/tests/e2e/pages.spec.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { expect, test } from "@playwright/test"; - -const HTTP_OK = 200; -const HTTP_INTERNAL_SERVER_ERROR = 500; - -test.describe("SSR Crashing Check - E2E", () => { - const pages = ["/", "/about", "/config", "/manager", "/search", "/sources"]; - - for (const pagePath of pages) { - test(`should load ${pagePath} without crashing`, async ({ page }) => { - const response = await page.goto(pagePath); - // Ensure the response was successful (200 OK) - expect(response?.status()).toBe(HTTP_OK); - }); - } - - test("should load dynamic route /sources/123 without crashing", async ({ - page, - }) => { - const response = await page.goto("/sources/123"); - // Ensure the response does not indicate a server crash (500) - expect(response?.status()).toBeLessThan(HTTP_INTERNAL_SERVER_ERROR); - }); - - test("should load a media detail route without an SSR failure", async ({ - page, - }) => { - const hydrationWarnings: string[] = []; - page.on("console", (message) => { - if (message.text().includes("Hydration Mismatch")) { - hydrationWarnings.push(message.text()); - } - }); - - const mediaDetailPath = - "/sources/00000000-0000-4000-8000-000000000001/00000000-0000-4000-8000-000000000002"; - const ssrResponse = await page.request.get(mediaDetailPath); - expect(ssrResponse.status()).toBe(HTTP_OK); - expect(await ssrResponse.text()).not.toContain("画面を読み込んでいます..."); - - const response = await page.goto(mediaDetailPath); - - expect(response?.status()).toBe(HTTP_OK); - await page.evaluate( - () => - new Promise((resolve) => { - requestAnimationFrame(() => requestAnimationFrame(() => resolve())); - }), - ); - expect(hydrationWarnings).toHaveLength(0); - - const reloadResponse = await page.reload(); - expect(reloadResponse?.status()).toBe(HTTP_OK); - await page.evaluate( - () => - new Promise((resolve) => { - requestAnimationFrame(() => requestAnimationFrame(() => resolve())); - }), - ); - expect(hydrationWarnings).toHaveLength(0); - }); - - test("should reload /search without hydrating a route pending fallback", async ({ - page, - }) => { - const hydrationWarnings: string[] = []; - page.on("console", (message) => { - if (message.text().includes("Hydration Mismatch")) { - hydrationWarnings.push(message.text()); - } - }); - - const ssrResponse = await page.request.get("/search"); - expect(ssrResponse.status()).toBe(HTTP_OK); - expect(await ssrResponse.text()).not.toContain("画面を読み込んでいます..."); - - const response = await page.goto("/search"); - expect(response?.status()).toBe(HTTP_OK); - await page.evaluate( - () => - new Promise((resolve) => { - requestAnimationFrame(() => requestAnimationFrame(() => resolve())); - }), - ); - - const reloadResponse = await page.reload(); - expect(reloadResponse?.status()).toBe(HTTP_OK); - await page.evaluate( - () => - new Promise((resolve) => { - requestAnimationFrame(() => requestAnimationFrame(() => resolve())); - }), - ); - expect(hydrationWarnings).toHaveLength(0); - }); - - test("should not duplicate the initial config query after hydration", async ({ - page, - }) => { - const configRequests: Array<{ - method: string; - postData: string | null; - url: string; - }> = []; - page.on("request", (request) => { - if (request.url().includes("/api/rpc/config/get")) { - configRequests.push({ - method: request.method(), - postData: request.postData(), - url: request.url(), - }); - } - }); - - await page.goto("/config", { waitUntil: "load" }); - await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible(); - await page.evaluate( - () => - new Promise((resolve) => { - requestAnimationFrame(() => requestAnimationFrame(() => resolve())); - }), - ); - - expect(configRequests).toHaveLength(1); - }); -}); diff --git a/apps/server/src/tests/e2e/realtime-preservation.spec.ts b/apps/server/src/tests/e2e/realtime-preservation.spec.ts new file mode 100644 index 00000000..275feab9 --- /dev/null +++ b/apps/server/src/tests/e2e/realtime-preservation.spec.ts @@ -0,0 +1,83 @@ +import { randomUUID } from "node:crypto"; +import { copyFile } from "node:fs/promises"; +import path from "node:path"; +import type { Request } from "@playwright/test"; +import { + E2E_PRIMARY_FILE_NAME, + E2E_SOURCE_NAME, + getE2eMediaDir, + getFixtureMediaPath, + sourcePath, +} from "./support/fixture"; +import { expect, test } from "./support/test"; + +const sourceEventsEndpoint = /\/api\/rpc\/sources\/events(?:\?|$)/; + +test("preserves an open dialog, input value, and focus after an SSE reconnect refreshes media", async ({ + context, + page, +}) => { + let streamAttempts = 0; + let reconnectedRequest: Request | undefined; + const reconnected = page.waitForResponse( + (response) => + response.request() === reconnectedRequest && response.status() === 200, + ); + await page.route(sourceEventsEndpoint, async (route) => { + streamAttempts++; + if (streamAttempts === 1) { + // Close a valid SSE response immediately. This exercises the normal + // completion path and retry backoff without creating a browser error. + await route.fulfill({ + status: 200, + contentType: "text/event-stream", + body: "", + }); + return; + } + if (streamAttempts === 2) { + reconnectedRequest = route.request(); + } + await route.continue(); + }); + + await page.goto(sourcePath()); + await expect(page.getByRole("button", { name: "Add media" })).toBeVisible(); + await reconnected; + expect(streamAttempts).toBeGreaterThanOrEqual(2); + await page.unroute(sourceEventsEndpoint); + + const fileChooser = page.waitForEvent("filechooser"); + await page.getByRole("button", { name: "Add media" }).click(); + await (await fileChooser).setFiles( + getFixtureMediaPath(E2E_PRIMARY_FILE_NAME), + ); + + const filenameInput = page.getByLabel("ファイル名", { exact: true }); + await expect(page.getByRole("dialog")).toBeVisible(); + await expect(filenameInput).toBeVisible(); + await filenameInput.fill("draft-name.png"); + await filenameInput.focus(); + await expect(filenameInput).toBeFocused(); + + const syncedFileName = `realtime-sse-event-${randomUUID()}.png`; + await copyFile( + getFixtureMediaPath(E2E_PRIMARY_FILE_NAME), + path.join(getE2eMediaDir(), syncedFileName), + ); + + const syncPage = await context.newPage(); + await syncPage.goto("/sources"); + const sourceCard = syncPage + .getByTestId("source-card") + .filter({ hasText: E2E_SOURCE_NAME }); + await expect(sourceCard).toBeVisible(); + await sourceCard.getByTestId("sync-source-btn").click(); + + await expect( + page.locator("p").filter({ hasText: /^3 件の結果$/ }), + ).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole("dialog")).toBeVisible(); + await expect(filenameInput).toHaveValue("draft-name.png"); + await expect(filenameInput).toBeFocused(); +}); diff --git a/apps/server/src/tests/e2e/route-reload.spec.ts b/apps/server/src/tests/e2e/route-reload.spec.ts new file mode 100644 index 00000000..81b8c89d --- /dev/null +++ b/apps/server/src/tests/e2e/route-reload.spec.ts @@ -0,0 +1,202 @@ +import { + E2E_PRIMARY_FILE_NAME, + E2E_SOURCE_ID, + mediaPath, + sourcePath, +} from "./support/fixture"; +import { expect, expectRouteHealthy, test } from "./support/test"; + +const routeErrorMarkup = [ + "画面を読み込んでいます...", + "画面を表示できませんでした", + "[object Object]", +]; +const INITIAL_CONTENT_BUDGET_MS = 12_000; +const SPA_CONTENT_BUDGET_MS = 5_000; + +async function expectSsrHtmlHealthy( + page: import("@playwright/test").Page, + path: string, + staticFallbackText?: string, +): Promise { + const response = await page.request.get(path); + expect(response.status()).toBeLessThan(500); + const html = await response.text(); + // Client-only route bodies may defer their data, but the App Shell itself + // must still be server-rendered so a cold navigation is never a blank page. + expect(html).toContain("Home"); + if (staticFallbackText) { + expect(html).toContain(staticFallbackText); + } + for (const markup of routeErrorMarkup) { + expect(html).not.toContain(markup); + } +} + +type RouteCase = { + name: string; + path: string; + heading: string; + apiEndpoint?: string; + readyMediaLink?: string; + staticFallbackText?: string; +}; + +const routeCases: readonly RouteCase[] = [ + { + name: "global search", + path: "/search", + heading: "メディア検索", + apiEndpoint: "/api/rpc/media/search", + readyMediaLink: E2E_PRIMARY_FILE_NAME, + }, + { + name: "settings", + path: "/config", + heading: "Settings", + apiEndpoint: "/api/rpc/config/get", + }, + { + name: "entity manager", + path: "/manager", + heading: "Entity Manager", + }, + { + name: "media sources", + path: "/sources", + heading: "Media Sources", + }, + { + name: "seeded source", + path: sourcePath(), + heading: `Media in Source: ${E2E_SOURCE_ID}`, + }, + { + name: "seeded media detail", + path: mediaPath(), + heading: E2E_PRIMARY_FILE_NAME, + staticFallbackText: "メディア詳細を準備しています...", + }, +]; + +test.describe("direct navigation and reload", () => { + for (const routeCase of routeCases) { + test(`${routeCase.name} renders after direct navigation and reload`, async ({ + page, + browserHealth, + }) => { + await expectSsrHtmlHealthy( + page, + routeCase.path, + routeCase.staticFallbackText, + ); + const directNavigationStartedAt = Date.now(); + const response = await page.goto(routeCase.path); + expect(response?.ok()).toBeTruthy(); + await expect( + page.getByRole("heading", { name: routeCase.heading, exact: true }), + ).toBeVisible(); + if (routeCase.readyMediaLink) { + await expect( + page.getByRole("link", { + name: new RegExp(routeCase.readyMediaLink), + }), + ).toBeVisible(); + } + const directNavigationElapsedMs = Date.now() - directNavigationStartedAt; + browserHealth.recordContentReady( + `${routeCase.name} direct navigation`, + directNavigationElapsedMs, + ); + expect(directNavigationElapsedMs).toBeLessThan(INITIAL_CONTENT_BUDGET_MS); + await expectRouteHealthy(page); + const apiRequestsBeforeReload = routeCase.apiEndpoint + ? browserHealth.apiRequestCount(routeCase.apiEndpoint) + : 0; + if (routeCase.apiEndpoint) { + expect( + apiRequestsBeforeReload, + `API requests: ${JSON.stringify(browserHealth.apiRequests())}`, + ).toBe(1); + } + + const reloadStartedAt = Date.now(); + const reloadResponse = await page.reload(); + expect(reloadResponse?.ok()).toBeTruthy(); + if (!reloadResponse) { + throw new Error( + `Reload did not receive a response for ${routeCase.path}`, + ); + } + const reloadHtml = await reloadResponse.text(); + expect(reloadHtml).toContain("Home"); + if (routeCase.staticFallbackText) { + expect(reloadHtml).toContain(routeCase.staticFallbackText); + } + for (const markup of routeErrorMarkup) { + expect(reloadHtml).not.toContain(markup); + } + await expect( + page.getByRole("heading", { name: routeCase.heading, exact: true }), + ).toBeVisible(); + if (routeCase.readyMediaLink) { + await expect( + page.getByRole("link", { + name: new RegExp(routeCase.readyMediaLink), + }), + ).toBeVisible(); + } + const reloadElapsedMs = Date.now() - reloadStartedAt; + browserHealth.recordContentReady( + `${routeCase.name} reload`, + reloadElapsedMs, + ); + expect(reloadElapsedMs).toBeLessThan(INITIAL_CONTENT_BUDGET_MS); + if (routeCase.apiEndpoint) { + expect( + browserHealth.apiRequestCount(routeCase.apiEndpoint) - + apiRequestsBeforeReload, + `API requests: ${JSON.stringify(browserHealth.apiRequests())}`, + ).toBe(1); + } + await expectRouteHealthy(page); + }); + } +}); + +test("SPA navigation renders the next route without a route error", async ({ + page, + browserHealth, +}) => { + await page.goto("/"); + const searchNavigationStartedAt = Date.now(); + await page.getByRole("link", { name: "Search", exact: true }).click(); + await expect(page).toHaveURL(/\/search$/); + await expect( + page.getByRole("heading", { name: "メディア検索", exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("link", { name: new RegExp(E2E_PRIMARY_FILE_NAME) }), + ).toBeVisible(); + const searchNavigationElapsedMs = Date.now() - searchNavigationStartedAt; + browserHealth.recordContentReady( + "search SPA navigation", + searchNavigationElapsedMs, + ); + expect(searchNavigationElapsedMs).toBeLessThan(SPA_CONTENT_BUDGET_MS); + await expectRouteHealthy(page); + + const sourcesNavigationStartedAt = Date.now(); + await page.getByRole("link", { name: "Sources", exact: true }).click(); + await expect(page).toHaveURL(/\/sources\/?$/); + await expect( + page.getByRole("heading", { name: "Media Sources", exact: true }), + ).toBeVisible(); + const sourcesNavigationElapsedMs = Date.now() - sourcesNavigationStartedAt; + browserHealth.recordContentReady( + "sources SPA navigation", + sourcesNavigationElapsedMs, + ); + expect(sourcesNavigationElapsedMs).toBeLessThan(SPA_CONTENT_BUDGET_MS); + await expectRouteHealthy(page); +}); diff --git a/apps/server/src/tests/e2e/search.responsive.spec.ts b/apps/server/src/tests/e2e/search.responsive.spec.ts new file mode 100644 index 00000000..dc4375ec --- /dev/null +++ b/apps/server/src/tests/e2e/search.responsive.spec.ts @@ -0,0 +1,46 @@ +import type { Page } from "@playwright/test"; +import { E2E_PRIMARY_FILE_NAME } from "./support/fixture"; +import { expect, test } from "./support/test"; + +async function expectNoHorizontalOverflow(page: Page): Promise { + const overflow = await page.evaluate( + () => + document.documentElement.scrollWidth - + document.documentElement.clientWidth, + ); + expect(overflow).toBeLessThanOrEqual(1); +} + +test("search keeps controls usable without horizontal overflow", async ({ + page, +}, testInfo) => { + await page.goto("/search"); + await expect( + page.getByRole("heading", { name: "メディア検索", exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("link", { name: new RegExp(E2E_PRIMARY_FILE_NAME) }), + ).toBeVisible(); + await expectNoHorizontalOverflow(page); + + const usesMobileFilterDialog = ["responsive-320", "responsive-375"].includes( + testInfo.project.name, + ); + if (usesMobileFilterDialog) { + await page.getByRole("button", { name: "Filter results" }).click(); + await expect(page.getByRole("dialog")).toBeVisible(); + await expect( + page.getByRole("heading", { name: "検索フィルター", exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "簡易", exact: true }), + ).toBeVisible(); + } else { + await expect( + page.getByRole("heading", { name: "検索フィルター", exact: true }), + ).toBeVisible(); + await expect(page.getByPlaceholder("ファイル名を入力...")).toBeVisible(); + } + + await expectNoHorizontalOverflow(page); +}); diff --git a/apps/server/src/tests/e2e/support/fixture.ts b/apps/server/src/tests/e2e/support/fixture.ts new file mode 100644 index 00000000..e7f76bd7 --- /dev/null +++ b/apps/server/src/tests/e2e/support/fixture.ts @@ -0,0 +1,33 @@ +import path from "node:path"; + +export const E2E_SOURCE_ID = "11111111-1111-4111-8111-111111111111"; +export const E2E_PRIMARY_MEDIA_ID = "22222222-2222-4222-8222-222222222222"; +export const E2E_SIMILAR_MEDIA_ID = "33333333-3333-4333-8333-333333333333"; + +export const E2E_SOURCE_NAME = "E2E Local Media"; +export const E2E_PRIMARY_FILE_NAME = "e2e-primary.png"; +export const E2E_SIMILAR_FILE_NAME = "e2e-similar.png"; + +export function getE2eRuntimeDir(): string { + const runtimeDir = process.env.E2E_RUNTIME_DIR; + if (!runtimeDir) { + throw new Error("E2E_RUNTIME_DIR must be set by the E2E runner"); + } + return path.resolve(runtimeDir); +} + +export function getE2eMediaDir(): string { + return path.join(getE2eRuntimeDir(), "media"); +} + +export function getFixtureMediaPath(fileName: string): string { + return path.join(getE2eMediaDir(), fileName); +} + +export function sourcePath(): string { + return `/sources/${E2E_SOURCE_ID}`; +} + +export function mediaPath(mediaId = E2E_PRIMARY_MEDIA_ID): string { + return `/sources/${E2E_SOURCE_ID}/${mediaId}`; +} diff --git a/apps/server/src/tests/e2e/support/test.ts b/apps/server/src/tests/e2e/support/test.ts new file mode 100644 index 00000000..d746e2f8 --- /dev/null +++ b/apps/server/src/tests/e2e/support/test.ts @@ -0,0 +1,238 @@ +import type { Page, Request } from "@playwright/test"; +import { test as base, expect } from "@playwright/test"; + +type UrlMatcher = RegExp | string; + +type BrowserHealth = { + allowConsole: (matcher: UrlMatcher) => void; + allowRequestFailure: (matcher: UrlMatcher) => void; + allowResponseFailure: (matcher: UrlMatcher) => void; + apiRequestCount: (matcher: UrlMatcher) => number; + apiRequests: () => readonly string[]; + recordContentReady: (label: string, elapsedMs: number) => void; +}; + +type BrowserPerformanceMetric = { + url: string; + firstContentfulPaintMs: number | null; + navigation: { + responseStartMs: number; + domContentLoadedMs: number; + loadMs: number; + transferSize: number; + } | null; +}; + +type ContentReadyMetric = { + label: string; + elapsedMs: number; +}; + +function matches(value: string, matchers: UrlMatcher[]): boolean { + return matchers.some((matcher) => + typeof matcher === "string" ? value.includes(matcher) : matcher.test(value), + ); +} + +export async function expectRouteHealthy(page: Page): Promise { + await expect( + page.getByText("画面を表示できませんでした", { exact: true }), + ).toHaveCount(0); + await expect(page.getByText("[object Object]", { exact: true })).toHaveCount( + 0, + ); +} + +async function collectPagePerformance( + page: Page, +): Promise { + return await page.evaluate(() => { + const navigation = performance.getEntriesByType("navigation").at(0) as + | PerformanceNavigationTiming + | undefined; + const firstContentfulPaint = performance + .getEntriesByType("paint") + .find((entry) => entry.name === "first-contentful-paint"); + + return { + url: window.location.href, + firstContentfulPaintMs: firstContentfulPaint?.startTime ?? null, + navigation: navigation + ? { + responseStartMs: navigation.responseStart, + domContentLoadedMs: navigation.domContentLoadedEventEnd, + loadMs: navigation.loadEventEnd, + transferSize: navigation.transferSize, + } + : null, + }; + }); +} + +export const test = base.extend<{ browserHealth: BrowserHealth }>({ + browserHealth: [ + async ({ context, page }, use, testInfo) => { + const failures: string[] = []; + const allowedConsole: UrlMatcher[] = []; + const allowedRequestFailures: UrlMatcher[] = []; + const allowedResponseFailures: UrlMatcher[] = []; + const apiRequestUrls: string[] = []; + const apiRequestCounts = new Map(); + const contentReadyMetrics: ContentReadyMetric[] = []; + const observedPages = new Set(); + + const observePage = (target: Page) => { + if (observedPages.has(target)) { + return; + } + observedPages.add(target); + let isClosing = false; + let isNavigating = false; + target.on("console", (message) => { + if (message.type() !== "error" && message.type() !== "warning") { + return; + } + const text = message.text(); + if (!matches(text, allowedConsole)) { + failures.push(`console ${message.type()}: ${text}`); + } + }); + target.on("pageerror", (error) => { + failures.push(`pageerror: ${error.stack ?? error.message}`); + }); + const isMainFrameNavigation = (request: Request) => + request.isNavigationRequest() && + request.frame() === target.mainFrame(); + target.on("request", (request) => { + if (isMainFrameNavigation(request)) { + isNavigating = true; + } + const url = new URL(request.url()); + if (!url.pathname.startsWith("/api/")) { + return; + } + apiRequestUrls.push(request.url()); + const key = `${request.method()} ${url.pathname}`; + apiRequestCounts.set(key, (apiRequestCounts.get(key) ?? 0) + 1); + }); + target.on("requestfailed", (request) => { + const errorText = request.failure()?.errorText ?? null; + // Browser navigation and page close legitimately abort both long-lived + // streams and ordinary in-flight fetches. Keep aborts during an + // otherwise stable page as a test failure. + const isLifecycleRequestAbort = + errorText?.includes("ERR_ABORTED") && (isNavigating || isClosing); + if ( + !isLifecycleRequestAbort && + !matches(request.url(), allowedRequestFailures) + ) { + failures.push( + `request failed: ${request.url()} ${errorText ?? ""}`, + ); + } + // A failed top-level navigation never emits load. Without resetting + // here, later stable-page aborts would be incorrectly ignored. + if (isMainFrameNavigation(request)) { + isNavigating = false; + } + }); + target.on("response", (response) => { + if ( + response.url().includes("/api/") && + response.status() >= 400 && + !matches(response.url(), allowedResponseFailures) + ) { + failures.push( + `server response ${response.status()}: ${response.url()}`, + ); + } + }); + target.on("load", () => { + isNavigating = false; + }); + target.on("close", () => { + isClosing = true; + }); + }; + + observePage(page); + for (const existingPage of context.pages()) { + observePage(existingPage); + } + const handleNewPage = (newPage: Page) => observePage(newPage); + context.on("page", handleNewPage); + + try { + await use({ + allowConsole: (matcher) => allowedConsole.push(matcher), + allowRequestFailure: (matcher) => + allowedRequestFailures.push(matcher), + allowResponseFailure: (matcher) => + allowedResponseFailures.push(matcher), + apiRequestCount: (matcher) => + apiRequestUrls.filter((url) => matches(url, [matcher])).length, + apiRequests: () => apiRequestUrls, + recordContentReady: (label, elapsedMs) => { + contentReadyMetrics.push({ label, elapsedMs }); + }, + }); + } finally { + context.off("page", handleNewPage); + const performanceMetrics: BrowserPerformanceMetric[] = []; + + for (const observedPage of observedPages) { + if (observedPage.isClosed()) { + continue; + } + try { + performanceMetrics.push(await collectPagePerformance(observedPage)); + } catch { + // A page can finish closing between isClosed() and evaluate(). + } + try { + await expectRouteHealthy(observedPage); + } catch (error) { + failures.push( + `route health (${observedPage.url()}): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + + await testInfo.attach("browser-request-metrics", { + body: JSON.stringify( + { + apiRequestCount: apiRequestUrls.length, + duplicateApiRequests: [...apiRequestCounts.entries()] + .filter(([, count]) => count > 1) + .map(([request, count]) => ({ request, count })), + }, + null, + 2, + ), + contentType: "application/json", + }); + await testInfo.attach("browser-performance-metrics", { + body: JSON.stringify(performanceMetrics, null, 2), + contentType: "application/json", + }); + await testInfo.attach("browser-content-ready-metrics", { + body: JSON.stringify(contentReadyMetrics, null, 2), + contentType: "application/json", + }); + + if (failures.length > 0) { + await testInfo.attach("browser-diagnostics", { + body: failures.join("\n"), + contentType: "text/plain", + }); + } + expect(failures).toEqual([]); + } + }, + { auto: true }, + ], +}); + +export { expect } from "@playwright/test"; diff --git a/apps/server/src/tests/integration/ai/lancedb-ccip-vector-store.test.ts b/apps/server/src/tests/integration/ai/lancedb-ccip-vector-store.test.ts new file mode 100644 index 00000000..53131d20 --- /dev/null +++ b/apps/server/src/tests/integration/ai/lancedb-ccip-vector-store.test.ts @@ -0,0 +1,258 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import type { CcipVectorRecord } from "@solid-imager/application/ports/ccip-vector-store"; +import type { ITaggingService } from "@solid-imager/application/ports/tagging-service"; +import { + CCIP_EMBEDDING_VERSION, + CCIP_MODEL, + CcipVectorService, +} from "@solid-imager/application/services/ccip-vector-service"; +import type { Media } from "@solid-imager/core/domain/media/schemas"; +import type { IMediaRepository } from "@solid-imager/core/domain/repositories/media-repository"; +import type { + MediaSource, + SourceRepository, +} from "@solid-imager/core/domain/repositories/source-repository"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { LanceDbCcipVectorStore } from "~/infrastructure/ai/lancedb-ccip-vector-store"; + +const SOURCE_A_ID = "11111111-1111-4111-8111-111111111111"; +const SOURCE_B_ID = "22222222-2222-4222-8222-222222222222"; +const ANCHOR_MEDIA_ID = "33333333-3333-4333-8333-333333333333"; +const NEAR_MEDIA_ID = "44444444-4444-4444-8444-444444444444"; +const FAR_MEDIA_ID = "55555555-5555-4555-8555-555555555555"; +const OTHER_SOURCE_MEDIA_ID = "66666666-6666-4666-8666-666666666666"; + +const MODIFIED_AT = new Date("2026-07-01T00:00:00.000Z"); +const EXTRACTED_AT = new Date("2026-07-02T00:00:00.000Z"); + +function vector(first: number, second = 0): number[] { + return Array.from({ length: 768 }, (_, index) => { + if (index === 0) return first; + if (index === 1) return second; + return 0; + }); +} + +function createMedia(id: string, mediaSourceId: string): Media { + return { + id, + mediaSourceId, + filePath: `${id}.png`, + fileName: `${id}.png`, + mediaType: "image", + width: 256, + height: 256, + fileSize: 1, + description: null, + createdAt: MODIFIED_AT, + modifiedAt: MODIFIED_AT, + indexedAt: MODIFIED_AT, + status: "active", + }; +} + +function createSource(id: string): MediaSource { + return { + id, + name: `LanceDB integration source ${id}`, + description: null, + type: "local", + connectionInfo: { path: "/tmp" }, + createdAt: MODIFIED_AT, + updatedAt: MODIFIED_AT, + }; +} + +function createRecord( + mediaId: string, + mediaSourceId: string, + feature: number[], +): CcipVectorRecord { + return { + mediaId, + mediaSourceId, + vector: feature, + model: CCIP_MODEL, + embeddingVersion: CCIP_EMBEDDING_VERSION, + mediaModifiedAt: MODIFIED_AT, + extractedAt: EXTRACTED_AT, + }; +} + +function createTaggingService(): ITaggingService { + const unsupported = async (): Promise => { + throw new Error( + "This integration test does not invoke native CCIP inference", + ); + }; + + return { + isServiceAvailable: async () => true, + getTags: unsupported, + getTagsForMedia: unsupported, + getCcipFeature: unsupported, + getCcipFeatureForMedia: unsupported, + getCcipDifference: unsupported, + getCcipDistances: async (_anchor, candidates) => + candidates.map((candidate) => candidate[1] ?? Number.POSITIVE_INFINITY), + }; +} + +describe("LanceDbCcipVectorStore integration", () => { + let directory: string | undefined; + + afterEach(async () => { + if (directory) { + await rm(directory, { recursive: true, force: true }); + directory = undefined; + } + }); + + it("persists, reopens, filters, and searches real CCIP vectors", async () => { + directory = await mkdtemp( + path.join(tmpdir(), "solid-imager-ccip-lancedb-"), + ); + const store = new LanceDbCcipVectorStore(directory); + const anchor = createRecord(ANCHOR_MEDIA_ID, SOURCE_A_ID, vector(1)); + const near = createRecord(NEAR_MEDIA_ID, SOURCE_A_ID, vector(0.875, 0.125)); + const far = createRecord(FAR_MEDIA_ID, SOURCE_A_ID, vector(0, 1)); + const otherSource = createRecord( + OTHER_SOURCE_MEDIA_ID, + SOURCE_B_ID, + vector(0.75, 0.25), + ); + + await store.upsertMany([anchor, near, far, otherSource]); + + const loaded = await store.get(ANCHOR_MEDIA_ID); + expect(loaded).toEqual(anchor); + const metadata = await store.getMetadataMany([ANCHOR_MEDIA_ID]); + expect(metadata.get(ANCHOR_MEDIA_ID)).toEqual({ + mediaId: anchor.mediaId, + mediaSourceId: anchor.mediaSourceId, + model: anchor.model, + embeddingVersion: anchor.embeddingVersion, + mediaModifiedAt: anchor.mediaModifiedAt, + extractedAt: anchor.extractedAt, + }); + + const reopenedStore = new LanceDbCcipVectorStore(directory); + expect( + await reopenedStore.getMany([ANCHOR_MEDIA_ID, NEAR_MEDIA_ID]), + ).toEqual( + new Map([ + [ANCHOR_MEDIA_ID, anchor], + [NEAR_MEDIA_ID, near], + ]), + ); + expect(await reopenedStore.listMediaIds(SOURCE_A_ID)).toEqual( + expect.arrayContaining([ANCHOR_MEDIA_ID, NEAR_MEDIA_ID, FAR_MEDIA_ID]), + ); + + const sourceScoped = await reopenedStore.search( + anchor.vector, + 10, + SOURCE_A_ID, + ); + expect(sourceScoped.map((candidate) => candidate.mediaId)).toEqual( + expect.arrayContaining([ANCHOR_MEDIA_ID, NEAR_MEDIA_ID, FAR_MEDIA_ID]), + ); + expect(sourceScoped.map((candidate) => candidate.mediaId)).not.toContain( + OTHER_SOURCE_MEDIA_ID, + ); + }); + + it("sees vectors committed by another open store instance", async () => { + directory = await mkdtemp( + path.join(tmpdir(), "solid-imager-ccip-lancedb-"), + ); + const writer = new LanceDbCcipVectorStore(directory); + const reader = new LanceDbCcipVectorStore(directory); + const anchor = createRecord(ANCHOR_MEDIA_ID, SOURCE_A_ID, vector(1)); + const near = createRecord(NEAR_MEDIA_ID, SOURCE_A_ID, vector(0.875, 0.125)); + + await writer.upsert(anchor); + expect(await reader.get(ANCHOR_MEDIA_ID)).toEqual(anchor); + + // The reader's Table handle is already open at the previous version. + // This mirrors Vite HMR, where an older API module and the active job + // worker can hold separate LanceDB connections to the same directory. + await writer.upsert(near); + + expect(await reader.get(NEAR_MEDIA_ID)).toEqual(near); + }); + + it("reports ready and stale status and searches candidates through the real store", async () => { + directory = await mkdtemp( + path.join(tmpdir(), "solid-imager-ccip-lancedb-"), + ); + const vectorStore = new LanceDbCcipVectorStore(directory); + const mediaById = new Map([ + [ANCHOR_MEDIA_ID, createMedia(ANCHOR_MEDIA_ID, SOURCE_A_ID)], + [NEAR_MEDIA_ID, createMedia(NEAR_MEDIA_ID, SOURCE_A_ID)], + [FAR_MEDIA_ID, createMedia(FAR_MEDIA_ID, SOURCE_A_ID)], + [OTHER_SOURCE_MEDIA_ID, createMedia(OTHER_SOURCE_MEDIA_ID, SOURCE_B_ID)], + ]); + const mediaRepository = { + findById: async (id: string) => mediaById.get(id) ?? null, + findByIds: async (ids: string[]) => + ids.flatMap((id) => { + const media = mediaById.get(id); + return media ? [media] : []; + }), + } as IMediaRepository; + const sources = new Map([ + [SOURCE_A_ID, createSource(SOURCE_A_ID)], + [SOURCE_B_ID, createSource(SOURCE_B_ID)], + ]); + const sourceRepository = { + findById: async (id: string) => sources.get(id) ?? null, + } as SourceRepository; + const service = new CcipVectorService({ + mediaRepository, + sourceRepository, + taggingService: createTaggingService(), + vectorStore, + }); + + await vectorStore.upsertMany([ + createRecord(ANCHOR_MEDIA_ID, SOURCE_A_ID, vector(1)), + createRecord(NEAR_MEDIA_ID, SOURCE_A_ID, vector(0.875, 0.125)), + createRecord(FAR_MEDIA_ID, SOURCE_A_ID, vector(0, 1)), + createRecord(OTHER_SOURCE_MEDIA_ID, SOURCE_B_ID, vector(0.75, 0.25)), + ]); + + expect(await service.getStatus(SOURCE_A_ID, ANCHOR_MEDIA_ID)).toMatchObject( + { + status: "ready", + model: CCIP_MODEL, + }, + ); + const anchorMedia = mediaById.get(ANCHOR_MEDIA_ID); + if (!anchorMedia) { + throw new Error("Anchor media fixture is missing"); + } + mediaById.set(ANCHOR_MEDIA_ID, { + ...anchorMedia, + modifiedAt: new Date(EXTRACTED_AT.getTime() + 1), + }); + expect(await service.getStatus(SOURCE_A_ID, ANCHOR_MEDIA_ID)).toMatchObject( + { + status: "stale", + }, + ); + mediaById.set(ANCHOR_MEDIA_ID, anchorMedia); + + const result = await service.searchSimilar(ANCHOR_MEDIA_ID, 2, SOURCE_A_ID); + expect(result.media.map((media) => media.id)).toEqual([ + NEAR_MEDIA_ID, + FAR_MEDIA_ID, + ]); + expect(result.scores.map((score) => score.mediaId)).toEqual([ + NEAR_MEDIA_ID, + FAR_MEDIA_ID, + ]); + }); +}); diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index 9b15a368..f8ba7965 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -7,6 +7,7 @@ "types": ["vitest/globals"], "isolatedModules": true, "paths": { + "#route-tree": ["./src/routeTree.gen.ts"], "~/*": ["./src/*"], "@solid-imager/ui/*": ["../../packages/ui/src/*"], "@solid-imager/application": ["../../packages/application/src"], diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 49b8c91f..7f8d1f5a 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -12,6 +12,52 @@ import { devtools } from "@tanstack/devtools-vite"; import mkcert from "vite-plugin-mkcert"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const isE2e = process.env.E2E === "1"; +const defaultRouteTreePath = path.resolve(__dirname, "src/routeTree.gen.ts"); + +function getRequiredE2eEnvironment(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} must be set when E2E=1`); + } + return value; +} + +function getE2ePort(name: "E2E_PORT" | "E2E_HMR_PORT", fallback: number): number { + if (!isE2e) { + return fallback; + } + const port = Number.parseInt(getRequiredE2eEnvironment(name), 10); + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new Error(`${name} must be an integer between 1 and 65535 when E2E=1`); + } + return port; +} + +const e2ePort = getE2ePort("E2E_PORT", 3100); +const e2eHmrPort = getE2ePort("E2E_HMR_PORT", 3101); + +const routeTreePath = isE2e + ? path.resolve(getRequiredE2eEnvironment("E2E_ROUTE_TREE_PATH")) + : defaultRouteTreePath; +const e2eNitroOutput = isE2e + ? (() => { + const dir = path.resolve(getRequiredE2eEnvironment("E2E_OUTPUT_DIR")); + return { + dir, + serverDir: path.join(dir, "server"), + publicDir: path.join(dir, "public"), + }; + })() + : undefined; +const e2eViteCacheDir = isE2e + ? path.join(getRequiredE2eEnvironment("E2E_RUNTIME_DIR"), "vite-cache") + : undefined; +const e2eRouterTmpDir = isE2e + ? path.join(getRequiredE2eEnvironment("E2E_RUNTIME_DIR"), "tanstack-tmp") + : undefined; +const workspaceRoot = path.resolve(__dirname, "../.."); +const routeFileIgnorePattern = "^components$"; type RuntimeImport = (specifier: string) => Promise; @@ -89,16 +135,36 @@ const devOrpcNodeMiddlewarePlugin = (): Plugin => ({ }); export default defineConfig({ - server: { - hmr: { - protocol: "wss", - host: "localhost", - port: 3001, - clientPort: 3001, + cacheDir: e2eViteCacheDir, + server: isE2e + ? { + host: "127.0.0.1", + port: e2ePort, + strictPort: true, + // Keep the dev-only transform and HMR client active. This catches the + // class of regressions that production builds cannot reproduce, while + // using a run-specific socket avoids the developer server's port. + hmr: { + protocol: "ws", + host: "127.0.0.1", + port: e2eHmrPort, + clientPort: e2eHmrPort, + }, + fs: { + allow: [workspaceRoot, path.dirname(routeTreePath)], + }, + } + : { + hmr: { + protocol: "wss", + host: "localhost", + port: 3001, + clientPort: 3001, + }, }, - }, resolve: { alias: { + "#route-tree": routeTreePath, "@solid-imager/core": path.resolve(__dirname, "../../packages/core/src"), "@": path.resolve(__dirname, "../../packages/core/src"), "~": path.resolve(__dirname, "./src"), @@ -106,20 +172,42 @@ export default defineConfig({ tsconfigPaths: true, }, plugins: [ - mkcert(), + ...(isE2e ? [] : [mkcert()]), bypassSecFetchDestPlugin(), devOrpcNodeMiddlewarePlugin(), devtools({ consolePiping: { enabled: false }, }), - nitro(), - tanstackRouter({ - target: "solid", - autoCodeSplitting: true, - routeFileIgnorePattern: ".*/components/.*", - }), + nitro( + e2eNitroOutput + ? { + output: e2eNitroOutput, + } + : undefined, + ), + tanstackRouter({ + target: "solid", + autoCodeSplitting: true, + routeFileIgnorePattern, + tmpDir: e2eRouterTmpDir, + // TanStack Start owns route generation below. Keep this standalone plugin + // disabled during E2E so two generators do not race on the same runtime + // file. + generatedRouteTree: routeTreePath, + enableRouteGeneration: !isE2e, + }), tailwindcss(), - tanstackStart(), + tanstackStart({ + router: { + // The generator matches each directory name, not its full path. + routeFileIgnorePattern, + // Start's generator powers its client route-tree transform, so keep it + // enabled. Its output path is isolated above during E2E. + generatedRouteTree: routeTreePath, + enableRouteGeneration: true, + tmpDir: e2eRouterTmpDir, + }, + }), solidPlugin({ ssr: true }), ], optimizeDeps: { diff --git a/mise.toml b/mise.toml index d759079b..79ef8763 100644 --- a/mise.toml +++ b/mise.toml @@ -3,6 +3,7 @@ run="psql -h ${DB_HOST} -p ${DB_PORT} -U ${DB_USER} ${DB_DATABASE}" [env] _.file = '.env' +AGENT_BROWSER_SOCKET_DIR = "/tmp/solid-imager-agent-browser" [tools] bun = "latest" @@ -10,4 +11,4 @@ java = "21.0.2" rust = "latest" "npm:chrome-devtools-mcp" = "latest" android-sdk = "latest" -"github:vercel-labs/agent-browser" = "latest" +"npm:agent-browser" = "latest" diff --git a/packages/ui/src/event-stream.test.ts b/packages/ui/src/event-stream.test.ts new file mode 100644 index 00000000..f2f3aae4 --- /dev/null +++ b/packages/ui/src/event-stream.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { subscribeToEventStream } from "./event-stream"; + +function createPageHideEvent(persisted: boolean): PageTransitionEvent { + const event = new Event("pagehide") as PageTransitionEvent; + Object.defineProperty(event, "persisted", { value: persisted }); + return event; +} + +describe("subscribeToEventStream", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("stops an active stream before a non-BFCache page unload", async () => { + const page = new EventTarget(); + vi.stubGlobal("window", page); + const onError = vi.fn(); + let streamSignal: AbortSignal | undefined; + const openStream = vi.fn( + (signal: AbortSignal) => + new Promise>((_, reject) => { + streamSignal = signal; + signal.addEventListener( + "abort", + () => reject(new Error("network error")), + { once: true }, + ); + }), + ); + + const unsubscribe = subscribeToEventStream(openStream, vi.fn(), onError); + expect(streamSignal).toBeDefined(); + + page.dispatchEvent(createPageHideEvent(false)); + expect(streamSignal?.aborted).toBe(true); + await Promise.resolve(); + await Promise.resolve(); + + expect(onError).not.toHaveBeenCalled(); + unsubscribe(); + }); + + it("keeps an active stream for a BFCache pagehide event", () => { + const page = new EventTarget(); + vi.stubGlobal("window", page); + let streamSignal: AbortSignal | undefined; + const openStream = vi.fn((signal: AbortSignal) => { + streamSignal = signal; + return new Promise>(() => {}); + }); + + const unsubscribe = subscribeToEventStream(openStream, vi.fn()); + page.dispatchEvent(createPageHideEvent(true)); + + expect(streamSignal?.aborted).toBe(false); + unsubscribe(); + expect(streamSignal?.aborted).toBe(true); + }); +}); diff --git a/packages/ui/src/event-stream.ts b/packages/ui/src/event-stream.ts index 08999df3..d9a90def 100644 --- a/packages/ui/src/event-stream.ts +++ b/packages/ui/src/event-stream.ts @@ -34,6 +34,16 @@ export function subscribeToEventStream( onError?: EventStreamErrorHandler, ): () => void { const abortController = new AbortController(); + const canObservePageLifecycle = typeof window !== "undefined"; + const onPageHide = (event: PageTransitionEvent) => { + if (!event.persisted) { + abortController.abort(); + } + }; + + if (canObservePageLifecycle) { + window.addEventListener("pagehide", onPageHide); + } const start = async () => { let retryCount = 0; @@ -77,6 +87,9 @@ export function subscribeToEventStream( void start(); return () => { + if (canObservePageLifecycle) { + window.removeEventListener("pagehide", onPageHide); + } abortController.abort(); }; } diff --git a/packages/ui/src/media-sidebar.tsx b/packages/ui/src/media-sidebar.tsx index 1c1c1033..7a5d8643 100644 --- a/packages/ui/src/media-sidebar.tsx +++ b/packages/ui/src/media-sidebar.tsx @@ -18,6 +18,7 @@ import { createSignal, For, type JSX, + on, onCleanup, Show, } from "solid-js"; @@ -102,11 +103,11 @@ export function MediaSidebar(props: MediaSidebarProps) { ); const [isCcipJobPending, setIsCcipJobPending] = createSignal(false); const [isExtractingCcip, setIsExtractingCcip] = createSignal(false); - const [ccipStatusRequestId, setCcipStatusRequestId] = createSignal(0); const [ccipMissingStatusCount, setCcipMissingStatusCount] = createSignal(0); const [descriptionValue, setDescriptionValue] = createSignal( props.media.description || "", ); + let ccipStatusRequestId = 0; createEffect(() => { if (!isEditingDescription()) { @@ -115,13 +116,13 @@ export function MediaSidebar(props: MediaSidebarProps) { }); const refreshCcipStatus = async () => { - const requestId = ccipStatusRequestId() + 1; - setCcipStatusRequestId(requestId); + const requestId = ccipStatusRequestId + 1; + ccipStatusRequestId = requestId; const activeJobIdAtRequest = activeCcipJobId(); if (props.getCcipVectorStatus) { try { const result = await props.getCcipVectorStatus(); - if (ccipStatusRequestId() !== requestId) { + if (ccipStatusRequestId !== requestId) { return; } if ( @@ -143,7 +144,7 @@ export function MediaSidebar(props: MediaSidebarProps) { setActiveCcipJobId(result.jobId ?? null); setIsCcipJobPending(result.status === "processing"); } catch { - if (ccipStatusRequestId() !== requestId) { + if (ccipStatusRequestId !== requestId) { return; } if (!activeCcipJobId()) { @@ -155,15 +156,15 @@ export function MediaSidebar(props: MediaSidebarProps) { } }; - createEffect(() => { - props.media.id; - props.media.mediaSourceId; - setCcipStatus("missing"); - setActiveCcipJobId(null); - setIsCcipJobPending(false); - setCcipMissingStatusCount(0); - void refreshCcipStatus(); - }); + createEffect( + on([() => props.media.id, () => props.media.mediaSourceId], () => { + setCcipStatus("missing"); + setActiveCcipJobId(null); + setIsCcipJobPending(false); + setCcipMissingStatusCount(0); + void refreshCcipStatus(); + }), + ); const extractCcipVector = async () => { if (!props.startCcipExtraction) return; diff --git a/packages/ui/src/screens/search-screen.tsx b/packages/ui/src/screens/search-screen.tsx index 34f1274d..15ee7c9d 100644 --- a/packages/ui/src/screens/search-screen.tsx +++ b/packages/ui/src/screens/search-screen.tsx @@ -49,7 +49,7 @@ export function SearchScreen(props: SearchScreenProps) { ]; const openMobileFilters = () => setIsMobileFilterOpen(true); - const panel = ( + const renderPanel = () => ( 検索フィルター -
{panel}
+
{renderPanel()}
@@ -101,7 +101,7 @@ export function SearchScreen(props: SearchScreenProps) { 検索フィルター - {panel} + {renderPanel()}