diff --git a/apps/desktop/scripts/run-tests.mjs b/apps/desktop/scripts/run-tests.mjs index a77fc061..bfcdb4da 100644 --- a/apps/desktop/scripts/run-tests.mjs +++ b/apps/desktop/scripts/run-tests.mjs @@ -67,6 +67,7 @@ const behaviorTests = [ ".test-dist/tests/zip-safety.test.js", ".test-dist/tests/tts-engine.test.js", ".test-dist/tests/codex-familiars.test.js", + ".test-dist/tests/catalog-remote-seams.test.js", ".test-dist/tests/claude-memory.test.js", ".test-dist/tests/prompt-memory-extraction.test.js", ".test-dist/tests/familiaros-memory-search.test.js", diff --git a/apps/desktop/src/catalog-remote.ts b/apps/desktop/src/catalog-remote.ts new file mode 100644 index 00000000..567b0d44 --- /dev/null +++ b/apps/desktop/src/catalog-remote.ts @@ -0,0 +1,195 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { app } from "electron"; + +import { validateCatalogV2, validateCatalogV3Index, validateCatalogV3Page, validateCatalogV3SearchIndex, validateCatalogV3SearchPage, type CatalogPetV2, type CatalogV2, type CatalogV3Index, type CatalogV3SearchPet } from "./catalog-validation.js"; + +export const catalogUrl = "https://familiaros.dev/familiars/catalog.v2.json"; +export const catalogV3Url = "https://familiaros.dev/familiars/catalog.v3.json"; + +type CatalogLoadResult = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: string }; + +const fixtureRelativePath = "catalog.v2.fixture.json"; +const maxCatalogBytes = 1_000_000; +const maxCatalogV3PageBytes = 256_000; +const fetchTimeoutMs = 5_000; + +const v3PageCache = new Map(); +let v3IndexPromise: Promise | null = null; +let v3SearchPromise: Promise | null = null; +let v2CatalogPromise: Promise | null = null; + +export async function tryLoadRemoteCatalogV3Index(): Promise> { + try { + return { ok: true, value: await getRemoteCatalogV3Index() }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : "unknown error" }; + } +} + +export async function tryLoadRemoteCatalogV3Page(page: number, index: CatalogV3Index): Promise> { + try { + return { ok: true, value: await getRemoteCatalogV3Page(page, index) }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : "unknown error" }; + } +} + +export async function tryLoadRemoteCatalog(): Promise> { + try { + return { ok: true, value: await getRemoteCatalogV2() }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : "unknown error" }; + } +} + +export async function tryLoadFixtureCatalog(): Promise> { + try { + return { ok: true, value: validateCatalogV2(await loadFixtureCatalog()) }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : "unknown error" }; + } +} + +export async function getRemoteCatalogV3Index(): Promise { + v3IndexPromise ||= Promise.resolve().then(async () => validateCatalogV3Index(JSON.parse(await fetchLimitedText(catalogV3Url, maxCatalogV3PageBytes)) as unknown)); + return await v3IndexPromise; +} + +export async function getRemoteCatalogV3Page(page: number, index: CatalogV3Index): Promise { + const cached = v3PageCache.get(page); + if (cached) return cached; + const pageUrl = index.pages[page]; + if (!pageUrl) throw new Error("Catalog page is out of range."); + const payload = validateCatalogV3Page(JSON.parse(await fetchLimitedText(pageUrl, maxCatalogV3PageBytes)) as unknown, page); + const familiars = payload.familiars.map(toCatalogPetV2Compat); + assertUniquePetIds(familiars); + v3PageCache.set(page, familiars); + return familiars; +} + +export async function getRemoteCatalogV3Search(index: CatalogV3Index): Promise { + v3SearchPromise ||= Promise.resolve().then(async () => { + const searchIndex = validateCatalogV3SearchIndex(JSON.parse(await fetchLimitedText(index.search, maxCatalogV3PageBytes)) as unknown); + const pages = await Promise.all( + searchIndex.pages.map(async (pageUrl, page) => + validateCatalogV3SearchPage(JSON.parse(await fetchLimitedText(pageUrl, maxCatalogV3PageBytes)) as unknown, page, index.pages.length), + ), + ); + const familiars = pages.flatMap((page) => page.familiars); + if (familiars.length !== index.total) throw new Error("Catalog v3 search total does not match index total."); + return familiars; + }); + return await v3SearchPromise; +} + +export async function getV2CatalogOrFixture(): Promise { + const remote = await tryLoadRemoteCatalog(); + if (remote.ok) return remote.value; + const fixture = await tryLoadFixtureCatalog(); + if (fixture.ok) return fixture.value; + throw new Error(`Catalog unavailable: ${remote.error}. Fixture unavailable: ${fixture.error}`); +} + +function toCatalogPetV2Compat(familiar: { + readonly id: string; + readonly displayName: string; + readonly description: string; + readonly thumbnail: string; + readonly spritesheet: string; + readonly zip: string; + readonly category: "western" | "asian"; + readonly subcategory?: string; + readonly original?: boolean; + readonly featured?: boolean; +}): CatalogPetV2 { + const entry: CatalogPetV2 = { + id: familiar.id, + displayName: familiar.displayName, + description: familiar.description, + preview: familiar.thumbnail, + spritesheet: familiar.spritesheet, + zip: familiar.zip, + category: familiar.category, + }; + return { + ...entry, + ...(familiar.subcategory ? { subcategory: familiar.subcategory } : {}), + ...(familiar.original === undefined ? {} : { original: familiar.original }), + ...(familiar.featured === undefined ? {} : { featured: familiar.featured }), + }; +} + +async function getRemoteCatalogV2(): Promise { + v2CatalogPromise ||= Promise.resolve().then(async () => validateCatalogV2(JSON.parse(await fetchLimitedText(catalogUrl, maxCatalogBytes)) as unknown)); + return await v2CatalogPromise; +} + +async function fetchLimitedText(url: string, maxBytes: number): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), fetchTimeoutMs); + + try { + const response = await fetch(url, { + signal: controller.signal, + redirect: "error", + credentials: "omit", + }); + + validateCatalogEndpoint(response.url, url); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + return await readLimitedResponse(response, maxBytes); + } finally { + clearTimeout(timeout); + } +} + +async function loadFixtureCatalog(): Promise { + const fixturePath = join(app.getAppPath(), fixtureRelativePath); + return JSON.parse(await readFile(fixturePath, "utf8")) as unknown; +} + +async function readLimitedResponse(response: Response, maxBytes: number): Promise { + const reader = response.body?.getReader(); + if (!reader) throw new Error("Catalog response body is unavailable for bounded reading."); + + const chunks: Uint8Array[] = []; + let total = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) throw new Error("Catalog response is too large."); + chunks.push(value); + } + + return new TextDecoder().decode(concatChunks(chunks, total)); +} + +function concatChunks(chunks: readonly Uint8Array[], total: number): Uint8Array { + const output = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.byteLength; + } + return output; +} + +function validateCatalogEndpoint(value: string, expected: string): void { + const url = new URL(value); + if (url.href !== expected) throw new Error("Catalog final URL is not allowed."); +} + +function assertUniquePetIds(familiars: readonly CatalogPetV2[]): void { + const ids = new Set(); + for (const familiar of familiars) { + if (ids.has(familiar.id)) throw new Error(`Duplicate catalog v3 familiar id: ${familiar.id}`); + ids.add(familiar.id); + } +} diff --git a/apps/desktop/src/catalog.ts b/apps/desktop/src/catalog.ts index 48553dbb..90d09fae 100644 --- a/apps/desktop/src/catalog.ts +++ b/apps/desktop/src/catalog.ts @@ -1,17 +1,8 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; - -import { app } from "electron"; - import { filterSurfaceablePets, isSurfaceablePet, summarizeSurfaceablePets } from "./catalog-surfaceable.js"; -import { validateCatalogV2, validateCatalogV3Index, validateCatalogV3Page, validateCatalogV3SearchIndex, validateCatalogV3SearchPage, type CatalogPetV2, type CatalogV2, type CatalogV3Index, type CatalogV3SearchPet } from "./catalog-validation.js"; +import { getRemoteCatalogV3Page, getRemoteCatalogV3Search, getV2CatalogOrFixture, tryLoadFixtureCatalog, tryLoadRemoteCatalog, tryLoadRemoteCatalogV3Index } from "./catalog-remote.js"; +import type { CatalogPetV2, CatalogV3Index, CatalogV3SearchPet } from "./catalog-validation.js"; -export const catalogUrl = "https://familiaros.dev/familiars/catalog.v2.json"; -export const catalogV3Url = "https://familiaros.dev/familiars/catalog.v3.json"; -const fixtureRelativePath = "catalog.v2.fixture.json"; -const maxCatalogBytes = 1_000_000; -const maxCatalogV3PageBytes = 256_000; -const fetchTimeoutMs = 5_000; +export { catalogUrl, catalogV3Url } from "./catalog-remote.js"; export interface CatalogUiState { readonly source: "remote" | "fixture" | "error"; @@ -35,30 +26,25 @@ export interface CatalogSearchUiState { readonly error?: string; } -const v3PageCache = new Map(); -let v3IndexPromise: Promise | null = null; -let v3SearchPromise: Promise | null = null; -let v2CatalogPromise: Promise | null = null; - export async function getCatalogUiState(): Promise { const remoteV3 = await tryLoadRemoteCatalogV3Index(); if (remoteV3.ok) { - const firstPage = await tryLoadSurfaceableCatalogV3Page(0, remoteV3.index); + const firstPage = await tryLoadSurfaceableCatalogV3Page(0, remoteV3.value); if (!firstPage.ok) return await getV2OrFixtureCatalogUiState(`v3 page unavailable: ${firstPage.error}`); - const surfaceableStats = summarizeSurfaceablePets(await getRemoteCatalogV3Search(remoteV3.index), remoteV3.index.pageSize); + const surfaceableStats = summarizeSurfaceablePets(await getRemoteCatalogV3Search(remoteV3.value), remoteV3.value.pageSize); return { source: "remote", familiars: filterSurfaceablePets(firstPage.familiars), - generatedAt: remoteV3.index.generatedAt, + generatedAt: remoteV3.value.generatedAt, version: 3, total: surfaceableStats.total, - categories: remoteV3.index.filters.categories, + categories: remoteV3.value.filters.categories, page: 0, pageCount: surfaceableStats.pageCount, supportsCategories: true, - originalsCount: remoteV3.index.filters.originalsCount, - featuredCount: remoteV3.index.filters.featuredCount, + originalsCount: remoteV3.value.filters.originalsCount, + featuredCount: remoteV3.value.filters.featuredCount, }; } @@ -69,23 +55,23 @@ export async function getCatalogPageUiState(page: number): Promise= surfaceableStats.pageCount) throw new Error("Catalog page is out of range."); - const pageResult = await tryLoadSurfaceableCatalogV3Page(page, remoteV3.index); + const pageResult = await tryLoadSurfaceableCatalogV3Page(page, remoteV3.value); if (!pageResult.ok) return { source: "error", familiars: [], error: pageResult.error }; return { source: "remote", familiars: filterSurfaceablePets(pageResult.familiars), - generatedAt: remoteV3.index.generatedAt, + generatedAt: remoteV3.value.generatedAt, version: 3, total: surfaceableStats.total, - categories: remoteV3.index.filters.categories, + categories: remoteV3.value.filters.categories, page, pageCount: surfaceableStats.pageCount, supportsCategories: true, - originalsCount: remoteV3.index.filters.originalsCount, - featuredCount: remoteV3.index.filters.featuredCount, + originalsCount: remoteV3.value.filters.originalsCount, + featuredCount: remoteV3.value.filters.featuredCount, }; } @@ -94,7 +80,7 @@ export async function getCatalogSearchUiState(): Promise { if (!remoteV3.ok) return { source: "error", familiars: [], error: remoteV3.error }; try { - const surfacedPets = getSurfaceableSearchPets(await getRemoteCatalogV3Search(remoteV3.index), remoteV3.index); + const surfacedPets = getSurfaceableSearchPets(await getRemoteCatalogV3Search(remoteV3.value), remoteV3.value); return { source: "remote", familiars: surfacedPets, total: surfacedPets.length }; } catch (error) { return { source: "error", familiars: [], error: error instanceof Error ? error.message : "unknown error" }; @@ -106,14 +92,14 @@ export async function getCatalogPet(petId: string): Promise { if (remoteV3.ok) { let blockedHiddenV3Pet = false; try { - const searchPets = await getRemoteCatalogV3Search(remoteV3.index); + const searchPets = await getRemoteCatalogV3Search(remoteV3.value); const searchPet = searchPets.find((familiar) => familiar.id === petId); if (searchPet && !isSurfaceablePet(searchPet)) { blockedHiddenV3Pet = true; throw new Error(`Familiar is not available in the curated catalog: ${petId}`); } if (searchPet) { - const page = await getRemoteCatalogV3Page(searchPet.catalogPage, remoteV3.index); + const page = await getRemoteCatalogV3Page(searchPet.catalogPage, remoteV3.value); const familiar = page.find((candidate) => candidate.id === petId); if (familiar && isSurfaceablePet(familiar)) return familiar; } @@ -135,10 +121,10 @@ async function getV2OrFixtureCatalogUiState(remoteV3Error: string): Promise { - try { - const index = await getRemoteCatalogV3Index(); - return { ok: true, index }; - } catch (error) { - return { ok: false, error: error instanceof Error ? error.message : "unknown error" }; - } -} - -async function tryLoadRemoteCatalogV3Page(page: number, index: CatalogV3Index): Promise<{ readonly ok: true; readonly familiars: readonly CatalogPetV2[] } | { readonly ok: false; readonly error: string }> { - try { - return { ok: true, familiars: await getRemoteCatalogV3Page(page, index) }; - } catch (error) { - return { ok: false, error: error instanceof Error ? error.message : "unknown error" }; - } -} - async function tryLoadSurfaceableCatalogV3Page(page: number, index: CatalogV3Index): Promise<{ readonly ok: true; readonly familiars: readonly CatalogPetV2[] } | { readonly ok: false; readonly error: string }> { try { const searchPets = filterSurfaceablePets(await getRemoteCatalogV3Search(index)); @@ -201,144 +170,3 @@ async function tryLoadSurfaceableCatalogV3Page(page: number, index: CatalogV3Ind return { ok: false, error: error instanceof Error ? error.message : "unknown error" }; } } - -async function getRemoteCatalogV3Index(): Promise { - v3IndexPromise ||= Promise.resolve().then(async () => validateCatalogV3Index(JSON.parse(await fetchLimitedText(catalogV3Url, maxCatalogV3PageBytes)) as unknown)); - return await v3IndexPromise; -} - -async function getRemoteCatalogV3Page(page: number, index: CatalogV3Index): Promise { - const cached = v3PageCache.get(page); - if (cached) return cached; - const pageUrl = index.pages[page]; - if (!pageUrl) throw new Error("Catalog page is out of range."); - const payload = validateCatalogV3Page(JSON.parse(await fetchLimitedText(pageUrl, maxCatalogV3PageBytes)) as unknown, page); - const familiars = payload.familiars.map(toCatalogPetV2Compat); - assertUniquePetIds(familiars); - v3PageCache.set(page, familiars); - return familiars; -} - -async function getRemoteCatalogV3Search(index: CatalogV3Index): Promise { - v3SearchPromise ||= Promise.resolve().then(async () => { - const searchIndex = validateCatalogV3SearchIndex(JSON.parse(await fetchLimitedText(index.search, maxCatalogV3PageBytes)) as unknown); - const pages = await Promise.all(searchIndex.pages.map(async (pageUrl, page) => validateCatalogV3SearchPage(JSON.parse(await fetchLimitedText(pageUrl, maxCatalogV3PageBytes)) as unknown, page, index.pages.length))); - const familiars = pages.flatMap((page) => page.familiars); - if (familiars.length !== index.total) throw new Error("Catalog v3 search total does not match index total."); - return familiars; - }); - return await v3SearchPromise; -} - -function toCatalogPetV2Compat(familiar: { readonly id: string; readonly displayName: string; readonly description: string; readonly thumbnail: string; readonly spritesheet: string; readonly zip: string; readonly category: "western" | "asian"; readonly subcategory?: string; readonly original?: boolean; readonly featured?: boolean }): CatalogPetV2 { - const entry: CatalogPetV2 = { - id: familiar.id, - displayName: familiar.displayName, - description: familiar.description, - preview: familiar.thumbnail, - spritesheet: familiar.spritesheet, - zip: familiar.zip, - category: familiar.category, - }; - return { - ...entry, - ...(familiar.subcategory ? { subcategory: familiar.subcategory } : {}), - ...(familiar.original === undefined ? {} : { original: familiar.original }), - ...(familiar.featured === undefined ? {} : { featured: familiar.featured }), - }; -} - -async function tryLoadRemoteCatalog(): Promise<{ readonly ok: true; readonly catalog: CatalogV2 } | { readonly ok: false; readonly error: string }> { - try { - return { ok: true, catalog: await getRemoteCatalogV2() }; - } catch (error) { - return { ok: false, error: error instanceof Error ? error.message : "unknown error" }; - } -} - -async function getRemoteCatalogV2(): Promise { - v2CatalogPromise ||= Promise.resolve().then(async () => validateCatalogV2(JSON.parse(await fetchLimitedText(catalogUrl, maxCatalogBytes)) as unknown)); - return await v2CatalogPromise; -} - -async function getV2CatalogOrFixture(): Promise { - const remote = await tryLoadRemoteCatalog(); - if (remote.ok) return remote.catalog; - const fixture = await tryLoadFixtureCatalog(); - if (fixture.ok) return fixture.catalog; - throw new Error(`Catalog unavailable: ${remote.error}. Fixture unavailable: ${fixture.error}`); -} - -async function fetchLimitedText(url: string, maxBytes: number): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), fetchTimeoutMs); - - try { - const response = await fetch(url, { - signal: controller.signal, - redirect: "error", - credentials: "omit", - }); - - validateCatalogEndpoint(response.url, url); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - - return await readLimitedResponse(response, maxBytes); - } finally { - clearTimeout(timeout); - } -} - -async function tryLoadFixtureCatalog(): Promise<{ readonly ok: true; readonly catalog: CatalogV2 } | { readonly ok: false; readonly error: string }> { - try { - return { ok: true, catalog: validateCatalogV2(await loadFixtureCatalog()) }; - } catch (error) { - return { ok: false, error: error instanceof Error ? error.message : "unknown error" }; - } -} - -async function loadFixtureCatalog(): Promise { - const fixturePath = join(app.getAppPath(), fixtureRelativePath); - return JSON.parse(await readFile(fixturePath, "utf8")) as unknown; -} - -async function readLimitedResponse(response: Response, maxBytes: number): Promise { - const reader = response.body?.getReader(); - if (!reader) throw new Error("Catalog response body is unavailable for bounded reading."); - - const chunks: Uint8Array[] = []; - let total = 0; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - total += value.byteLength; - if (total > maxBytes) throw new Error("Catalog response is too large."); - chunks.push(value); - } - - return new TextDecoder().decode(concatChunks(chunks, total)); -} - -function concatChunks(chunks: readonly Uint8Array[], total: number): Uint8Array { - const output = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - output.set(chunk, offset); - offset += chunk.byteLength; - } - return output; -} - -function validateCatalogEndpoint(value: string, expected: string): void { - const url = new URL(value); - if (url.href !== expected) throw new Error("Catalog final URL is not allowed."); -} - -function assertUniquePetIds(familiars: readonly CatalogPetV2[]): void { - const ids = new Set(); - for (const familiar of familiars) { - if (ids.has(familiar.id)) throw new Error(`Duplicate catalog v3 familiar id: ${familiar.id}`); - ids.add(familiar.id); - } -} diff --git a/apps/desktop/src/check-packaging-contract.ts b/apps/desktop/src/check-packaging-contract.ts index 84ed0612..366b18b8 100644 --- a/apps/desktop/src/check-packaging-contract.ts +++ b/apps/desktop/src/check-packaging-contract.ts @@ -72,6 +72,8 @@ const petWindowSource = readFileSync(join(appDir, "src", "familiar-window.ts"), const petWindowHostSource = readFileSync(join(appDir, "src", "familiar-window-host.ts"), "utf8"); const familiarInstallationSource = readFileSync(join(appDir, "src", "familiar-installation.ts"), "utf8"); const familiarInstallationArchiveSource = readFileSync(join(appDir, "src", "familiar-installation-archive.ts"), "utf8"); +const catalogSource = readFileSync(join(appDir, "src", "catalog.ts"), "utf8"); +const catalogRemoteSource = readFileSync(join(appDir, "src", "catalog-remote.ts"), "utf8"); const petWindowContentSource = readFileSync(join(appDir, "src", "familiar-window-content.ts"), "utf8"); const petWindowInteractionSource = readFileSync(join(appDir, "src", "familiar-window-interactions.ts"), "utf8"); const petWindowMouseInteropSource = readFileSync(join(appDir, "src", "familiar-window-mouse-interop.ts"), "utf8"); @@ -353,6 +355,16 @@ assert.match(familiarInstallationArchiveSource, /redirect:\s*"error"/, "familiar assert.match(familiarInstallationArchiveSource, /validateZipUrl\(response\.url\)/, "familiar-installation archive downloads must re-validate the final URL."); assert.match(familiarInstallationArchiveSource, /strictFileNames:\s*true/, "familiar-installation archive extraction must keep yauzl strict file-name validation."); assert.match(familiarInstallationArchiveSource, /safePath\.relativeOutputPath === "spritesheet\.webp"[\s\S]*?maxCodexSpritesheetBytes/, "familiar-installation archive extraction must cap spritesheets at the codex size limit."); +assert.match(catalogSource, /from "\.\/catalog-remote(?:\.js)?"/, "catalog must import the extracted remote helper seam."); +assert.match(catalogSource, /export \{ catalogUrl, catalogV3Url \} from "\.\/catalog-remote(?:\.js)?"/, "catalog must re-export catalog URLs through the extracted remote seam."); +assert.match(catalogRemoteSource, /export async function tryLoadRemoteCatalogV3Index/, "catalog remote seam must export the v3 index loader."); +assert.match(catalogRemoteSource, /export async function tryLoadRemoteCatalogV3Page/, "catalog remote seam must export the v3 page loader."); +assert.match(catalogRemoteSource, /export async function tryLoadRemoteCatalog/, "catalog remote seam must export the v2 remote loader."); +assert.match(catalogRemoteSource, /export async function tryLoadFixtureCatalog/, "catalog remote seam must export the fixture loader."); +assert.match(catalogRemoteSource, /export async function getRemoteCatalogV3Index/, "catalog remote seam must export the v3 index fetch/cache helper."); +assert.match(catalogRemoteSource, /export async function getRemoteCatalogV3Page/, "catalog remote seam must export the v3 page fetch/cache helper."); +assert.match(catalogRemoteSource, /export async function getRemoteCatalogV3Search/, "catalog remote seam must export the v3 search fetch/cache helper."); +assert.match(catalogRemoteSource, /export async function getV2CatalogOrFixture/, "catalog remote seam must export the v2/fixture fallback helper."); assert.match(petWindowContentSource, /export async function createDefaultPetRender/, "familiar-window content helper must export default familiar rendering."); assert.match(petWindowContentSource, /export async function createInstalledPetRender/, "familiar-window content helper must export installed familiar rendering."); assert.match(petWindowLayoutSource, /export function getBasePetWindowSize/, "familiar-window-layout must export base window sizing."); diff --git a/apps/desktop/src/codemap.md b/apps/desktop/src/codemap.md index e3af934d..28bdb8bd 100644 --- a/apps/desktop/src/codemap.md +++ b/apps/desktop/src/codemap.md @@ -285,7 +285,8 @@ plugin-service-local-support.ts → plugin-local-loader.ts validates selected fo - `familiar-paths.ts`: Safe path resolution for familiar directories - `codex-familiars.ts`: Import from `~/.codex/familiars/` with validation - `codex-familiars-core.ts`: Codex metadata validation constants -- `catalog.ts`: Remote catalog fetch with V3 pagination support, search, fixture fallback +- `catalog.ts`: Catalog UI-state assembly, v3 surfaceability paging, pet lookup, and fixture fallback presentation. +- `catalog-remote.ts`: Extracted remote catalog v2/v3 fetch/cache plumbing, fixture loading, and bounded response validation. - `catalog-validation.ts`: CatalogV2/V3 schema validation - `zip-safety.ts`: ZIP entry path validation (traversal prevention, case collision detection) diff --git a/apps/desktop/tests/catalog-remote-seams.test.ts b/apps/desktop/tests/catalog-remote-seams.test.ts new file mode 100644 index 00000000..5c0ed9be --- /dev/null +++ b/apps/desktop/tests/catalog-remote-seams.test.ts @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const desktopRoot = process.env.FAMILIAROS_DESKTOP_ROOT ?? resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const catalogSource = readFileSync(resolve(desktopRoot, "src/catalog.ts"), "utf8"); +const catalogRemoteSource = readFileSync(resolve(desktopRoot, "src/catalog-remote.ts"), "utf8"); + +assert.match(catalogSource, /from "\.\/catalog-remote(?:\.js)?"/, "catalog must import the extracted remote helper seam."); +assert.match(catalogSource, /export \{ catalogUrl, catalogV3Url \} from "\.\/catalog-remote(?:\.js)?"/, "catalog must re-export catalog URLs through the extracted remote seam."); +assert.match(catalogSource, /getRemoteCatalogV3Page/, "catalog must delegate v3 page reads through the extracted remote seam."); +assert.match(catalogSource, /getRemoteCatalogV3Search/, "catalog must delegate v3 search reads through the extracted remote seam."); +assert.match(catalogSource, /tryLoadRemoteCatalogV3Index/, "catalog must delegate v3 index reads through the extracted remote seam."); +assert.match(catalogSource, /tryLoadRemoteCatalog/, "catalog must delegate v2 remote reads through the extracted remote seam."); +assert.match(catalogSource, /tryLoadFixtureCatalog/, "catalog must delegate fixture fallback reads through the extracted remote seam."); +assert.match(catalogSource, /getV2CatalogOrFixture/, "catalog must delegate v2-or-fixture fallback through the extracted remote seam."); + +assert.match(catalogRemoteSource, /export const catalogUrl/, "catalog remote seam must export the v2 catalog URL."); +assert.match(catalogRemoteSource, /export const catalogV3Url/, "catalog remote seam must export the v3 catalog URL."); +assert.match(catalogRemoteSource, /export async function tryLoadRemoteCatalogV3Index/, "catalog remote seam must export the v3 index loader."); +assert.match(catalogRemoteSource, /export async function tryLoadRemoteCatalogV3Page/, "catalog remote seam must export the v3 page loader."); +assert.match(catalogRemoteSource, /export async function tryLoadRemoteCatalog/, "catalog remote seam must export the v2 remote loader."); +assert.match(catalogRemoteSource, /export async function tryLoadFixtureCatalog/, "catalog remote seam must export the fixture loader."); +assert.match(catalogRemoteSource, /export async function getRemoteCatalogV3Index/, "catalog remote seam must export the v3 index fetch/cache helper."); +assert.match(catalogRemoteSource, /export async function getRemoteCatalogV3Page/, "catalog remote seam must export the v3 page fetch/cache helper."); +assert.match(catalogRemoteSource, /export async function getRemoteCatalogV3Search/, "catalog remote seam must export the v3 search fetch/cache helper."); +assert.match(catalogRemoteSource, /export async function getV2CatalogOrFixture/, "catalog remote seam must export the v2/fixture fallback helper."); + +console.error("Catalog remote seam validation passed.");