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 }