From 9cdac4568fa7bbbaf629c8dc3a01947250e7df2a Mon Sep 17 00:00:00 2001 From: MyPrototypeWhat Date: Mon, 6 Jul 2026 15:51:38 +0800 Subject: [PATCH 1/7] feat(testkit): provider conformance helpers (private package) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add @refkit/provider-testkit (private, workspace-only) with searchConformant() and expectLicenseMap(): reusable assertions that run a provider's search through fixture or live fetch and check schema validity, id/providerId identify the provider, licenseVersion is only on CC-family licenses, and D8 image-URL rules for image providers. id-rule: settled on exact prefix match `ref.id.startsWith(`${provider.id}:`)` plus `ref.source.providerId === provider.id`, dropping the plan draft's OR fallback — verified every current provider factory (including dual-factory packages like openverse/openverse-audio, pexels/pexels-video, pixabay/pixabay-video, polyhaven/ambientcg) stamps referenceId(providerId, …) and source.providerId with the exact same string as provider.id, so the exact match is sufficient and still catches a provider stamping the wrong provider's id. Also registers packages/provider-testkit/vitest.config.ts in the root aggregator (vitest.config.ts enumerates package paths explicitly). --- packages/provider-testkit/package.json | 26 ++++++ .../src/__tests__/testkit.test.ts | 91 +++++++++++++++++++ packages/provider-testkit/src/index.ts | 86 ++++++++++++++++++ packages/provider-testkit/tsconfig.json | 5 + packages/provider-testkit/vitest.config.ts | 2 + pnpm-lock.yaml | 6 ++ vitest.config.ts | 1 + 7 files changed, 217 insertions(+) create mode 100644 packages/provider-testkit/package.json create mode 100644 packages/provider-testkit/src/__tests__/testkit.test.ts create mode 100644 packages/provider-testkit/src/index.ts create mode 100644 packages/provider-testkit/tsconfig.json create mode 100644 packages/provider-testkit/vitest.config.ts diff --git a/packages/provider-testkit/package.json b/packages/provider-testkit/package.json new file mode 100644 index 0000000..d70c3e0 --- /dev/null +++ b/packages/provider-testkit/package.json @@ -0,0 +1,26 @@ +{ + "name": "@refkit/provider-testkit", + "version": "0.0.0", + "private": true, + "description": "Conformance test helpers for refkit provider satellites (internal, not published).", + "type": "module", + "license": "Apache-2.0", + "keywords": [ + "refkit", + "reference-retrieval", + "testkit", + "conformance", + "internal" + ], + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@refkit/core": "workspace:*" + } +} diff --git a/packages/provider-testkit/src/__tests__/testkit.test.ts b/packages/provider-testkit/src/__tests__/testkit.test.ts new file mode 100644 index 0000000..22d2d98 --- /dev/null +++ b/packages/provider-testkit/src/__tests__/testkit.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import { defineProvider, type ProviderContext, type Reference } from '@refkit/core' +import { expectLicenseMap, searchConformant } from '../index' + +// A minimal, otherwise-conformant reference. Individual tests override +// exactly the field under examination. +function baseRef(overrides: Partial = {}): Reference { + return { + id: 'fake:abc123', + modality: 'image', + source: { providerId: 'fake', sourceUrl: 'https://example.com/photo/1' }, + canonicalUrl: 'https://example.com/photo/1', + rights: { + license: 'CC0-1.0', + rehostPolicy: 'hotlink-required', + raw: { sourceTerms: 'public domain', sourceUrl: 'https://example.com/terms' }, + }, + verifiedAt: new Date().toISOString(), + thumbnail: { url: 'https://example.com/photo/1-thumb.jpg' }, + preview: { url: 'https://example.com/photo/1-full.jpg', mediaType: 'image/jpeg' }, + relevance: 1, + ...overrides, + } +} + +// Fixture-fetch fake provider: search() ignores ctx.fetch's real behavior and +// just returns whatever result set the test configured, but still routes +// through ctx.fetch once so the shape matches a real satellite. +function fakeProvider(results: Reference[]) { + return defineProvider({ + id: 'fake', + modalities: ['image'], + queryFeatures: [], + async search(_query, ctx: ProviderContext) { + await ctx.fetch('https://example.com/api') + return results + }, + }) +} + +const fixtureFetch: typeof fetch = (async () => new Response('{}', { status: 200 })) as typeof fetch + +describe('searchConformant', () => { + it('passes a conformant provider and returns parsed refs', async () => { + const provider = fakeProvider([baseRef()]) + const refs = await searchConformant(provider, fixtureFetch) + expect(refs).toHaveLength(1) + expect(refs[0].id).toBe('fake:abc123') + }) + + it('fails when an image provider emits a page-URL thumbnail (D8)', async () => { + const provider = fakeProvider([ + baseRef({ thumbnail: { url: 'https://example.com/photo/1' } }), // page URL, not an image resource + ]) + await expect(searchConformant(provider, fixtureFetch)).rejects.toThrow(/thumbnail\.url is not image-like/) + }) + + it('fails when licenseVersion is stamped on a non-CC-family license (CC0-1.0)', async () => { + const provider = fakeProvider([ + baseRef({ + rights: { + license: 'CC0-1.0', + licenseVersion: '4.0', + rehostPolicy: 'hotlink-required', + raw: { sourceTerms: 'public domain', sourceUrl: 'https://example.com/terms' }, + }, + }), + ]) + await expect(searchConformant(provider, fixtureFetch)).rejects.toThrow(/carries licenseVersion on non-CC-family license/) + }) +}) + +describe('expectLicenseMap', () => { + it('passes silently on exact matches', () => { + expect(() => + expectLicenseMap((code: string) => (code === 'by' ? 'CC-BY' : 'unknown'), [ + { input: 'by', expect: 'CC-BY' }, + { input: 'zz', expect: 'unknown' }, + ]), + ).not.toThrow() + }) + + it('reports every mismatch when the mapping is wrong', () => { + expect(() => + expectLicenseMap((code: string) => (code === 'by' ? 'CC-BY-SA' : 'PD'), [ + { input: 'by', expect: 'CC-BY' }, + { input: 'zz', expect: 'unknown' }, + ]), + ).toThrowError(/license mapping mismatches[\s\S]*by[\s\S]*zz/) + }) +}) diff --git a/packages/provider-testkit/src/index.ts b/packages/provider-testkit/src/index.ts new file mode 100644 index 0000000..034d6b2 --- /dev/null +++ b/packages/provider-testkit/src/index.ts @@ -0,0 +1,86 @@ +import { + parseReference, isLikelyImageUrl, CC_FAMILY_BY_TOKEN, + type Reference, type ReferenceProvider, type NormalizedQuery, type ProviderContext, type LicenseId, +} from '@refkit/core' + +/** Licenses allowed to carry rights.licenseVersion (the six versioned CC families). */ +const VERSIONED: ReadonlySet = new Set(Object.values(CC_FAMILY_BY_TOKEN)) + +export interface ConformanceOptions { + /** Text query for the search. Default 'landscape'. */ + query?: string + /** Extra NormalizedQuery fields (providerOptions, limit, controls…). */ + queryExtras?: Partial + /** Providers whose modality is image-only must yield image-like preview/thumbnail URLs (D8). Default true when provider.modalities includes 'image'. */ + enforceImageUrls?: boolean +} + +/** Run provider.search through the given fetch and assert every conformance rule + * refkit demands of a satellite. Throws (with a per-item message) on violation; + * returns the parsed refs for further assertions. Works for fixture fetches AND + * the real global fetch (live smoke). */ +export async function searchConformant( + provider: ReferenceProvider, + fetchImpl: typeof fetch, + opts: ConformanceOptions = {}, +): Promise { + const query: NormalizedQuery = { + text: opts.query ?? 'landscape', + modalities: provider.modalities, + limit: 5, + ...opts.queryExtras, + } + const ctx: ProviderContext = { fetch: fetchImpl } + const raw = await provider.search(query, ctx) + const enforceImages = opts.enforceImageUrls ?? provider.modalities.includes('image') + return raw.map((item, i) => { + let ref: Reference + try { + ref = parseReference(item) // schema validity incl. provenance trio + rights record + } catch (e) { + throw new Error(`[${provider.id}] result #${i} failed referenceSchema: ${(e as Error).message}`) + } + // Every satellite factory in this repo stamps referenceId(provider.id, …) as + // `${provider.id}:${hash}` and sets `provider.id` to that exact same string — + // verified across all current providers, including the dual-factory cases + // where one package exports two distinct provider ids for two modalities/kinds + // (openverse/openverse-audio, pexels/pexels-video, pixabay/pixabay-video, + // polyhaven/ambientcg). None of those share a provider.id across prefixes, so + // an exact `${provider.id}:` prefix match is sufficient and catches a provider + // stamping the WRONG provider's id (e.g. openverse-audio results carrying an + // 'openverse:' id) without needing a looser fallback. + if (!ref.id.startsWith(`${provider.id}:`)) { + throw new Error(`[${provider.id}] result #${i} id does not identify the provider (id=${ref.id}, provider.id=${provider.id})`) + } + if (ref.source.providerId !== provider.id) { + throw new Error(`[${provider.id}] result #${i} source.providerId does not match the provider (source.providerId=${ref.source.providerId}, provider.id=${provider.id})`) + } + if (ref.rights.licenseVersion !== undefined && !VERSIONED.has(ref.rights.license)) { + throw new Error(`[${provider.id}] result #${i} carries licenseVersion on non-CC-family license ${ref.rights.license}`) + } + if (enforceImages) { + // D8: preview/thumbnail must be an image resource, never a web page + if (ref.thumbnail && !isLikelyImageUrl(ref.thumbnail.url)) { + throw new Error(`[${provider.id}] result #${i} thumbnail.url is not image-like: ${ref.thumbnail.url}`) + } + if (ref.preview && !ref.preview.mediaType.startsWith('image/')) { + throw new Error(`[${provider.id}] result #${i} image preview has non-image mediaType: ${ref.preview.mediaType}`) + } + } + return ref + }) +} + +export interface LicenseMapCase { input: A; expect: { license: LicenseId; version?: string } | LicenseId } + +/** Data-driven license-mapping assertion: runs each case and reports every mismatch at once. */ +export function expectLicenseMap( + mapFn: (input: A) => { license: LicenseId; version?: string } | LicenseId, + cases: ReadonlyArray>, +): void { + const failures = cases.flatMap(({ input, expect: want }) => { + const got = mapFn(input) + return JSON.stringify(got) === JSON.stringify(want) ? [] : [`map(${JSON.stringify(input)}) = ${JSON.stringify(got)}, want ${JSON.stringify(want)}`] + }) + if (failures.length > 0) throw new Error(`license mapping mismatches:\n${failures.join('\n')}`) +} diff --git a/packages/provider-testkit/tsconfig.json b/packages/provider-testkit/tsconfig.json new file mode 100644 index 0000000..98922fe --- /dev/null +++ b/packages/provider-testkit/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "out", "rootDir": "src", "types": ["node"] }, + "include": ["src/**/*"] +} diff --git a/packages/provider-testkit/vitest.config.ts b/packages/provider-testkit/vitest.config.ts new file mode 100644 index 0000000..220b630 --- /dev/null +++ b/packages/provider-testkit/vitest.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from 'vitest/config' +export default defineConfig({ test: { name: 'provider-testkit', environment: 'node', include: ['src/**/*.{test,spec}.ts'] } }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a94a744..902b12d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -225,6 +225,12 @@ importers: specifier: workspace:* version: link:../core + packages/provider-testkit: + dependencies: + '@refkit/core': + specifier: workspace:* + version: link:../core + packages/provider-unsplash: dependencies: '@refkit/core': diff --git a/vitest.config.ts b/vitest.config.ts index 665a7ae..379a029 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -24,6 +24,7 @@ export default defineConfig({ './packages/provider-jamendo/vitest.config.ts', './packages/provider-europeana/vitest.config.ts', './packages/provider-internet-archive/vitest.config.ts', + './packages/provider-testkit/vitest.config.ts', ], }, }) From a24dd63f4cfe589d3ba6fe2733fa8afe187eb081 Mon Sep 17 00:00:00 2001 From: MyPrototypeWhat Date: Mon, 6 Jul 2026 16:07:13 +0800 Subject: [PATCH 2/7] fix(testkit): calibrate D8 thumbnail rule for real CDN URLs; guard all checks; field-wise license map compare MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - D8 thumbnail: fail only when the thumbnail reuses the item's landing page (=== canonicalUrl or === source.sourceUrl); extensionless CDN thumbnails (openverse /thumb/, smithsonian deliveryService, unsplash query-param URLs) no longer false-fail — isLikelyImageUrl is now only a fast-accept, never a failure condition. Verified the REAL openverse factory + its real fixture passes searchConformant (would have thrown before this fix). - Preview rule unchanged: image providers must declare image/* mediaType. - Add negative self-tests for the previously unguarded checks: foreign id prefix, source.providerId mismatch, text/html preview mediaType; plus a passing extensionless-CDN thumbnail case. - expectLicenseMap: replace key-order-sensitive JSON.stringify equality with field-wise compare (license+version exact; jurisdiction only when the expectation specifies it), widen case type to mapRightsUrl's shape, and add self-tests for key-order insensitivity and jurisdiction handling. - JSDoc caveat on enforceImageUrls for mixed-modality factories. --- .../src/__tests__/testkit.test.ts | 59 +++++++++++++++++-- packages/provider-testkit/src/index.ts | 45 +++++++++++--- 2 files changed, 91 insertions(+), 13 deletions(-) diff --git a/packages/provider-testkit/src/__tests__/testkit.test.ts b/packages/provider-testkit/src/__tests__/testkit.test.ts index 22d2d98..80a92e7 100644 --- a/packages/provider-testkit/src/__tests__/testkit.test.ts +++ b/packages/provider-testkit/src/__tests__/testkit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { defineProvider, type ProviderContext, type Reference } from '@refkit/core' -import { expectLicenseMap, searchConformant } from '../index' +import { expectLicenseMap, searchConformant, type LicenseMapResult } from '../index' // A minimal, otherwise-conformant reference. Individual tests override // exactly the field under examination. @@ -48,11 +48,38 @@ describe('searchConformant', () => { expect(refs[0].id).toBe('fake:abc123') }) - it('fails when an image provider emits a page-URL thumbnail (D8)', async () => { + it('accepts an extensionless CDN-style thumbnail (real providers: openverse /thumb/, smithsonian deliveryService)', async () => { const provider = fakeProvider([ - baseRef({ thumbnail: { url: 'https://example.com/photo/1' } }), // page URL, not an image resource + baseRef({ thumbnail: { url: 'https://api.example.org/v1/images/x/thumb/' } }), ]) - await expect(searchConformant(provider, fixtureFetch)).rejects.toThrow(/thumbnail\.url is not image-like/) + const refs = await searchConformant(provider, fixtureFetch) + expect(refs).toHaveLength(1) + }) + + it('fails when an image provider reuses the landing page as the thumbnail (D8)', async () => { + const provider = fakeProvider([ + baseRef({ thumbnail: { url: 'https://example.com/photo/1' } }), // === canonicalUrl: the page itself + ]) + await expect(searchConformant(provider, fixtureFetch)).rejects.toThrow(/thumbnail\.url is the item's landing page/) + }) + + it('fails when an image provider emits a preview with non-image mediaType (D8)', async () => { + const provider = fakeProvider([ + baseRef({ preview: { url: 'https://example.com/photo/1', mediaType: 'text/html' } }), + ]) + await expect(searchConformant(provider, fixtureFetch)).rejects.toThrow(/image preview has non-image mediaType: text\/html/) + }) + + it('fails when a ref carries a different provider\'s id prefix', async () => { + const provider = fakeProvider([baseRef({ id: 'other:abc123' })]) + await expect(searchConformant(provider, fixtureFetch)).rejects.toThrow(/id does not identify the provider/) + }) + + it('fails when source.providerId does not match the provider', async () => { + const provider = fakeProvider([ + baseRef({ source: { providerId: 'other', sourceUrl: 'https://example.com/photo/1' } }), + ]) + await expect(searchConformant(provider, fixtureFetch)).rejects.toThrow(/source\.providerId does not match the provider/) }) it('fails when licenseVersion is stamped on a non-CC-family license (CC0-1.0)', async () => { @@ -80,6 +107,30 @@ describe('expectLicenseMap', () => { ).not.toThrow() }) + it('compares objects field-wise, insensitive to key order', () => { + // Construct the result with keys in the opposite order of the expectation. + const mapFn = (): LicenseMapResult => JSON.parse('{"version":"4.0","license":"CC-BY"}') as LicenseMapResult + expect(() => + expectLicenseMap(mapFn, [{ input: 'x', expect: { license: 'CC-BY', version: '4.0' } }]), + ).not.toThrow() + }) + + it('ignores extra jurisdiction on the result unless the expectation specifies it (mapRightsUrl shape)', () => { + const mapFn = (): LicenseMapResult => ({ license: 'PD', jurisdiction: 'US' }) + // Matches when jurisdiction is asserted… + expect(() => + expectLicenseMap(mapFn, [{ input: 'noc-us', expect: { license: 'PD', jurisdiction: 'US' } }]), + ).not.toThrow() + // …and when the expectation omits it entirely. + expect(() => + expectLicenseMap(mapFn, [{ input: 'noc-us', expect: { license: 'PD' } }]), + ).not.toThrow() + // But a WRONG asserted jurisdiction still fails. + expect(() => + expectLicenseMap(mapFn, [{ input: 'noc-us', expect: { license: 'PD', jurisdiction: 'DE' } }]), + ).toThrowError(/license mapping mismatches/) + }) + it('reports every mismatch when the mapping is wrong', () => { expect(() => expectLicenseMap((code: string) => (code === 'by' ? 'CC-BY-SA' : 'PD'), [ diff --git a/packages/provider-testkit/src/index.ts b/packages/provider-testkit/src/index.ts index 034d6b2..61fd2a9 100644 --- a/packages/provider-testkit/src/index.ts +++ b/packages/provider-testkit/src/index.ts @@ -11,7 +11,8 @@ export interface ConformanceOptions { query?: string /** Extra NormalizedQuery fields (providerOptions, limit, controls…). */ queryExtras?: Partial - /** Providers whose modality is image-only must yield image-like preview/thumbnail URLs (D8). Default true when provider.modalities includes 'image'. */ + /** Providers whose modality is image-only must yield image-like preview/thumbnail URLs (D8). Default true when provider.modalities includes 'image'. + * Caveat: mixed-modality factories (image plus others) also default to true — pass false explicitly if their non-image results legitimately carry non-image previews. */ enforceImageUrls?: boolean } @@ -59,10 +60,21 @@ export async function searchConformant( throw new Error(`[${provider.id}] result #${i} carries licenseVersion on non-CC-family license ${ref.rights.license}`) } if (enforceImages) { - // D8: preview/thumbnail must be an image resource, never a web page - if (ref.thumbnail && !isLikelyImageUrl(ref.thumbnail.url)) { - throw new Error(`[${provider.id}] result #${i} thumbnail.url is not image-like: ${ref.thumbnail.url}`) + // D8 thumbnail rule, calibrated for real providers: legitimate thumbnails are + // often extensionless CDN/proxy URLs that no URL heuristic can bless + // (openverse `/v1/images//thumb/`, smithsonian `deliveryService?...`, + // unsplash `photo-1?w=200`), so failing isLikelyImageUrl MUST NOT fail the + // check — it is only an optional fast-accept. We fail solely on the + // unambiguous D8 disaster case: the item's landing page reused verbatim as + // the thumbnail. + if ( + ref.thumbnail && !isLikelyImageUrl(ref.thumbnail.url) + && (ref.thumbnail.url === ref.canonicalUrl || ref.thumbnail.url === ref.source.sourceUrl) + ) { + throw new Error(`[${provider.id}] result #${i} thumbnail.url is the item's landing page, not an image resource: ${ref.thumbnail.url}`) } + // D8 preview rule: preview is the rehostable asset — for image providers its + // declared mediaType must be image/*. if (ref.preview && !ref.preview.mediaType.startsWith('image/')) { throw new Error(`[${provider.id}] result #${i} image preview has non-image mediaType: ${ref.preview.mediaType}`) } @@ -71,16 +83,31 @@ export async function searchConformant( }) } -export interface LicenseMapCase { input: A; expect: { license: LicenseId; version?: string } | LicenseId } +/** What a license-mapping function may return (mapCcDeedUrl/mapRightsUrl shape). */ +export interface LicenseMapResult { license: LicenseId; version?: string; jurisdiction?: string } -/** Data-driven license-mapping assertion: runs each case and reports every mismatch at once. */ +export interface LicenseMapCase { input: A; expect: LicenseId | LicenseMapResult } + +function asResult(v: LicenseId | LicenseMapResult): LicenseMapResult { + return typeof v === 'string' ? { license: v } : v +} + +/** Data-driven license-mapping assertion: runs each case and reports every mismatch + * at once. Comparison is field-wise (key-order-insensitive): license and version + * must match exactly; jurisdiction is compared ONLY when the expectation specifies + * it, so mapRightsUrl-style extra keys on the result don't false-fail a plain + * `{license}` expectation. */ export function expectLicenseMap( - mapFn: (input: A) => { license: LicenseId; version?: string } | LicenseId, + mapFn: (input: A) => LicenseId | LicenseMapResult, cases: ReadonlyArray>, ): void { const failures = cases.flatMap(({ input, expect: want }) => { - const got = mapFn(input) - return JSON.stringify(got) === JSON.stringify(want) ? [] : [`map(${JSON.stringify(input)}) = ${JSON.stringify(got)}, want ${JSON.stringify(want)}`] + const g = asResult(mapFn(input)) + const w = asResult(want) + const ok = g.license === w.license + && g.version === w.version + && (w.jurisdiction === undefined || g.jurisdiction === w.jurisdiction) + return ok ? [] : [`map(${JSON.stringify(input)}) = ${JSON.stringify(g)}, want ${JSON.stringify(w)}`] }) if (failures.length > 0) throw new Error(`license mapping mismatches:\n${failures.join('\n')}`) } From c365eeeff8ce1983d342f094e7ef8c2cbb880ba5 Mon Sep 17 00:00:00 2001 From: MyPrototypeWhat Date: Mon, 6 Jul 2026 16:12:01 +0800 Subject: [PATCH 3/7] test(providers): adopt testkit conformance checks in openverse + europeana --- packages/provider-europeana/package.json | 3 +++ .../provider-europeana/src/__tests__/europeana.test.ts | 6 ++++++ packages/provider-openverse/package.json | 3 ++- .../provider-openverse/src/__tests__/openverse.test.ts | 6 ++++++ pnpm-lock.yaml | 7 +++++++ 5 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/provider-europeana/package.json b/packages/provider-europeana/package.json index dc541b0..4026f81 100644 --- a/packages/provider-europeana/package.json +++ b/packages/provider-europeana/package.json @@ -27,6 +27,9 @@ "dependencies": { "@refkit/core": "workspace:*" }, + "devDependencies": { + "@refkit/provider-testkit": "workspace:*" + }, "files": [ "dist", "LICENSE" diff --git a/packages/provider-europeana/src/__tests__/europeana.test.ts b/packages/provider-europeana/src/__tests__/europeana.test.ts index 1ecec6b..8dd4cba 100644 --- a/packages/provider-europeana/src/__tests__/europeana.test.ts +++ b/packages/provider-europeana/src/__tests__/europeana.test.ts @@ -33,6 +33,7 @@ describe('mapEuropeanaRights', () => { }) import { evaluateUse, type ProviderContext } from '@refkit/core' +import { searchConformant } from '@refkit/provider-testkit' import { europeana } from '../index' // Realistic Europeana Search API item shapes. Note every metadata field is an @@ -162,6 +163,11 @@ describe('europeana toReference', () => { expect(refs[0].preview?.url).toBe('https://images.example.org/no-extension') expect(refs[0].preview?.mediaType).toBe('image/png') }) + + it('passes provider conformance (testkit)', async () => { + const refs = await searchConformant(europeana({ apiKey: 'k' }), okCtx([ITEM_CC0, ITEM_BY_SA]).fetch) + expect(refs.length).toBeGreaterThan(0) + }) }) describe('europeana search request', () => { diff --git a/packages/provider-openverse/package.json b/packages/provider-openverse/package.json index fea7d0f..0460c42 100644 --- a/packages/provider-openverse/package.json +++ b/packages/provider-openverse/package.json @@ -32,7 +32,8 @@ "@refkit/core": "workspace:*" }, "devDependencies": { - "@refkit/provider-pexels": "workspace:*" + "@refkit/provider-pexels": "workspace:*", + "@refkit/provider-testkit": "workspace:*" }, "files": [ "dist", diff --git a/packages/provider-openverse/src/__tests__/openverse.test.ts b/packages/provider-openverse/src/__tests__/openverse.test.ts index 64c3dd3..c14bebc 100644 --- a/packages/provider-openverse/src/__tests__/openverse.test.ts +++ b/packages/provider-openverse/src/__tests__/openverse.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { evaluateUse, type ProviderContext } from '@refkit/core' +import { searchConformant } from '@refkit/provider-testkit' import { openverse, openverseAudio, mapOpenverseLicense } from '../index' const FIXTURE = { @@ -193,6 +194,11 @@ describe('openverse provider', () => { // ...while the cc0 item is allowed: expect(evaluateUse(refs[0].rights, 'commercial-product').decision).toBe('allowed') }) + + it('passes provider conformance (testkit)', async () => { + const refs = await searchConformant(openverse(), ctxWith(FIXTURE).fetch) + expect(refs.length).toBeGreaterThan(0) + }) }) describe('openverseAudio provider', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 902b12d..9f84584 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,6 +138,10 @@ importers: '@refkit/core': specifier: workspace:* version: link:../core + devDependencies: + '@refkit/provider-testkit': + specifier: workspace:* + version: link:../provider-testkit packages/provider-flickr: dependencies: @@ -188,6 +192,9 @@ importers: '@refkit/provider-pexels': specifier: workspace:* version: link:../provider-pexels + '@refkit/provider-testkit': + specifier: workspace:* + version: link:../provider-testkit packages/provider-pexels: dependencies: From ec87931c8353137a99a3c67569f15b5428bd254c Mon Sep 17 00:00:00 2001 From: MyPrototypeWhat Date: Mon, 6 Jul 2026 16:22:45 +0800 Subject: [PATCH 4/7] test(providers): env-gated live smoke suites (REFKIT_LIVE=1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add packages/provider-testkit/src/live.ts exporting liveSmoke(), an env-gated describe.skipIf wrapper around searchConformant() that runs one real query against the live API (30s timeout) and asserts refs.length > 0. Exported via a separate "./live" package export so consuming test files import from '@refkit/provider-testkit/live', keeping vitest out of the main entry. Add src/__tests__/live.test.ts to all 18 provider packages: keyless (met, artic, wikimedia-commons, openverse+openverse-audio, gutendex, poetrydb, rijksmuseum, polyhaven+ambientcg, internet-archive) and BYOK (unsplash/UNSPLASH_KEY, pexels+pexels-video/PEXELS_KEY, pixabay+pixabay-video/PIXABAY_KEY, flickr/FLICKR_KEY, smithsonian/SI_KEY, brave/BRAVE_TOKEN, freesound/FREESOUND_TOKEN, jamendo/JAMENDO_CLIENT_ID, europeana/EUROPEANA_KEY), matching packages/mcp/src/cli.ts's defaultProviders construction exactly. Text providers (gutendex, poetrydb) use query 'love'. Add @refkit/provider-testkit devDependency to every touched package still missing it, plus vitest as a direct dependency of provider-testkit (live.ts imports it). Root package.json gets a test:live script (REFKIT_LIVE=1 vitest run). Carry-over from W3.2 review: hoist europeana's inline pageOnly fixture to module scope (ITEM_PAGE_ONLY) and include it in the conformance test's fixture array, exercising the landing-page-rejection path. europeana keeps page-only items via their thumbnail (not dropped), so the count assertion holds unchanged. Hermetic default (pnpm test:run): 304 passed, 22 new skipped (live suites), totals otherwise unchanged. Spot-checked REFKIT_LIVE=1 against the real met and poetrydb APIs — both green. --- package.json | 1 + packages/provider-artic/package.json | 3 + .../provider-artic/src/__tests__/live.test.ts | 4 ++ packages/provider-brave/package.json | 3 +- .../provider-brave/src/__tests__/live.test.ts | 4 ++ .../src/__tests__/europeana.test.ts | 25 ++++--- .../src/__tests__/live.test.ts | 4 ++ packages/provider-flickr/package.json | 3 + .../src/__tests__/live.test.ts | 4 ++ packages/provider-freesound/package.json | 3 + .../src/__tests__/live.test.ts | 4 ++ packages/provider-gutendex/package.json | 3 +- .../src/__tests__/live.test.ts | 4 ++ .../provider-internet-archive/package.json | 3 + .../src/__tests__/live.test.ts | 4 ++ packages/provider-jamendo/package.json | 3 + .../src/__tests__/live.test.ts | 4 ++ packages/provider-met/package.json | 3 + .../provider-met/src/__tests__/live.test.ts | 4 ++ .../src/__tests__/live.test.ts | 5 ++ packages/provider-pexels/package.json | 3 + .../src/__tests__/live.test.ts | 5 ++ packages/provider-pixabay/package.json | 3 + .../src/__tests__/live.test.ts | 5 ++ packages/provider-poetrydb/package.json | 3 + .../src/__tests__/live.test.ts | 4 ++ packages/provider-polyhaven/package.json | 3 + .../src/__tests__/live.test.ts | 5 ++ packages/provider-rijksmuseum/package.json | 3 + .../src/__tests__/live.test.ts | 4 ++ packages/provider-smithsonian/package.json | 3 + .../src/__tests__/live.test.ts | 4 ++ packages/provider-testkit/package.json | 6 +- packages/provider-testkit/src/live.ts | 20 ++++++ packages/provider-unsplash/package.json | 3 + .../src/__tests__/live.test.ts | 4 ++ .../provider-wikimedia-commons/package.json | 3 + .../src/__tests__/live.test.ts | 4 ++ pnpm-lock.yaml | 65 +++++++++++++++++++ 39 files changed, 227 insertions(+), 14 deletions(-) create mode 100644 packages/provider-artic/src/__tests__/live.test.ts create mode 100644 packages/provider-brave/src/__tests__/live.test.ts create mode 100644 packages/provider-europeana/src/__tests__/live.test.ts create mode 100644 packages/provider-flickr/src/__tests__/live.test.ts create mode 100644 packages/provider-freesound/src/__tests__/live.test.ts create mode 100644 packages/provider-gutendex/src/__tests__/live.test.ts create mode 100644 packages/provider-internet-archive/src/__tests__/live.test.ts create mode 100644 packages/provider-jamendo/src/__tests__/live.test.ts create mode 100644 packages/provider-met/src/__tests__/live.test.ts create mode 100644 packages/provider-openverse/src/__tests__/live.test.ts create mode 100644 packages/provider-pexels/src/__tests__/live.test.ts create mode 100644 packages/provider-pixabay/src/__tests__/live.test.ts create mode 100644 packages/provider-poetrydb/src/__tests__/live.test.ts create mode 100644 packages/provider-polyhaven/src/__tests__/live.test.ts create mode 100644 packages/provider-rijksmuseum/src/__tests__/live.test.ts create mode 100644 packages/provider-smithsonian/src/__tests__/live.test.ts create mode 100644 packages/provider-testkit/src/live.ts create mode 100644 packages/provider-unsplash/src/__tests__/live.test.ts create mode 100644 packages/provider-wikimedia-commons/src/__tests__/live.test.ts diff --git a/package.json b/package.json index ce10fe8..6b293db 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "test": "vitest", "test:run": "vitest run", "test:watch": "vitest watch", + "test:live": "REFKIT_LIVE=1 vitest run", "changeset": "changeset", "version-packages": "changeset version", "release": "pnpm -r --if-present build && changeset publish" diff --git a/packages/provider-artic/package.json b/packages/provider-artic/package.json index e7879f4..09cce2c 100644 --- a/packages/provider-artic/package.json +++ b/packages/provider-artic/package.json @@ -44,5 +44,8 @@ "import": "./dist/index.js" } } + }, + "devDependencies": { + "@refkit/provider-testkit": "workspace:*" } } diff --git a/packages/provider-artic/src/__tests__/live.test.ts b/packages/provider-artic/src/__tests__/live.test.ts new file mode 100644 index 0000000..26bcd42 --- /dev/null +++ b/packages/provider-artic/src/__tests__/live.test.ts @@ -0,0 +1,4 @@ +import { liveSmoke } from '@refkit/provider-testkit/live' +import { artic } from '../index' + +liveSmoke('artic', () => artic()) diff --git a/packages/provider-brave/package.json b/packages/provider-brave/package.json index b3bcc04..ddf9ad2 100644 --- a/packages/provider-brave/package.json +++ b/packages/provider-brave/package.json @@ -31,7 +31,8 @@ "@refkit/core": "workspace:*" }, "devDependencies": { - "@refkit/provider-openverse": "workspace:*" + "@refkit/provider-openverse": "workspace:*", + "@refkit/provider-testkit": "workspace:*" }, "files": [ "dist", diff --git a/packages/provider-brave/src/__tests__/live.test.ts b/packages/provider-brave/src/__tests__/live.test.ts new file mode 100644 index 0000000..4841988 --- /dev/null +++ b/packages/provider-brave/src/__tests__/live.test.ts @@ -0,0 +1,4 @@ +import { liveSmoke } from '@refkit/provider-testkit/live' +import { brave } from '../index' + +liveSmoke('brave', () => brave({ token: process.env.BRAVE_TOKEN! }), { keyEnv: 'BRAVE_TOKEN' }) diff --git a/packages/provider-europeana/src/__tests__/europeana.test.ts b/packages/provider-europeana/src/__tests__/europeana.test.ts index 8dd4cba..5a9f8e8 100644 --- a/packages/provider-europeana/src/__tests__/europeana.test.ts +++ b/packages/provider-europeana/src/__tests__/europeana.test.ts @@ -71,6 +71,15 @@ const ITEM_INC = { edmIsShownAt: ['https://archive.example.org/item/inc'], rights: ['http://rightsstatements.org/vocab/InC/1.0/'], } +// No media resource, only a landing PAGE + a Europeana thumbnail image — exercises +// the landing-page-rejection path (edmIsShownAt is never surfaced as preview). +const ITEM_PAGE_ONLY = { + ...ITEM_CC0, + id: '/x/page_only', + edmIsShownBy: [], + edmIsShownAt: ['https://www.rijksmuseum.nl/en/collection/SK-A-1'], // a web page, NOT an image + edmPreview: ['https://api.europeana.eu/thumbnail/v3/200/pagethumb.jpg'], +} const okCtx = (items: unknown[]): ProviderContext => ({ fetch: (async () => @@ -138,15 +147,7 @@ describe('europeana toReference', () => { }) it('never uses edmIsShownAt (a landing page) as preview; keeps the item via its thumbnail', async () => { - // No media resource, only a landing PAGE + a Europeana thumbnail image. - const pageOnly = { - ...ITEM_CC0, - id: '/x/page_only', - edmIsShownBy: [], - edmIsShownAt: ['https://www.rijksmuseum.nl/en/collection/SK-A-1'], // a web page, NOT an image - edmPreview: ['https://api.europeana.eu/thumbnail/v3/200/pagethumb.jpg'], - } - const refs = await europeana({ apiKey: 'k' }).search({ text: 'x', modalities: ['image'] }, okCtx([pageOnly])) + const refs = await europeana({ apiKey: 'k' }).search({ text: 'x', modalities: ['image'] }, okCtx([ITEM_PAGE_ONLY])) expect(refs).toHaveLength(1) expect(refs[0].preview).toBeUndefined() // the landing page is never surfaced as media expect(refs[0].thumbnail?.url).toBe('https://api.europeana.eu/thumbnail/v3/200/pagethumb.jpg') @@ -165,7 +166,11 @@ describe('europeana toReference', () => { }) it('passes provider conformance (testkit)', async () => { - const refs = await searchConformant(europeana({ apiKey: 'k' }), okCtx([ITEM_CC0, ITEM_BY_SA]).fetch) + // Includes ITEM_PAGE_ONLY to exercise the landing-page-rejection path (D8): + // europeana keeps it via its thumbnail (kept, not dropped), so all three refs + // survive — the count assertion holds regardless of whether a given fixture + // item is dropped by provider-specific logic. + const refs = await searchConformant(europeana({ apiKey: 'k' }), okCtx([ITEM_CC0, ITEM_BY_SA, ITEM_PAGE_ONLY]).fetch) expect(refs.length).toBeGreaterThan(0) }) }) diff --git a/packages/provider-europeana/src/__tests__/live.test.ts b/packages/provider-europeana/src/__tests__/live.test.ts new file mode 100644 index 0000000..e000d58 --- /dev/null +++ b/packages/provider-europeana/src/__tests__/live.test.ts @@ -0,0 +1,4 @@ +import { liveSmoke } from '@refkit/provider-testkit/live' +import { europeana } from '../index' + +liveSmoke('europeana', () => europeana({ apiKey: process.env.EUROPEANA_KEY! }), { keyEnv: 'EUROPEANA_KEY' }) diff --git a/packages/provider-flickr/package.json b/packages/provider-flickr/package.json index 4107dd3..2b2b494 100644 --- a/packages/provider-flickr/package.json +++ b/packages/provider-flickr/package.json @@ -43,5 +43,8 @@ "import": "./dist/index.js" } } + }, + "devDependencies": { + "@refkit/provider-testkit": "workspace:*" } } diff --git a/packages/provider-flickr/src/__tests__/live.test.ts b/packages/provider-flickr/src/__tests__/live.test.ts new file mode 100644 index 0000000..b0ff450 --- /dev/null +++ b/packages/provider-flickr/src/__tests__/live.test.ts @@ -0,0 +1,4 @@ +import { liveSmoke } from '@refkit/provider-testkit/live' +import { flickr } from '../index' + +liveSmoke('flickr', () => flickr({ apiKey: process.env.FLICKR_KEY! }), { keyEnv: 'FLICKR_KEY' }) diff --git a/packages/provider-freesound/package.json b/packages/provider-freesound/package.json index 4d3c0e0..7ee1012 100644 --- a/packages/provider-freesound/package.json +++ b/packages/provider-freesound/package.json @@ -43,5 +43,8 @@ "import": "./dist/index.js" } } + }, + "devDependencies": { + "@refkit/provider-testkit": "workspace:*" } } diff --git a/packages/provider-freesound/src/__tests__/live.test.ts b/packages/provider-freesound/src/__tests__/live.test.ts new file mode 100644 index 0000000..6ba1b47 --- /dev/null +++ b/packages/provider-freesound/src/__tests__/live.test.ts @@ -0,0 +1,4 @@ +import { liveSmoke } from '@refkit/provider-testkit/live' +import { freesound } from '../index' + +liveSmoke('freesound', () => freesound({ apiKey: process.env.FREESOUND_TOKEN! }), { keyEnv: 'FREESOUND_TOKEN' }) diff --git a/packages/provider-gutendex/package.json b/packages/provider-gutendex/package.json index c586828..e29480d 100644 --- a/packages/provider-gutendex/package.json +++ b/packages/provider-gutendex/package.json @@ -32,7 +32,8 @@ "@refkit/core": "workspace:*" }, "devDependencies": { - "@refkit/provider-poetrydb": "workspace:*" + "@refkit/provider-poetrydb": "workspace:*", + "@refkit/provider-testkit": "workspace:*" }, "files": [ "dist", diff --git a/packages/provider-gutendex/src/__tests__/live.test.ts b/packages/provider-gutendex/src/__tests__/live.test.ts new file mode 100644 index 0000000..03661d5 --- /dev/null +++ b/packages/provider-gutendex/src/__tests__/live.test.ts @@ -0,0 +1,4 @@ +import { liveSmoke } from '@refkit/provider-testkit/live' +import { gutendex } from '../index' + +liveSmoke('gutendex', () => gutendex(), { query: 'love' }) diff --git a/packages/provider-internet-archive/package.json b/packages/provider-internet-archive/package.json index 109bb60..298721b 100644 --- a/packages/provider-internet-archive/package.json +++ b/packages/provider-internet-archive/package.json @@ -40,5 +40,8 @@ "import": "./dist/index.js" } } + }, + "devDependencies": { + "@refkit/provider-testkit": "workspace:*" } } diff --git a/packages/provider-internet-archive/src/__tests__/live.test.ts b/packages/provider-internet-archive/src/__tests__/live.test.ts new file mode 100644 index 0000000..e994038 --- /dev/null +++ b/packages/provider-internet-archive/src/__tests__/live.test.ts @@ -0,0 +1,4 @@ +import { liveSmoke } from '@refkit/provider-testkit/live' +import { internetArchive } from '../index' + +liveSmoke('internet-archive', () => internetArchive()) diff --git a/packages/provider-jamendo/package.json b/packages/provider-jamendo/package.json index d7e39a9..a5fb1be 100644 --- a/packages/provider-jamendo/package.json +++ b/packages/provider-jamendo/package.json @@ -40,5 +40,8 @@ "import": "./dist/index.js" } } + }, + "devDependencies": { + "@refkit/provider-testkit": "workspace:*" } } diff --git a/packages/provider-jamendo/src/__tests__/live.test.ts b/packages/provider-jamendo/src/__tests__/live.test.ts new file mode 100644 index 0000000..b814b7d --- /dev/null +++ b/packages/provider-jamendo/src/__tests__/live.test.ts @@ -0,0 +1,4 @@ +import { liveSmoke } from '@refkit/provider-testkit/live' +import { jamendo } from '../index' + +liveSmoke('jamendo', () => jamendo({ clientId: process.env.JAMENDO_CLIENT_ID! }), { keyEnv: 'JAMENDO_CLIENT_ID' }) diff --git a/packages/provider-met/package.json b/packages/provider-met/package.json index fc1ade7..584bddb 100644 --- a/packages/provider-met/package.json +++ b/packages/provider-met/package.json @@ -45,5 +45,8 @@ "import": "./dist/index.js" } } + }, + "devDependencies": { + "@refkit/provider-testkit": "workspace:*" } } diff --git a/packages/provider-met/src/__tests__/live.test.ts b/packages/provider-met/src/__tests__/live.test.ts new file mode 100644 index 0000000..5aecad5 --- /dev/null +++ b/packages/provider-met/src/__tests__/live.test.ts @@ -0,0 +1,4 @@ +import { liveSmoke } from '@refkit/provider-testkit/live' +import { met } from '../index' + +liveSmoke('met', () => met()) diff --git a/packages/provider-openverse/src/__tests__/live.test.ts b/packages/provider-openverse/src/__tests__/live.test.ts new file mode 100644 index 0000000..329da76 --- /dev/null +++ b/packages/provider-openverse/src/__tests__/live.test.ts @@ -0,0 +1,5 @@ +import { liveSmoke } from '@refkit/provider-testkit/live' +import { openverse, openverseAudio } from '../index' + +liveSmoke('openverse', () => openverse()) +liveSmoke('openverse-audio', () => openverseAudio()) diff --git a/packages/provider-pexels/package.json b/packages/provider-pexels/package.json index b628821..cf6742f 100644 --- a/packages/provider-pexels/package.json +++ b/packages/provider-pexels/package.json @@ -44,5 +44,8 @@ "import": "./dist/index.js" } } + }, + "devDependencies": { + "@refkit/provider-testkit": "workspace:*" } } diff --git a/packages/provider-pexels/src/__tests__/live.test.ts b/packages/provider-pexels/src/__tests__/live.test.ts new file mode 100644 index 0000000..c4103a5 --- /dev/null +++ b/packages/provider-pexels/src/__tests__/live.test.ts @@ -0,0 +1,5 @@ +import { liveSmoke } from '@refkit/provider-testkit/live' +import { pexels, pexelsVideo } from '../index' + +liveSmoke('pexels', () => pexels({ apiKey: process.env.PEXELS_KEY! }), { keyEnv: 'PEXELS_KEY' }) +liveSmoke('pexels-video', () => pexelsVideo({ apiKey: process.env.PEXELS_KEY! }), { keyEnv: 'PEXELS_KEY' }) diff --git a/packages/provider-pixabay/package.json b/packages/provider-pixabay/package.json index fa37906..67da558 100644 --- a/packages/provider-pixabay/package.json +++ b/packages/provider-pixabay/package.json @@ -44,5 +44,8 @@ "import": "./dist/index.js" } } + }, + "devDependencies": { + "@refkit/provider-testkit": "workspace:*" } } diff --git a/packages/provider-pixabay/src/__tests__/live.test.ts b/packages/provider-pixabay/src/__tests__/live.test.ts new file mode 100644 index 0000000..e857fa7 --- /dev/null +++ b/packages/provider-pixabay/src/__tests__/live.test.ts @@ -0,0 +1,5 @@ +import { liveSmoke } from '@refkit/provider-testkit/live' +import { pixabay, pixabayVideo } from '../index' + +liveSmoke('pixabay', () => pixabay({ key: process.env.PIXABAY_KEY! }), { keyEnv: 'PIXABAY_KEY' }) +liveSmoke('pixabay-video', () => pixabayVideo({ key: process.env.PIXABAY_KEY! }), { keyEnv: 'PIXABAY_KEY' }) diff --git a/packages/provider-poetrydb/package.json b/packages/provider-poetrydb/package.json index c2d81bb..300dd77 100644 --- a/packages/provider-poetrydb/package.json +++ b/packages/provider-poetrydb/package.json @@ -43,5 +43,8 @@ "import": "./dist/index.js" } } + }, + "devDependencies": { + "@refkit/provider-testkit": "workspace:*" } } diff --git a/packages/provider-poetrydb/src/__tests__/live.test.ts b/packages/provider-poetrydb/src/__tests__/live.test.ts new file mode 100644 index 0000000..a9c0269 --- /dev/null +++ b/packages/provider-poetrydb/src/__tests__/live.test.ts @@ -0,0 +1,4 @@ +import { liveSmoke } from '@refkit/provider-testkit/live' +import { poetrydb } from '../index' + +liveSmoke('poetrydb', () => poetrydb(), { query: 'love' }) diff --git a/packages/provider-polyhaven/package.json b/packages/provider-polyhaven/package.json index f193f7f..96771a5 100644 --- a/packages/provider-polyhaven/package.json +++ b/packages/provider-polyhaven/package.json @@ -41,5 +41,8 @@ "import": "./dist/index.js" } } + }, + "devDependencies": { + "@refkit/provider-testkit": "workspace:*" } } diff --git a/packages/provider-polyhaven/src/__tests__/live.test.ts b/packages/provider-polyhaven/src/__tests__/live.test.ts new file mode 100644 index 0000000..73eed17 --- /dev/null +++ b/packages/provider-polyhaven/src/__tests__/live.test.ts @@ -0,0 +1,5 @@ +import { liveSmoke } from '@refkit/provider-testkit/live' +import { polyhaven, ambientcg } from '../index' + +liveSmoke('polyhaven', () => polyhaven()) +liveSmoke('ambientcg', () => ambientcg()) diff --git a/packages/provider-rijksmuseum/package.json b/packages/provider-rijksmuseum/package.json index b703b81..dbcd32d 100644 --- a/packages/provider-rijksmuseum/package.json +++ b/packages/provider-rijksmuseum/package.json @@ -44,5 +44,8 @@ "import": "./dist/index.js" } } + }, + "devDependencies": { + "@refkit/provider-testkit": "workspace:*" } } diff --git a/packages/provider-rijksmuseum/src/__tests__/live.test.ts b/packages/provider-rijksmuseum/src/__tests__/live.test.ts new file mode 100644 index 0000000..e26b3cc --- /dev/null +++ b/packages/provider-rijksmuseum/src/__tests__/live.test.ts @@ -0,0 +1,4 @@ +import { liveSmoke } from '@refkit/provider-testkit/live' +import { rijksmuseum } from '../index' + +liveSmoke('rijksmuseum', () => rijksmuseum()) diff --git a/packages/provider-smithsonian/package.json b/packages/provider-smithsonian/package.json index b984f1d..aa56d65 100644 --- a/packages/provider-smithsonian/package.json +++ b/packages/provider-smithsonian/package.json @@ -44,5 +44,8 @@ "import": "./dist/index.js" } } + }, + "devDependencies": { + "@refkit/provider-testkit": "workspace:*" } } diff --git a/packages/provider-smithsonian/src/__tests__/live.test.ts b/packages/provider-smithsonian/src/__tests__/live.test.ts new file mode 100644 index 0000000..632a36b --- /dev/null +++ b/packages/provider-smithsonian/src/__tests__/live.test.ts @@ -0,0 +1,4 @@ +import { liveSmoke } from '@refkit/provider-testkit/live' +import { smithsonian } from '../index' + +liveSmoke('smithsonian', () => smithsonian({ apiKey: process.env.SI_KEY! }), { keyEnv: 'SI_KEY' }) diff --git a/packages/provider-testkit/package.json b/packages/provider-testkit/package.json index d70c3e0..e7faf9d 100644 --- a/packages/provider-testkit/package.json +++ b/packages/provider-testkit/package.json @@ -15,12 +15,14 @@ "main": "./src/index.ts", "types": "./src/index.ts", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./live": "./src/live.ts" }, "scripts": { "typecheck": "tsc --noEmit" }, "dependencies": { - "@refkit/core": "workspace:*" + "@refkit/core": "workspace:*", + "vitest": "^3.2.6" } } diff --git a/packages/provider-testkit/src/live.ts b/packages/provider-testkit/src/live.ts new file mode 100644 index 0000000..4446382 --- /dev/null +++ b/packages/provider-testkit/src/live.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import type { ReferenceProvider } from '@refkit/core' +import { searchConformant, type ConformanceOptions } from './index' + +/** Register an env-gated live smoke suite for a provider. Runs only with + * REFKIT_LIVE=1 (and, if keyEnv given, that env var set). One real query, + * full conformance assertions, 30s timeout. */ +export function liveSmoke( + name: string, + make: () => ReferenceProvider, + opts: ConformanceOptions & { keyEnv?: string } = {}, +): void { + const enabled = process.env.REFKIT_LIVE === '1' && (!opts.keyEnv || !!process.env[opts.keyEnv]) + describe.skipIf(!enabled)(`live smoke: ${name}`, () => { + it('returns conformant references from the real API', { timeout: 30_000 }, async () => { + const refs = await searchConformant(make(), globalThis.fetch, opts) + expect(refs.length).toBeGreaterThan(0) + }) + }) +} diff --git a/packages/provider-unsplash/package.json b/packages/provider-unsplash/package.json index b3fbe7f..ef80495 100644 --- a/packages/provider-unsplash/package.json +++ b/packages/provider-unsplash/package.json @@ -42,5 +42,8 @@ "import": "./dist/index.js" } } + }, + "devDependencies": { + "@refkit/provider-testkit": "workspace:*" } } diff --git a/packages/provider-unsplash/src/__tests__/live.test.ts b/packages/provider-unsplash/src/__tests__/live.test.ts new file mode 100644 index 0000000..ffc2fdd --- /dev/null +++ b/packages/provider-unsplash/src/__tests__/live.test.ts @@ -0,0 +1,4 @@ +import { liveSmoke } from '@refkit/provider-testkit/live' +import { unsplash } from '../index' + +liveSmoke('unsplash', () => unsplash({ accessKey: process.env.UNSPLASH_KEY! }), { keyEnv: 'UNSPLASH_KEY' }) diff --git a/packages/provider-wikimedia-commons/package.json b/packages/provider-wikimedia-commons/package.json index 5d6dac3..ffb2270 100644 --- a/packages/provider-wikimedia-commons/package.json +++ b/packages/provider-wikimedia-commons/package.json @@ -44,5 +44,8 @@ "import": "./dist/index.js" } } + }, + "devDependencies": { + "@refkit/provider-testkit": "workspace:*" } } diff --git a/packages/provider-wikimedia-commons/src/__tests__/live.test.ts b/packages/provider-wikimedia-commons/src/__tests__/live.test.ts new file mode 100644 index 0000000..204a427 --- /dev/null +++ b/packages/provider-wikimedia-commons/src/__tests__/live.test.ts @@ -0,0 +1,4 @@ +import { liveSmoke } from '@refkit/provider-testkit/live' +import { wikimediaCommons } from '../index' + +liveSmoke('wikimedia-commons', () => wikimediaCommons()) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f84584..541173f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,6 +122,10 @@ importers: '@refkit/core': specifier: workspace:* version: link:../core + devDependencies: + '@refkit/provider-testkit': + specifier: workspace:* + version: link:../provider-testkit packages/provider-brave: dependencies: @@ -132,6 +136,9 @@ importers: '@refkit/provider-openverse': specifier: workspace:* version: link:../provider-openverse + '@refkit/provider-testkit': + specifier: workspace:* + version: link:../provider-testkit packages/provider-europeana: dependencies: @@ -148,12 +155,20 @@ importers: '@refkit/core': specifier: workspace:* version: link:../core + devDependencies: + '@refkit/provider-testkit': + specifier: workspace:* + version: link:../provider-testkit packages/provider-freesound: dependencies: '@refkit/core': specifier: workspace:* version: link:../core + devDependencies: + '@refkit/provider-testkit': + specifier: workspace:* + version: link:../provider-testkit packages/provider-gutendex: dependencies: @@ -164,24 +179,39 @@ importers: '@refkit/provider-poetrydb': specifier: workspace:* version: link:../provider-poetrydb + '@refkit/provider-testkit': + specifier: workspace:* + version: link:../provider-testkit packages/provider-internet-archive: dependencies: '@refkit/core': specifier: workspace:* version: link:../core + devDependencies: + '@refkit/provider-testkit': + specifier: workspace:* + version: link:../provider-testkit packages/provider-jamendo: dependencies: '@refkit/core': specifier: workspace:* version: link:../core + devDependencies: + '@refkit/provider-testkit': + specifier: workspace:* + version: link:../provider-testkit packages/provider-met: dependencies: '@refkit/core': specifier: workspace:* version: link:../core + devDependencies: + '@refkit/provider-testkit': + specifier: workspace:* + version: link:../provider-testkit packages/provider-openverse: dependencies: @@ -201,54 +231,89 @@ importers: '@refkit/core': specifier: workspace:* version: link:../core + devDependencies: + '@refkit/provider-testkit': + specifier: workspace:* + version: link:../provider-testkit packages/provider-pixabay: dependencies: '@refkit/core': specifier: workspace:* version: link:../core + devDependencies: + '@refkit/provider-testkit': + specifier: workspace:* + version: link:../provider-testkit packages/provider-poetrydb: dependencies: '@refkit/core': specifier: workspace:* version: link:../core + devDependencies: + '@refkit/provider-testkit': + specifier: workspace:* + version: link:../provider-testkit packages/provider-polyhaven: dependencies: '@refkit/core': specifier: workspace:* version: link:../core + devDependencies: + '@refkit/provider-testkit': + specifier: workspace:* + version: link:../provider-testkit packages/provider-rijksmuseum: dependencies: '@refkit/core': specifier: workspace:* version: link:../core + devDependencies: + '@refkit/provider-testkit': + specifier: workspace:* + version: link:../provider-testkit packages/provider-smithsonian: dependencies: '@refkit/core': specifier: workspace:* version: link:../core + devDependencies: + '@refkit/provider-testkit': + specifier: workspace:* + version: link:../provider-testkit packages/provider-testkit: dependencies: '@refkit/core': specifier: workspace:* version: link:../core + vitest: + specifier: ^3.2.6 + version: 3.2.6(@types/node@22.20.0)(tsx@4.22.4) packages/provider-unsplash: dependencies: '@refkit/core': specifier: workspace:* version: link:../core + devDependencies: + '@refkit/provider-testkit': + specifier: workspace:* + version: link:../provider-testkit packages/provider-wikimedia-commons: dependencies: '@refkit/core': specifier: workspace:* version: link:../core + devDependencies: + '@refkit/provider-testkit': + specifier: workspace:* + version: link:../provider-testkit packages: From 33fc146ea4a20c6b2766509e790f57f38d740429 Mon Sep 17 00:00:00 2001 From: MyPrototypeWhat Date: Mon, 6 Jul 2026 16:28:51 +0800 Subject: [PATCH 5/7] ci: weekly live-smoke workflow + docs No changeset: testkit is private, live suites and the workflow are dev-only, and provider devDependency additions do not change published artifacts. --- .github/workflows/live-smoke.yml | 32 ++++++++++++++++++++++++++++++++ README.md | 2 ++ 2 files changed, 34 insertions(+) create mode 100644 .github/workflows/live-smoke.yml diff --git a/.github/workflows/live-smoke.yml b/.github/workflows/live-smoke.yml new file mode 100644 index 0000000..3bac8c3 --- /dev/null +++ b/.github/workflows/live-smoke.yml @@ -0,0 +1,32 @@ +name: live-smoke + +on: + schedule: + - cron: '17 3 * * 1' # Mondays 03:17 UTC — weekly upstream drift check + workflow_dispatch: {} + +jobs: + live: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Live smoke (keyless always; BYOK when secrets exist) + env: + REFKIT_LIVE: '1' + UNSPLASH_KEY: ${{ secrets.UNSPLASH_KEY }} + PEXELS_KEY: ${{ secrets.PEXELS_KEY }} + PIXABAY_KEY: ${{ secrets.PIXABAY_KEY }} + FLICKR_KEY: ${{ secrets.FLICKR_KEY }} + SI_KEY: ${{ secrets.SI_KEY }} + BRAVE_TOKEN: ${{ secrets.BRAVE_TOKEN }} + FREESOUND_TOKEN: ${{ secrets.FREESOUND_TOKEN }} + JAMENDO_CLIENT_ID: ${{ secrets.JAMENDO_CLIENT_ID }} + EUROPEANA_KEY: ${{ secrets.EUROPEANA_KEY }} + run: pnpm vitest run diff --git a/README.md b/README.md index 0ed7443..c5d82e0 100644 --- a/README.md +++ b/README.md @@ -244,4 +244,6 @@ pnpm test:run # all packages pnpm build # tsup → dist for every package ``` +`REFKIT_LIVE=1 pnpm test:run` runs the live smoke suites against real provider APIs (BYOK ones need their key env vars set); the weekly cron in CI runs the same check. + Releases are automated with [changesets](https://github.com/changesets/changesets): run `pnpm changeset` to record a change; merging the CI-generated "Version Packages" PR publishes to npm. From aa9f96b9f8dc06a92de1f3719653e93be61b390b Mon Sep 17 00:00:00 2001 From: MyPrototypeWhat Date: Mon, 6 Jul 2026 16:34:31 +0800 Subject: [PATCH 6/7] ci: read-only permissions for live-smoke; commit wave plan --- .github/workflows/live-smoke.yml | 3 + .../plans/2026-07-03-test-infrastructure.md | 218 ++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-03-test-infrastructure.md diff --git a/.github/workflows/live-smoke.yml b/.github/workflows/live-smoke.yml index 3bac8c3..0ed8fe8 100644 --- a/.github/workflows/live-smoke.yml +++ b/.github/workflows/live-smoke.yml @@ -5,6 +5,9 @@ on: - cron: '17 3 * * 1' # Mondays 03:17 UTC — weekly upstream drift check workflow_dispatch: {} +permissions: + contents: read + jobs: live: runs-on: ubuntu-latest diff --git a/docs/superpowers/plans/2026-07-03-test-infrastructure.md b/docs/superpowers/plans/2026-07-03-test-infrastructure.md new file mode 100644 index 0000000..510d1b2 --- /dev/null +++ b/docs/superpowers/plans/2026-07-03-test-infrastructure.md @@ -0,0 +1,218 @@ +# Wave 3 — Test Infrastructure Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans, task-by-task. Checkboxes track steps. + +**Goal:** (3a) a private `@refkit/provider-testkit` package encoding the provider-conformance rules (D1–D8, H1–H6, schema validity, D8 image-URL rule, licenseVersion guard) as reusable executable assertions; (3b) env-gated live smoke tests against real provider APIs + a weekly GitHub Actions cron — upstream API drift becomes visible instead of silent. + +**Architecture:** testkit is `private: true` (never published), consumed as a workspace devDependency. Live smoke reuses the same conformance assertions so one helper serves both fixture tests and live tests. Live tests self-skip via `describe.skipIf` unless `REFKIT_LIVE=1` (and, for BYOK providers, the key env) — so `pnpm test:run` stays hermetic/green with zero config changes; the cron workflow opts in. + +**Tech Stack:** TypeScript ESM, vitest, zod (via core), pnpm workspaces, GitHub Actions. Branch: `m13t/wave3-test-infrastructure`. + +--- + +### Task W3.1: `@refkit/provider-testkit` package + +**Files:** +- Create: `packages/provider-testkit/package.json`, `tsconfig.json`, `src/index.ts` +- Test: `packages/provider-testkit/src/__tests__/testkit.test.ts` +- Modify: root `vitest.config.ts` ONLY IF it enumerates package paths (read it first; if it globs `packages/*`, no change). + +`package.json` (mirror a provider's shape — see provider-met — but `"private": true`, name `@refkit/provider-testkit`, version `0.0.0`, description "Conformance test helpers for refkit provider satellites (internal, not published).", dependency `"@refkit/core": "workspace:*"`, devDeps typescript+vitest matching siblings, scripts: typecheck only — no build/tsup needed since it's consumed from src via workspace). + +`src/index.ts`: + +```ts +import { + parseReference, isLikelyImageUrl, CC_FAMILY_BY_TOKEN, + type Reference, type ReferenceProvider, type NormalizedQuery, type ProviderContext, type LicenseId, +} from '@refkit/core' + +/** Licenses allowed to carry rights.licenseVersion (the six versioned CC families). */ +const VERSIONED: ReadonlySet = new Set(Object.values(CC_FAMILY_BY_TOKEN)) + +export interface ConformanceOptions { + /** Text query for the search. Default 'landscape'. */ + query?: string + /** Extra NormalizedQuery fields (providerOptions, limit, controls…). */ + queryExtras?: Partial + /** Providers whose modality is image-only must yield image-like preview/thumbnail URLs (D8). Default true when provider.modalities includes 'image'. */ + enforceImageUrls?: boolean +} + +/** Run provider.search through the given fetch and assert every conformance rule + * refkit demands of a satellite. Throws (with a per-item message) on violation; + * returns the parsed refs for further assertions. Works for fixture fetches AND + * the real global fetch (live smoke). */ +export async function searchConformant( + provider: ReferenceProvider, + fetchImpl: typeof fetch, + opts: ConformanceOptions = {}, +): Promise { + const query: NormalizedQuery = { + text: opts.query ?? 'landscape', + modalities: provider.modalities, + limit: 5, + ...opts.queryExtras, + } + const ctx: ProviderContext = { fetch: fetchImpl } + const raw = await provider.search(query, ctx) + const enforceImages = opts.enforceImageUrls ?? provider.modalities.includes('image') + return raw.map((item, i) => { + let ref: Reference + try { + ref = parseReference(item) // schema validity incl. provenance trio + rights record + } catch (e) { + throw new Error(`[${provider.id}] result #${i} failed referenceSchema: ${(e as Error).message}`) + } + if (!ref.id.startsWith(`${provider.id}:`) && !ref.source.providerId.startsWith(provider.id.split('-')[0])) { + throw new Error(`[${provider.id}] result #${i} id/providerId do not identify the provider (id=${ref.id}, providerId=${ref.source.providerId})`) + } + if (ref.rights.licenseVersion !== undefined && !VERSIONED.has(ref.rights.license)) { + throw new Error(`[${provider.id}] result #${i} carries licenseVersion on non-CC-family license ${ref.rights.license}`) + } + if (enforceImages) { + // D8: preview/thumbnail must be an image resource, never a web page + if (ref.thumbnail && !isLikelyImageUrl(ref.thumbnail.url)) { + throw new Error(`[${provider.id}] result #${i} thumbnail.url is not image-like: ${ref.thumbnail.url}`) + } + if (ref.preview && !ref.preview.mediaType.startsWith('image/') ) { + throw new Error(`[${provider.id}] result #${i} image preview has non-image mediaType: ${ref.preview.mediaType}`) + } + } + return ref + }) +} + +export interface LicenseMapCase { input: A; expect: { license: LicenseId; version?: string } | LicenseId } + +/** Data-driven license-mapping assertion: runs each case and reports every mismatch at once. */ +export function expectLicenseMap( + mapFn: (input: A) => { license: LicenseId; version?: string } | LicenseId, + cases: ReadonlyArray>, +): void { + const failures = cases.flatMap(({ input, expect: want }) => { + const got = mapFn(input) + return JSON.stringify(got) === JSON.stringify(want) ? [] : [`map(${JSON.stringify(input)}) = ${JSON.stringify(got)}, want ${JSON.stringify(want)}`] + }) + if (failures.length > 0) throw new Error(`license mapping mismatches:\n${failures.join('\n')}`) +} +``` + +Testkit's OWN tests (`__tests__/testkit.test.ts`): a minimal in-file fake provider + fixture fetch; assert (1) a conformant provider passes and returns parsed refs; (2) a provider emitting a page-URL thumbnail fails with the D8 message; (3) a ref with licenseVersion on `CC0-1.0` fails with the guard message; (4) expectLicenseMap reports mismatches and passes on exact matches. TDD: tests first (RED: module missing), then implement. + +Verify: `pnpm exec vitest run packages/provider-testkit && pnpm --filter @refkit/provider-testkit typecheck` and full `pnpm -r --parallel typecheck`. Commit: `feat(testkit): provider conformance helpers (private package)`. + +--- + +### Task W3.2: prove the testkit on two providers + +**Files:** +- Modify: `packages/provider-openverse/package.json` + `src/__tests__/openverse.test.ts` +- Modify: `packages/provider-europeana/package.json` + `src/__tests__/europeana.test.ts` + +Add `"@refkit/provider-testkit": "workspace:*"` to each package's devDependencies (`pnpm install` to link). In each test file ADD (do not remove existing tests) one conformance test reusing an existing fixture-backed fetch: openverse — the existing OPENVERSE fixture route; europeana — its okCtx-style fixture. Shape: + +```ts + it('passes provider conformance (testkit)', async () => { + const refs = await searchConformant(openverse(), fixtureFetch) + expect(refs.length).toBeGreaterThan(0) + }) +``` + +(Adapt fixture names to each file. If a fixture legitimately violates a rule — e.g. europeana page-URL previews are already handled by D8 logic in the provider — the conformance test passing IS the proof; if it fails, the testkit found a real gap: STOP and report BLOCKED with the output rather than weakening the testkit.) + +Verify: both provider suites + full `pnpm test:run` green. Commit: `test(providers): adopt testkit conformance checks in openverse + europeana`. + +--- + +### Task W3.3: live smoke tests + +**Files:** +- Create: `packages/provider-testkit/src/live.ts` (helper) exporting: + +```ts +import { describe, expect, it } from 'vitest' +import type { ReferenceProvider } from '@refkit/core' +import { searchConformant, type ConformanceOptions } from './index' + +/** Register an env-gated live smoke suite for a provider. Runs only with + * REFKIT_LIVE=1 (and, if keyEnv given, that env var set). One real query, + * full conformance assertions, 30s timeout. */ +export function liveSmoke( + name: string, + make: () => ReferenceProvider, + opts: ConformanceOptions & { keyEnv?: string } = {}, +): void { + const enabled = process.env.REFKIT_LIVE === '1' && (!opts.keyEnv || !!process.env[opts.keyEnv]) + describe.skipIf(!enabled)(`live smoke: ${name}`, () => { + it('returns conformant references from the real API', { timeout: 30_000 }, async () => { + const refs = await searchConformant(make(), globalThis.fetch, opts) + expect(refs.length).toBeGreaterThan(0) + }) + }) +} +``` + +- Create per-provider `src/__tests__/live.test.ts` in these packages (each ~6 lines, using liveSmoke; add testkit devDep where missing): + - keyless: met, artic, wikimedia-commons, openverse (`openverse()` and `openverseAudio()` in one file), gutendex, poetrydb, rijksmuseum, polyhaven (`polyhaven()` + `ambientcg()`), internet-archive + - BYOK (keyEnv per cli.ts convention): unsplash (`UNSPLASH_KEY`), pexels (`PEXELS_KEY`), pixabay (`PIXABAY_KEY`), flickr (`FLICKR_KEY`), smithsonian (`SI_KEY`), brave (`BRAVE_TOKEN`), freesound (`FREESOUND_TOKEN`), jamendo (`JAMENDO_CLIENT_ID`), europeana (`EUROPEANA_KEY`) + - BYOK factories take config objects — construct with the env var, e.g. `liveSmoke('unsplash', () => unsplash({ accessKey: process.env.UNSPLASH_KEY! }), { keyEnv: 'UNSPLASH_KEY' })`. + - Query choice: default 'landscape' works for image/audio; text providers (gutendex, poetrydb) pass `{ query: 'love' }`. brave results are license-unknown — conformance still passes (unknown is valid). +- Root `package.json`: add script `"test:live": "REFKIT_LIVE=1 vitest run"` (note in the plan: fine for macOS/Linux CI; Windows devs can set the var manually). + +Verify hermetic default: `pnpm test:run` → all live suites reported skipped, totals otherwise unchanged. Then a REAL spot check: `REFKIT_LIVE=1 pnpm exec vitest run packages/provider-met packages/provider-poetrydb` (two stable keyless APIs) — expect green; if an API is down/drifted, report findings rather than skipping silently. Commit: `test(providers): env-gated live smoke suites (REFKIT_LIVE=1)`. + +--- + +### Task W3.4: weekly cron workflow + docs + +**Files:** +- Create: `.github/workflows/live-smoke.yml`: + +```yaml +name: live-smoke +on: + schedule: + - cron: '17 3 * * 1' # Mondays 03:17 UTC — weekly upstream drift check + workflow_dispatch: {} + +jobs: + live: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Live smoke (keyless always; BYOK when secrets exist) + env: + REFKIT_LIVE: '1' + UNSPLASH_KEY: ${{ secrets.UNSPLASH_KEY }} + PEXELS_KEY: ${{ secrets.PEXELS_KEY }} + PIXABAY_KEY: ${{ secrets.PIXABAY_KEY }} + FLICKR_KEY: ${{ secrets.FLICKR_KEY }} + SI_KEY: ${{ secrets.SI_KEY }} + BRAVE_TOKEN: ${{ secrets.BRAVE_TOKEN }} + FREESOUND_TOKEN: ${{ secrets.FREESOUND_TOKEN }} + JAMENDO_CLIENT_ID: ${{ secrets.JAMENDO_CLIENT_ID }} + EUROPEANA_KEY: ${{ secrets.EUROPEANA_KEY }} + run: pnpm vitest run +``` + +(Mirror ci.yml's actual setup steps — read it first and copy its pnpm/node versions; the env-gated skipIf means a plain `vitest run` runs live suites plus normal suites, which doubles as a full check. If ci.yml uses different action versions, match them.) +- README Develop section: add one line — `REFKIT_LIVE=1 pnpm test:run` runs live smoke against real APIs (weekly cron does this in CI). +- No changeset: testkit is private, live tests + workflow are dev-only, provider devDependency additions don't change published artifacts. State this explicitly in the commit body. + +Verify: `pnpm -r --parallel typecheck && pnpm test:run` all green; `actionlint` if available (else careful YAML read). Commit: `ci: weekly live-smoke workflow + docs`. + +--- + +## Self-review notes + +- Testkit deliberately throws plain Errors (framework-agnostic) — vitest surfaces them fine; no vitest dependency in index.ts (live.ts DOES import vitest — that's fine, it's only used from test files). +- searchConformant's id check is heuristic (openverse-audio's id prefix is 'openverse-audio' while provider.id matches) — implementer: verify against real provider id conventions and loosen ONLY as far as needed; report what rule you settled on. +- Line anchors may drift; locate by quoted code. From 41b8628b0f3917594669096d1038ad49e0a4c78d Mon Sep 17 00:00:00 2001 From: MyPrototypeWhat Date: Mon, 6 Jul 2026 18:40:44 +0800 Subject: [PATCH 7/7] fix(testkit): consume core's canonical CC_VERSIONED_FAMILIES instead of deriving from the token table --- packages/core/src/index.ts | 2 +- packages/core/src/provider-helpers.ts | 4 +++- packages/provider-testkit/src/index.ts | 7 ++++--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7bf122f..1f013e9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -46,7 +46,7 @@ export type { export { setIfString, setIfBoolean, setIfStringList, setIfInt, setIfPositiveInt, setIfNonNegativeInt, setIfNumber, - first, mapCcDeedUrl, mapRightsUrl, ccVersionFor, CC_FAMILY_BY_TOKEN, + first, mapCcDeedUrl, mapRightsUrl, ccVersionFor, CC_FAMILY_BY_TOKEN, CC_VERSIONED_FAMILIES, isLikelyImageUrl, imageMediaType, IMAGE_EXT, } from './provider-helpers' export { normalizeQuery } from './query' diff --git a/packages/core/src/provider-helpers.ts b/packages/core/src/provider-helpers.ts index d85f41c..83036d3 100644 --- a/packages/core/src/provider-helpers.ts +++ b/packages/core/src/provider-helpers.ts @@ -94,7 +94,9 @@ export function mapCcDeedUrl(url: string | undefined | null): { license: License return { license: 'unknown' } } -const CC_VERSIONED_FAMILIES: ReadonlySet = new Set([ +/** Canonical membership set backing `ccVersionFor`; also consumed by the testkit's + * licenseVersion conformance rule. */ +export const CC_VERSIONED_FAMILIES: ReadonlySet = new Set([ 'CC-BY', 'CC-BY-SA', 'CC-BY-NC', 'CC-BY-NC-SA', 'CC-BY-NC-ND', 'CC-BY-ND', ]) diff --git a/packages/provider-testkit/src/index.ts b/packages/provider-testkit/src/index.ts index 61fd2a9..9790a32 100644 --- a/packages/provider-testkit/src/index.ts +++ b/packages/provider-testkit/src/index.ts @@ -1,10 +1,11 @@ import { - parseReference, isLikelyImageUrl, CC_FAMILY_BY_TOKEN, + parseReference, isLikelyImageUrl, CC_VERSIONED_FAMILIES, type Reference, type ReferenceProvider, type NormalizedQuery, type ProviderContext, type LicenseId, } from '@refkit/core' -/** Licenses allowed to carry rights.licenseVersion (the six versioned CC families). */ -const VERSIONED: ReadonlySet = new Set(Object.values(CC_FAMILY_BY_TOKEN)) +/** Licenses allowed to carry rights.licenseVersion (the six versioned CC families) — + * core's canonical membership set, kept in sync with `ccVersionFor`. */ +const VERSIONED: ReadonlySet = CC_VERSIONED_FAMILIES export interface ConformanceOptions { /** Text query for the search. Default 'landscape'. */