From 4c53d7ed70ba521a2e4a580a6a21ea04054926bf Mon Sep 17 00:00:00 2001 From: Sy Bohy Date: Sat, 17 Oct 2026 04:05:34 -0700 Subject: [PATCH] fix: Load SupportedDevices from Ecosystem device model summaries SupportedDevices now reads GET /v1/device_models/summaries/list (seamapi/ecosystem#69, DEVDB-437): the same models, filters and caller rules as device_models/list in a slim shape without commerce data. With brotli, the Yale list goes from 384 kB to 214 kB on the wire (5.5 MB to 2.5 MB of JSON) and Schlage from 88 kB to 54 kB. The component's attributes are unchanged. useEcosystemDeviceModels still returns the full device model shape. The fake Ecosystem API serves the summaries route, validates an embed key when one is sent, defaults manufacturer to a manufacturer key's own and returns manufacturer_context from embed/resolve, like Ecosystem now does. Tests serve the fake in-process instead of on a local port, which could collide with the fake Devicedb server's port. Refs DEVDB-445, DEVDB-437 Co-Authored-By: Claude Opus 5.5 --- .storybook/fake-ecosystem.js | 200 +++++++++++++----- .../SupportedDevices/DeviceModelRow.tsx | 7 +- .../SupportedDevices.test.tsx | 7 + .../SupportedDevices/SupportedDevices.tsx | 4 +- .../SupportedDevices/device-models.test.ts | 13 +- .../SupportedDevices/device-models.ts | 36 ++-- src/lib/seam/ecosystem/types.ts | 80 ++++++- .../use-ecosystem-device-model-summaries.ts | 89 ++++++++ test/fixtures/ecosystem.tsx | 53 ++++- 9 files changed, 392 insertions(+), 97 deletions(-) create mode 100644 src/lib/seam/ecosystem/use-ecosystem-device-model-summaries.ts diff --git a/.storybook/fake-ecosystem.js b/.storybook/fake-ecosystem.js index 52c4e31f0..0e1651cea 100644 --- a/.storybook/fake-ecosystem.js +++ b/.storybook/fake-ecosystem.js @@ -5,7 +5,9 @@ import { createServer } from 'node:http' // It mirrors the routes, parameters, errors and declared-first support resolution of // seamapi/ecosystem, with fictional partners and a small set of device models. // Unlike the real API, /v1/compatibility always requires an embed key here, -// as if every caller were cross-origin. +// as if every caller were cross-origin. Other public reads validate an embed key +// only when one is sent (Ecosystem's `auth: "optional"`), and a manufacturer key +// defaults `manufacturer` to its own (DEVDB-439). export const fakeEmbedKey = 'eco_live_storybook' export const fakePartnerEmbedKey = 'eco_live_storybook_partner' @@ -1162,6 +1164,17 @@ const requireEmbedKey = ({ headers, searchParams }) => { return { key, ...embedKey } } +// Ecosystem's `auth: "optional"`: an embed key is validated only when one is sent. +const optionalEmbedKey = ({ headers, searchParams }) => { + if (searchParams.get('embed_key') == null && headers['x-embed-key'] == null) { + return null + } + return requireEmbedKey({ headers, searchParams }) +} + +const manufacturerContextOf = (embedKey) => + embedKey?.owner_kind === 'manufacturer' ? embedKey.manufacturer_slug : null + const capabilityNames = new Set(Object.values(capabilitiesByDeviceType).flat()) const paginate = (items, searchParams) => { @@ -1191,9 +1204,120 @@ const listParam = (searchParams, key) => .map((v) => v.trim()) .filter((v) => v.length > 0) +const listDeviceModels = ({ searchParams, headers, assetBaseUrl }) => { + const embedKey = optionalEmbedKey({ headers, searchParams }) + const capability = searchParams.get('capability') + if (capability != null && !capabilityNames.has(capability)) { + throw new FakeApiError(400, 'invalid_params', 'Invalid request parameters') + } + const slugs = listParam(searchParams, 'slugs') + const manufacturer = + searchParams.get('manufacturer') ?? manufacturerContextOf(embedKey) + const deviceType = searchParams.get('device_type') + const provider = searchParams.get('provider') + const supported = searchParams.get('supported') + const isAccessory = searchParams.get('is_accessory') + const models = deviceModels + .map((row) => toDeviceModel(row, assetBaseUrl)) + // include_forbidden only unlocks forbidden models for Seam and manufacturer + // secret keys, which the fake does not model. + .filter((m) => m.is_forbidden_to_3dparty !== true) + .filter((m) => slugs.length === 0 || slugs.includes(m.slug)) + .filter( + (m) => manufacturer == null || m.manufacturer?.slug === manufacturer + ) + .filter((m) => deviceType == null || m.device_type?.slug === deviceType) + .filter( + (m) => provider == null || m.providers.some((p) => p.slug === provider) + ) + .filter( + (m) => + isAccessory == null || + String(m.device_type?.is_accessory ?? false) === isAccessory + ) + .filter( + (m) => + capability == null || + m.capabilities_by_provider.some(({ capabilities }) => + capabilities.some( + (c) => + c.name === capability && + (supported == null || c.supported === supported) + ) + ) + ) + return paginate(models, searchParams) +} + +// The slim `device_models/summaries/list` shape (DEVDB-437). +const toDeviceImage = (asset) => + asset == null + ? null + : { + url: asset.url, + mime: asset.mime, + width: asset.width, + height: asset.height, + formats: Object.fromEntries( + Object.entries(asset.formats).map(([name, format]) => [ + name, + { url: format.url, width: format.width, height: format.height }, + ]) + ), + } + +const toDeviceModelSummary = (model, { commerce = false } = {}) => ({ + device_model_id: model.device_model_id, + slug: model.slug, + display_name: model.display_name, + description_md: model.description_md, + product_url: model.product_url, + no_longer_sold: model.no_longer_sold, + is_forbidden_to_3dparty: model.is_forbidden_to_3dparty, + manufacturer: model.manufacturer, + device_type: model.device_type, + device_type_properties: model.device_type_properties, + featured_image: toDeviceImage(model.featured_image), + variants: model.variants.map((variant) => ({ + device_model_variant_id: variant.device_model_variant_id, + color_name: variant.color_name, + style_name: variant.style_name, + primary_color_hex: variant.primary_color_hex, + manufacturer_model_id: variant.manufacturer_model_id, + images: variant.images.map(toDeviceImage), + ...(commerce + ? { + asin: variant.asin, + gtins: variant.gtins, + marketplace_listings: variant.marketplace_listings, + } + : {}), + })), + providers: model.providers, + capabilities_by_provider: model.capabilities_by_provider.map( + ({ provider, capabilities }) => ({ + provider, + capabilities: capabilities.map((c) => ({ + name: c.name, + supported: c.supported, + integration_support_level: c.integration_support_level, + report: c.report, + })), + }) + ), + capabilities_stale: model.capabilities_stale, + ...(commerce + ? { + shop_listings: model.shop_listings, + last_commerce_search_at: model.last_commerce_search_at, + } + : {}), + updated_at: model.updated_at, +}) + const routes = { 'v1/compatibility': ({ searchParams, headers, assetBaseUrl }) => { - requireEmbedKey({ headers, searchParams }) + const embedKey = requireEmbedKey({ headers, searchParams }) const category = searchParams.get('device_category') ?? '' if (!/^[a-z0-9_]+$/.test(category)) { throw new FakeApiError( @@ -1204,7 +1328,10 @@ const routes = { } const query = { device_category: category, - manufacturer_slug: searchParams.get('manufacturer_slug') ?? '', + manufacturer_slug: + searchParams.get('manufacturer_slug') ?? + manufacturerContextOf(embedKey) ?? + '', provider_slug: searchParams.get('provider_slug') ?? '', capability: searchParams.get('capability') ?? '', } @@ -1234,54 +1361,24 @@ const routes = { } }, - 'v1/device_models/list': ({ searchParams, assetBaseUrl }) => { - const capability = searchParams.get('capability') - if (capability != null && !capabilityNames.has(capability)) { - throw new FakeApiError( - 400, - 'invalid_params', - 'Invalid request parameters' - ) - } - const slugs = listParam(searchParams, 'slugs') - const manufacturer = searchParams.get('manufacturer') - const deviceType = searchParams.get('device_type') - const provider = searchParams.get('provider') - const supported = searchParams.get('supported') - const includeForbidden = searchParams.get('include_forbidden') === 'true' - const isAccessory = searchParams.get('is_accessory') - const models = deviceModels - .map((row) => toDeviceModel(row, assetBaseUrl)) - .filter((m) => includeForbidden || m.is_forbidden_to_3dparty !== true) - .filter((m) => slugs.length === 0 || slugs.includes(m.slug)) - .filter( - (m) => manufacturer == null || m.manufacturer?.slug === manufacturer - ) - .filter((m) => deviceType == null || m.device_type?.slug === deviceType) - .filter( - (m) => provider == null || m.providers.some((p) => p.slug === provider) - ) - .filter( - (m) => - isAccessory == null || - String(m.device_type?.is_accessory ?? false) === isAccessory - ) - .filter( - (m) => - capability == null || - m.capabilities_by_provider.some(({ capabilities }) => - capabilities.some( - (c) => - c.name === capability && - (supported == null || c.supported === supported) - ) - ) - ) - const { items, pagination } = paginate(models, searchParams) + 'v1/device_models/list': (request) => { + const { items, pagination } = listDeviceModels(request) return { device_models: items, pagination } }, - 'v1/manufacturers/list': ({ searchParams, assetBaseUrl }) => { + 'v1/device_models/summaries/list': (request) => { + const { items, pagination } = listDeviceModels(request) + const include = listParam(request.searchParams, 'include') + return { + device_models: items.map((m) => + toDeviceModelSummary(m, { commerce: include.includes('commerce') }) + ), + pagination, + } + }, + + 'v1/manufacturers/list': ({ searchParams, headers, assetBaseUrl }) => { + optionalEmbedKey({ headers, searchParams }) const slugs = listParam(searchParams, 'slugs') const provider = searchParams.get('provider') const list = manufacturers @@ -1292,7 +1389,8 @@ const routes = { return { manufacturers: items, pagination } }, - 'v1/manufacturers/get': ({ searchParams, assetBaseUrl }) => { + 'v1/manufacturers/get': ({ searchParams, headers, assetBaseUrl }) => { + optionalEmbedKey({ headers, searchParams }) const slug = searchParams.get('slug') const manufacturerId = searchParams.get('manufacturer_id') if (slug == null && manufacturerId == null) { @@ -1349,6 +1447,10 @@ const routes = { revoked_at: null, created_at: timestamp, }, + manufacturer_context: + manufacturerContextOf(embedKey) == null + ? null + : { manufacturer_slug: manufacturerContextOf(embedKey) }, exclusions, } }, diff --git a/src/lib/seam/components/SupportedDevices/DeviceModelRow.tsx b/src/lib/seam/components/SupportedDevices/DeviceModelRow.tsx index e3da18478..fd4d55750 100644 --- a/src/lib/seam/components/SupportedDevices/DeviceModelRow.tsx +++ b/src/lib/seam/components/SupportedDevices/DeviceModelRow.tsx @@ -6,10 +6,13 @@ import { getDeviceModelName, getSupportedCapabilities, } from 'lib/seam/components/SupportedDevices/device-models.js' -import type { DeviceModel, Manufacturer } from 'lib/seam/ecosystem/types.js' +import type { + DeviceModelSummary, + Manufacturer, +} from 'lib/seam/ecosystem/types.js' interface DeviceModelRowProps { - deviceModel: DeviceModel + deviceModel: DeviceModelSummary manufacturer: Manufacturer | null } diff --git a/src/lib/seam/components/SupportedDevices/SupportedDevices.test.tsx b/src/lib/seam/components/SupportedDevices/SupportedDevices.test.tsx index 19fffa7b2..0f8e4ccd1 100644 --- a/src/lib/seam/components/SupportedDevices/SupportedDevices.test.tsx +++ b/src/lib/seam/components/SupportedDevices/SupportedDevices.test.tsx @@ -41,6 +41,13 @@ test('SupportedDevices groups device models by manufacture screen.getByRole('button', { name: 'Show all 7 Schlage devices' }) }) +test('SupportedDevices loads the slim device model summaries', async (ctx) => { + renderWithEcosystem(, ctx) + await screen.findByText('Control Smart Lock') + expect(ctx.ecosystemRequests).toContain('GET v1/device_models/summaries/list') + expect(ctx.ecosystemRequests).not.toContain('GET v1/device_models/list') +}) + test('SupportedDevices shows one manufacturer', async (ctx) => { renderWithEcosystem(, ctx) await screen.findByText('Control Smart Lock') diff --git a/src/lib/seam/components/SupportedDevices/SupportedDevices.tsx b/src/lib/seam/components/SupportedDevices/SupportedDevices.tsx index da2beb87c..a1253e5bd 100644 --- a/src/lib/seam/components/SupportedDevices/SupportedDevices.tsx +++ b/src/lib/seam/components/SupportedDevices/SupportedDevices.tsx @@ -15,7 +15,7 @@ import { FilterSelect } from 'lib/seam/components/SupportedDevices/FilterSelect. import { ManufacturerSection } from 'lib/seam/components/SupportedDevices/ManufacturerSection.js' import { useEcosystemContext } from 'lib/seam/ecosystem/EcosystemProvider.js' import type { Manufacturer } from 'lib/seam/ecosystem/types.js' -import { useEcosystemDeviceModels } from 'lib/seam/ecosystem/use-ecosystem-device-models.js' +import { useEcosystemDeviceModelSummaries } from 'lib/seam/ecosystem/use-ecosystem-device-model-summaries.js' import { useEcosystemManufacturers } from 'lib/seam/ecosystem/use-ecosystem-manufacturers.js' import { getEmbedKeyManufacturer, @@ -82,7 +82,7 @@ export function SupportedDevices({ isError: isDeviceModelsError, error: deviceModelsError, refetch: refetchDeviceModels, - } = useEcosystemDeviceModels({ + } = useEcosystemDeviceModelSummaries({ manufacturer: scopeManufacturer ?? '', deviceType: fixedDeviceType ?? '', capability: diff --git a/src/lib/seam/components/SupportedDevices/device-models.test.ts b/src/lib/seam/components/SupportedDevices/device-models.test.ts index 8d8e1c3a5..bea8ed0d2 100644 --- a/src/lib/seam/components/SupportedDevices/device-models.test.ts +++ b/src/lib/seam/components/SupportedDevices/device-models.test.ts @@ -3,7 +3,7 @@ import { expect, test } from 'vitest' import type { CapabilityName, CapabilitySupported, - DeviceModel, + DeviceModelSummary, EmbedExclusions, } from 'lib/seam/ecosystem/types.js' @@ -33,7 +33,7 @@ const deviceModel = ({ string, Partial> > -}): DeviceModel => ({ +}): DeviceModelSummary => ({ device_model_id: slug, slug, display_name: `Model ${slug}`, @@ -41,8 +41,6 @@ const deviceModel = ({ product_url: null, is_forbidden_to_3dparty: false, no_longer_sold: false, - shop_listings: null, - last_commerce_search_at: null, manufacturer: manufacturer == null ? null @@ -62,23 +60,18 @@ const deviceModel = ({ ([provider, caps]) => ({ provider: { provider_id: provider, ...ref(provider) }, capabilities: Object.entries(caps).map(([name, supported]) => ({ - capability_id: `${provider}:${name}`, name: name as CapabilityName, supported, integration_support_level: 'stable', report: { details: `${name} via ${provider}` }, - report_raw: null, - locale: 'en', - computed_at: '2026-10-01T00:00:00.000Z', })), }) ), capabilities_stale: false, - created_at: '2026-10-01T00:00:00.000Z', updated_at: '2026-10-01T00:00:00.000Z', }) -const at = (list: DeviceModel[], index: number): DeviceModel => { +const at = (list: DeviceModelSummary[], index: number): DeviceModelSummary => { const deviceModel = list[index] if (deviceModel == null) throw new Error(`Missing device model ${index}`) return deviceModel diff --git a/src/lib/seam/components/SupportedDevices/device-models.ts b/src/lib/seam/components/SupportedDevices/device-models.ts index dccc960db..d253ae6cb 100644 --- a/src/lib/seam/components/SupportedDevices/device-models.ts +++ b/src/lib/seam/components/SupportedDevices/device-models.ts @@ -1,7 +1,7 @@ import type { - CapabilityDetail, CapabilityName, - DeviceModel, + CapabilitySummary, + DeviceModelSummary, EmbedExclusions, Manufacturer, ManufacturerRef, @@ -23,7 +23,7 @@ export interface SupportedCapability { export interface ManufacturerGroup { key: string manufacturer: ManufacturerRef | null - deviceModels: DeviceModel[] + deviceModels: DeviceModelSummary[] } export interface FilterOption { @@ -61,9 +61,9 @@ export const getCapabilityLabel = (name: string): string => * resolved capability. A model left with no provider is removed. */ export const applyEmbedExclusions = ( - deviceModels: DeviceModel[], + deviceModels: DeviceModelSummary[], exclusions: EmbedExclusions | null | undefined -): DeviceModel[] => { +): DeviceModelSummary[] => { if (exclusions == null) return deviceModels const excludedManufacturers = new Set(exclusions.excluded_manufacturers) @@ -71,7 +71,7 @@ export const applyEmbedExclusions = ( const unsupportedProviders = new Set(exclusions.unsupported_providers) const { capability } = exclusions - const result: DeviceModel[] = [] + const result: DeviceModelSummary[] = [] for (const deviceModel of deviceModels) { const manufacturerSlug = deviceModel.manufacturer?.slug ?? '' const deviceTypeSlug = deviceModel.device_type?.slug ?? '' @@ -123,7 +123,7 @@ export const applyEmbedExclusions = ( /** The best support for each capability across the model's providers. */ export const getSupportedCapabilities = ( - deviceModel: DeviceModel + deviceModel: DeviceModelSummary ): SupportedCapability[] => { const best = new Map() for (const { capabilities } of deviceModel.capabilities_by_provider) { @@ -145,15 +145,15 @@ export const getSupportedCapabilities = ( } export const supportsCapability = ( - deviceModel: DeviceModel, + deviceModel: DeviceModelSummary, capability: string ): boolean => getSupportedCapabilities(deviceModel).some((c) => c.name === capability) export const filterDeviceModels = ( - deviceModels: DeviceModel[], + deviceModels: DeviceModelSummary[], { manufacturer, deviceType, capability, search = '' }: DeviceModelFilters -): DeviceModel[] => { +): DeviceModelSummary[] => { const terms = search.toLowerCase().trim().split(/\s+/).filter(Boolean) return deviceModels.filter((deviceModel) => { if (manufacturer != null && deviceModel.manufacturer?.slug !== manufacturer) @@ -168,7 +168,7 @@ export const filterDeviceModels = ( }) } -const getSearchText = (deviceModel: DeviceModel): string => +const getSearchText = (deviceModel: DeviceModelSummary): string => [ deviceModel.display_name, deviceModel.slug, @@ -182,7 +182,7 @@ const getSearchText = (deviceModel: DeviceModel): string => .toLowerCase() export const groupDeviceModelsByManufacturer = ( - deviceModels: DeviceModel[] + deviceModels: DeviceModelSummary[] ): ManufacturerGroup[] => { const groups = new Map() for (const deviceModel of deviceModels) { @@ -210,7 +210,7 @@ export const groupDeviceModelsByManufacturer = ( } export const getManufacturerOptions = ( - deviceModels: DeviceModel[] + deviceModels: DeviceModelSummary[] ): FilterOption[] => uniqueOptions( deviceModels.map((m) => @@ -224,7 +224,7 @@ export const getManufacturerOptions = ( ) export const getDeviceTypeOptions = ( - deviceModels: DeviceModel[] + deviceModels: DeviceModelSummary[] ): FilterOption[] => uniqueOptions( deviceModels.map((m) => @@ -235,7 +235,7 @@ export const getDeviceTypeOptions = ( ) export const getCapabilityOptions = ( - deviceModels: DeviceModel[] + deviceModels: DeviceModelSummary[] ): FilterOption[] => { const names = new Set() for (const deviceModel of deviceModels) { @@ -248,7 +248,7 @@ export const getCapabilityOptions = ( .map((name) => ({ value: name, label: getCapabilityLabel(name) })) } -export const getDeviceModelName = (deviceModel: DeviceModel): string => +export const getDeviceModelName = (deviceModel: DeviceModelSummary): string => deviceModel.display_name ?? deviceModel.slug export const getManufacturerName = ( @@ -257,7 +257,7 @@ export const getManufacturerName = ( /** The best available image URL for a device model. */ export const getDeviceModelImageUrl = ( - deviceModel: DeviceModel, + deviceModel: DeviceModelSummary, manufacturer?: Manufacturer | null ): string | null => { const image = @@ -303,7 +303,7 @@ export const getManufacturerLogoUrl = ( } const toSupported = ( - capability: CapabilityDetail + capability: CapabilitySummary ): SupportedCapability['supported'] | null => { if (capability.supported === 'yes') return 'yes' if (capability.supported === 'conditionally') return 'conditionally' diff --git a/src/lib/seam/ecosystem/types.ts b/src/lib/seam/ecosystem/types.ts index fd5bad95c..0ef934d26 100644 --- a/src/lib/seam/ecosystem/types.ts +++ b/src/lib/seam/ecosystem/types.ts @@ -2,7 +2,7 @@ * Response types for the public Seam Ecosystem API (PartnerDB and DeviceDB). * * Hand-copied from the zod schemas in `seamapi/ecosystem` `public-types/` - * (origin/main 733edf3, 2026-10-16) to avoid a zod runtime dependency. + * (origin/main 055335c, 2026-10-17) to avoid a zod runtime dependency. * Fields stored verbatim from the source as JSON are typed as `unknown`. * Keep in sync with Ecosystem until it publishes its types as a package. */ @@ -112,18 +112,16 @@ export interface EmbedExclusions { capability_exclusions: EmbedCapabilityExclusion[] } -/** - * Manufacturer context for manufacturer-owned keys. - * Not returned by Ecosystem yet (DEVDB-439); read when present. - */ +/** The manufacturer a manufacturer-owned key scopes DeviceDB widgets to (DEVDB-439). */ export interface EmbedManufacturerContext { manufacturer_slug: string } export interface EmbedResolution { embed_key: EmbedKey - exclusions: EmbedExclusions + /** Set for manufacturer-owned keys; null for partner and Seam keys. */ manufacturer_context?: EmbedManufacturerContext | null + exclusions: EmbedExclusions } // Devices (DeviceDB) @@ -273,6 +271,71 @@ export interface DeviceModel { updated_at: string } +// Device model summaries (`device_models/summaries/list`, DEVDB-437): +// the same models in a slim shape, without storage internals or commerce data. + +export interface DeviceImageFormat { + url: string + width: number | null + height: number | null +} + +export interface DeviceImage { + url: string + mime: string | null + width: number | null + height: number | null + formats: Partial> +} + +export interface CapabilitySummary { + name: CapabilityName + supported: CapabilitySupported + integration_support_level: IntegrationSupportLevel | null + report: CapabilityReport | null +} + +export interface DeviceModelSummaryVariant { + device_model_variant_id: string + color_name: string | null + style_name: string | null + primary_color_hex: string | null + manufacturer_model_id: string | null + images: DeviceImage[] + /** Only with `include=commerce`. */ + asin?: string | null + /** Only with `include=commerce`. */ + gtins?: unknown + /** Only with `include=commerce`. */ + marketplace_listings?: unknown +} + +export interface DeviceModelSummary { + device_model_id: string + slug: string + display_name: string | null + description_md: string | null + product_url: string | null + no_longer_sold: boolean | null + is_forbidden_to_3dparty: boolean | null + manufacturer: ManufacturerRef | null + device_type: DeviceTypeRef | null + device_type_properties: unknown + featured_image: DeviceImage | null + variants: DeviceModelSummaryVariant[] + providers: ProviderRef[] + capabilities_by_provider: Array<{ + provider: ProviderRef + capabilities: CapabilitySummary[] + }> + capabilities_stale: boolean + /** Only with `include=commerce`. */ + shop_listings?: unknown + /** Only with `include=commerce`. */ + last_commerce_search_at?: string | null + updated_at: string +} + export interface ManufacturerDefaultDeviceImage { device_type: DeviceTypeRef asset: DeviceAsset @@ -318,6 +381,11 @@ export interface DeviceModelsListResponse { pagination: Pagination } +export interface DeviceModelSummariesListResponse { + device_models: DeviceModelSummary[] + pagination: Pagination +} + export interface ManufacturersListResponse { manufacturers: Manufacturer[] pagination: Pagination diff --git a/src/lib/seam/ecosystem/use-ecosystem-device-model-summaries.ts b/src/lib/seam/ecosystem/use-ecosystem-device-model-summaries.ts new file mode 100644 index 000000000..577b6cfb5 --- /dev/null +++ b/src/lib/seam/ecosystem/use-ecosystem-device-model-summaries.ts @@ -0,0 +1,89 @@ +import { useQuery } from '@tanstack/react-query' + +import { + type EcosystemApiError, + fetchEcosystem, +} from 'lib/seam/ecosystem/client.js' +import { useEcosystemContext } from 'lib/seam/ecosystem/EcosystemProvider.js' +import type { + DeviceModelSummariesListResponse, + DeviceModelSummary, +} from 'lib/seam/ecosystem/types.js' +import type { UseEcosystemDeviceModelsParams } from 'lib/seam/ecosystem/use-ecosystem-device-models.js' +import { + retryEcosystemQuery, + type UseEcosystemQueryResult, +} from 'lib/seam/ecosystem/use-ecosystem-query-result.js' + +export type UseEcosystemDeviceModelSummariesParams = + UseEcosystemDeviceModelsParams + +export type UseEcosystemDeviceModelSummariesData = DeviceModelSummary[] + +const pageSize = 500 +const maxPages = 20 + +/** + * Published device models in the slim summary shape from Ecosystem + * `GET /v1/device_models/summaries/list` (no commerce data), + * following pagination to return every matching model. + */ +export function useEcosystemDeviceModelSummaries({ + manufacturer = '', + deviceType = '', + provider = '', + capability = '', + enabled = true, +}: UseEcosystemDeviceModelSummariesParams = {}): UseEcosystemQueryResult< + 'deviceModels', + UseEcosystemDeviceModelSummariesData +> { + const { embedKey, endpoint } = useEcosystemContext() + + const params = { + manufacturer: manufacturer.trim(), + device_type: deviceType.trim(), + provider: provider.trim(), + capability: capability.trim(), + } + + const { data, ...rest } = useQuery< + UseEcosystemDeviceModelSummariesData, + EcosystemApiError + >({ + enabled, + queryKey: [ + 'ecosystem', + endpoint, + embedKey ?? null, + 'device_models', + 'summaries', + 'list', + params, + ], + queryFn: async ({ signal }) => { + const deviceModels: DeviceModelSummary[] = [] + let pageCursor: string | undefined + for (let page = 0; page < maxPages; page++) { + const res = await fetchEcosystem( + 'device_models/summaries/list', + { + endpoint, + embedKey, + params: { ...params, limit: pageSize, page_cursor: pageCursor }, + signal, + } + ) + deviceModels.push(...res.device_models) + const { has_next_page: hasNextPage, next_page_cursor: nextPageCursor } = + res.pagination + if (!hasNextPage || nextPageCursor == null) break + pageCursor = nextPageCursor + } + return deviceModels + }, + retry: retryEcosystemQuery, + }) + + return { ...rest, deviceModels: data } +} diff --git a/test/fixtures/ecosystem.tsx b/test/fixtures/ecosystem.tsx index 75f824e95..651370552 100644 --- a/test/fixtures/ecosystem.tsx +++ b/test/fixtures/ecosystem.tsx @@ -6,7 +6,7 @@ import { afterEach, beforeEach } from 'vitest' import { EcosystemProvider } from 'lib/seam/ecosystem/EcosystemProvider.js' // eslint-disable-next-line import/no-relative-parent-imports -import { startFakeEcosystemServer } from '../../.storybook/fake-ecosystem.js' +import { handleFakeEcosystemRequest } from '../../.storybook/fake-ecosystem.js' export { fakeEmbedKey, @@ -19,23 +19,55 @@ export { export interface EcosystemTestContext { ecosystemEndpoint: string + /** The method and path of every request to the fake, e.g., `GET v1/compatibility`. */ + ecosystemRequests: string[] } -/** Starts a fake Ecosystem API for each test in the calling file. */ +const fakeEcosystemOrigin = 'http://fake-ecosystem.test' + +/** + * Serves a fake Ecosystem API at `ctx.ecosystemEndpoint` for each test in the calling file. + * Requests are handled in-process by stubbing fetch, so no port is opened + * (a local server could take the port the fake Devicedb server is about to bind). + */ export const setupFakeEcosystem = (): void => { - beforeEach(async (ctx) => { - const fake = await startFakeEcosystemServer() - ctx.ecosystemEndpoint = fake.endpoint + beforeEach((ctx) => { + const requests: string[] = [] + ctx.ecosystemEndpoint = `${fakeEcosystemOrigin}/v1/` + ctx.ecosystemRequests = requests + const { fetch } = globalThis // UPSTREAM: Under happy-dom, the global fetch is Node's, // which rejects the AbortSignal from happy-dom's AbortController. - const { fetch } = globalThis - globalThis.fetch = async (input, { signal: _signal, ...init } = {}) => - await fetch(input, init) + globalThis.fetch = async (input, { signal: _signal, ...init } = {}) => { + const url = new URL(input instanceof Request ? input.url : String(input)) + if (url.origin !== fakeEcosystemOrigin) return await fetch(input, init) + + const method = init.method ?? 'GET' + const path = url.pathname.replace(/^\/+/, '') + requests.push(`${method} ${path}`) + + const headers: Record = {} + new Headers(init.headers).forEach((value, key) => { + headers[key.toLowerCase()] = value + }) + + const { + status, + headers: resHeaders, + body, + } = handleFakeEcosystemRequest({ + method, + path, + searchParams: url.searchParams, + headers, + assetBaseUrl: fakeEcosystemOrigin, + }) + return new Response(body, { status, headers: resHeaders }) + } - return async () => { + return () => { globalThis.fetch = fetch - await fake.close() } }) @@ -50,6 +82,7 @@ export const renderWithEcosystem = ( ui: Parameters[0], { ecosystemEndpoint, + ecosystemRequests: _ecosystemRequests, embedKey, ...options }: Parameters[1] & EcosystemTestContext & { embedKey?: string }