From bebd01e46d637015afa90f11b30c6b4598a6cd2f Mon Sep 17 00:00:00 2001 From: nd0ut Date: Tue, 6 Oct 2026 03:31:10 +0300 Subject: [PATCH 001/104] test(ai-image-editor): run the specs and browser tests against @uploadcare/api-emulator The derivative API specs and the editor/plugin browser tests now get their Upload API and CDN from the emulator instead of hand-written responses: - specs inject a fetch backed by the emulator's handle(), and getFileInfo runs against an emulator server through upload-client's own transport (replacing the isReadyPoll module mock); - browser tests route Uploadcare's hosts (and the tests' cdn.example.com cname) to handle() through a vitest browser command, with a fresh session per test; editor tests use the instant-derivatives project so a generation doesn't wait out the 1.5s poll interval; - Bearer tokens are real ones the emulator verifies. Stubs stay only where the emulator can't produce the case: a bare non-JSON failure, a poll that hangs until aborted or never finishes. The emulator is a TEMPORARY file: dependency on an unreleased checkout, so npm ci fails in CI until it ships and this becomes a version range. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 25 + package-lock.json | 25 + packages/ai-image-editor/package.json | 1 + .../entities/provider/api/emulator.testing.ts | 28 ++ .../provider/api/uploadcareApiClient.test.ts | 262 +++++------ .../api/uploadcareDerivativeApi.test.ts | 442 +++++++----------- .../tests/editor/aspect-ratio.e2e.test.tsx | 10 +- .../tests/editor/auth-token.e2e.test.tsx | 69 ++- .../tests/editor/filename.e2e.test.tsx | 8 +- .../tests/editor/generation.e2e.test.tsx | 66 +-- .../ai-image-editor/tests/editor/harness.ts | 83 ++-- .../tests/editor/history.e2e.test.tsx | 8 +- .../tests/editor/layout.e2e.test.tsx | 6 +- .../tests/editor/sizing.e2e.test.tsx | 4 +- packages/ai-image-editor/tests/emulator.ts | 91 ++++ packages/ai-image-editor/tests/setup.ts | 7 + packages/ai-image-editor/vitest.config.ts | 3 + 17 files changed, 612 insertions(+), 526 deletions(-) create mode 100644 packages/ai-image-editor/src/entities/provider/api/emulator.testing.ts create mode 100644 packages/ai-image-editor/tests/emulator.ts create mode 100644 packages/ai-image-editor/tests/setup.ts diff --git a/AGENTS.md b/AGENTS.md index 8dd55ed..6471b59 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,31 @@ packages again would give the plugin two `AuthTokenResolverError` classes: `instanceof` fails across them and the editor wraps the uploader's error a second time, pushing the original one level deeper on `cause`. +## Tests talk to the Uploadcare API emulator + +`@uploadcare/api-emulator` (a devDependency of `packages/ai-image-editor`) +stands in for the Upload API and CDN; tests should not hand-write Uploadcare +responses. + +- **Specs** (happy-dom) inject `emulatorFetch()` from + `src/entities/provider/api/emulator.testing.ts` as the client's `fetch`, and + call `resetSession()` before each test. `getFileInfo` goes through + upload-client's own transport, so those specs start a real emulator server + (`@uploadcare/api-emulator/listen`) and pass its origin as `baseUrl`. +- **Browser e2e** get the emulator from `tests/emulator.ts`: browser commands + that `page.route` Uploadcare's hosts (and `cdn.example.com`, the tests' CDN + cname) to `handle()`, with a fresh session before every test + (`tests/setup.ts`). The editor tests use `DERIVATIVE_INSTANT_PUBLIC_KEY` so a + generation finishes on its first poll instead of after the editor's 1.5s + interval several times over. +- Bearer tokens must be real: mint them with `mintAuthToken()` (specs) or + `commands.mintAuthToken()` (e2e). +- A hand-written stub is still right for what the emulator can't do (a bare + non-JSON failure, a poll that hangs or never finishes); say why next to it. + +The dependency is a TEMPORARY `file:` link to an unreleased checkout until the +package ships; swap it for a version range then and drop the TEMPORARY comments. + ## Docs layout `docs/` is a VitePress site published to GitHub Pages: hand-written guides in diff --git a/package-lock.json b/package-lock.json index cc13342..2871af3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,26 @@ "node": ">=20" } }, + "../uc-586-sync/packages/api-emulator": { + "name": "@uploadcare/api-emulator", + "version": "6.23.0", + "dev": true, + "license": "MIT", + "devDependencies": { + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "oxfmt": "^0.53.0", + "oxlint": "^1.68.0", + "oxlint-tsgolint": "^0.23.0", + "vite": "^7.0.0", + "vite-plugin-dts": "^4.5.4", + "vitest": "^3.2.4", + "yaml": "^2.9.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@algolia/abtesting": { "version": "1.21.1", "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.21.1.tgz", @@ -4225,6 +4245,10 @@ "resolved": "packages/ai-image-editor", "link": true }, + "node_modules/@uploadcare/api-emulator": { + "resolved": "../uc-586-sync/packages/api-emulator", + "link": true + }, "node_modules/@uploadcare/cdn-url": { "version": "6.20.0-alpha.6", "resolved": "https://registry.npmjs.org/@uploadcare/cdn-url/-/cdn-url-6.20.0-alpha.6.tgz", @@ -15212,6 +15236,7 @@ "devDependencies": { "@custom-elements-manifest/analyzer": "^0.11.0", "@custom-elements-manifest/to-markdown": "^0.1.0", + "@uploadcare/api-emulator": "file:/Users/nd0ut/workspace/uc-586-sync/packages/api-emulator", "@uploadcare/file-uploader": "1.34.0", "@vitest/browser-playwright": "^4.1.10", "lit-analyzer": "^2.0.3", diff --git a/packages/ai-image-editor/package.json b/packages/ai-image-editor/package.json index 7a088a2..8d8c1fd 100644 --- a/packages/ai-image-editor/package.json +++ b/packages/ai-image-editor/package.json @@ -60,6 +60,7 @@ "devDependencies": { "@custom-elements-manifest/analyzer": "^0.11.0", "@custom-elements-manifest/to-markdown": "^0.1.0", + "@uploadcare/api-emulator": "file:/Users/nd0ut/workspace/uc-586-sync/packages/api-emulator", "@uploadcare/file-uploader": "1.34.0", "@vitest/browser-playwright": "^4.1.10", "lit-analyzer": "^2.0.3", diff --git a/packages/ai-image-editor/src/entities/provider/api/emulator.testing.ts b/packages/ai-image-editor/src/entities/provider/api/emulator.testing.ts new file mode 100644 index 0000000..b9d6f50 --- /dev/null +++ b/packages/ai-image-editor/src/entities/provider/api/emulator.testing.ts @@ -0,0 +1,28 @@ +// TEMPORARY: `@uploadcare/api-emulator` is a `file:` dependency on an unreleased +// checkout (see packages/ai-image-editor/package.json); swap it for a version +// range once the package ships, and drop this comment. +import { handle, SIGNED_UPLOADS_SECRET_KEY } from '@uploadcare/api-emulator'; +import { generateAuthToken } from '@uploadcare/signed-uploads/server'; +import { vi } from 'vitest'; + +/** + * A `fetch` answered by the Uploadcare API emulator instead of the network, + * spied so a spec can read back what was sent. Reset the emulator's state with + * `resetSession()` between tests. + */ +export const emulatorFetch = () => + vi.fn(async (input, init) => { + // What a real fetch does with a signal that is already aborted. + init?.signal?.throwIfAborted(); + const request = new Request(input, init); + const response = await handle(request); + if (!response) throw new TypeError(`The emulator does not implement ${request.method} ${request.url}`); + return response; + }); + +/** A Bearer token the emulator accepts; `id` keeps two tokens minted in the same second apart. */ +export const mintAuthToken = (id = 'token') => + generateAuthToken(SIGNED_UPLOADS_SECRET_KEY, { lifetime: 60_000, tokenId: id }); + +/** A stored image every fresh emulator session starts with. */ +export const SEEDED_IMAGE_UUID = '49b4c5a1-31b3-4349-ba07-d97a2d883c37'; diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts index c61603c..90d3d72 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts @@ -1,14 +1,26 @@ -import { describe, expect, it, vi } from 'vitest'; +// TEMPORARY: file: dependency, see emulator.testing.ts. +import { DERIVATIVE_DISABLED_PUBLIC_KEY, resetSession } from '@uploadcare/api-emulator'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AiProviderError } from '../model/types'; +import { emulatorFetch, mintAuthToken, SEEDED_IMAGE_UUID } from './emulator.testing'; import { UploadcareApiClient } from './uploadcareApiClient'; -function jsonResponse(body: unknown, init: ResponseInit = {}): Response { - return new Response(JSON.stringify(body), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - ...init, - }); -} +const PUBLIC_KEY = 'demopublickey'; + +const sentInit = (fetchImpl: ReturnType, call = 0) => + fetchImpl.mock.calls[call]![1] as RequestInit; +const sentBody = (fetchImpl: ReturnType, call = 0) => + JSON.parse(sentInit(fetchImpl, call).body as string); + +/** + * The emulator answers every refusal with the JSON error envelope, so it can't + * produce the bare non-2xx, non-JSON response a proxy or an outage would. This + * stub stands in for that. + */ +const plainTextFailure = (status: number, statusText: string) => + vi.fn().mockResolvedValue(new Response('upstream failure', { status, statusText })); + +beforeEach(() => resetSession()); describe('UploadcareApiClient', () => { it('throws when publicKey is missing', () => { @@ -16,55 +28,54 @@ describe('UploadcareApiClient', () => { }); describe('authToken', () => { - const headersOf = (call: unknown) => new Headers((call as [string, RequestInit])[1].headers); + const authOf = (fetchImpl: ReturnType, call: number) => + new Headers(sentInit(fetchImpl, call).headers).get('Authorization'); it('sends no Authorization header when unset', async () => { - const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ type: 'job', job_id: 'j' })); - const client = new UploadcareApiClient({ publicKey: 'pk', fetch: fetchImpl }); + const fetchImpl = emulatorFetch(); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: fetchImpl }); await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); - expect(headersOf(fetchImpl.mock.calls[0]!).has('Authorization')).toBe(false); + expect(authOf(fetchImpl, 0)).toBeNull(); }); it('sends a plain token as a bearer header', async () => { - const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ type: 'job', job_id: 'j' })); - const client = new UploadcareApiClient({ publicKey: 'pk', fetch: fetchImpl, authToken: 'eyJ' }); + const token = mintAuthToken(); + const fetchImpl = emulatorFetch(); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: fetchImpl, authToken: token }); await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); - expect(headersOf(fetchImpl.mock.calls[0]!).get('Authorization')).toBe('Bearer eyJ'); + expect(authOf(fetchImpl, 0)).toBe(`Bearer ${token}`); }); it('re-resolves a resolver per request, so a job can rotate tokens mid-flight', async () => { - const fetchImpl = vi - .fn() - .mockResolvedValueOnce(jsonResponse({ type: 'job', job_id: 'j' })) - .mockResolvedValueOnce(jsonResponse({ type: 'status', status: 'pending' })); - const authToken = vi.fn().mockResolvedValueOnce('first').mockResolvedValueOnce('second'); - const client = new UploadcareApiClient({ publicKey: 'pk', fetch: fetchImpl, authToken }); + const [first, second] = [mintAuthToken('first'), mintAuthToken('second')]; + const fetchImpl = emulatorFetch(); + const authToken = vi.fn().mockResolvedValueOnce(first).mockResolvedValueOnce(second); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: fetchImpl, authToken }); - await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); - await client.getJobStatus('j'); + const { job_id } = await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); + await client.getJobStatus(job_id!); - expect(headersOf(fetchImpl.mock.calls[0]!).get('Authorization')).toBe('Bearer first'); - expect(headersOf(fetchImpl.mock.calls[1]!).get('Authorization')).toBe('Bearer second'); + expect(authOf(fetchImpl, 0)).toBe(`Bearer ${first}`); + expect(authOf(fetchImpl, 1)).toBe(`Bearer ${second}`); }); }); describe('generate', () => { it('POSTs pub_key + prompt + aspect_ratio + filename and returns the job', async () => { - const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ type: 'job', job_id: 'job-1' })); - const client = new UploadcareApiClient({ publicKey: 'pk', fetch: fetchImpl }); + const fetchImpl = emulatorFetch(); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: fetchImpl }); const job = await client.generate({ prompt: 'a hat', aspectRatio: [16, 9], filename: 'generated.png' }); - expect(job).toEqual({ type: 'job', job_id: 'job-1' }); - const [url, init] = fetchImpl.mock.calls[0]! as [string, RequestInit]; - expect(url).toBe('https://upload.uploadcare.com/derivative/image/generate/'); - expect(init.method).toBe('POST'); - expect(JSON.parse(init.body as string)).toMatchObject({ - pub_key: 'pk', + expect(job).toEqual({ type: 'job', job_id: expect.any(String) }); + expect(fetchImpl.mock.calls[0]![0]).toBe('https://upload.uploadcare.com/derivative/image/generate/'); + expect(sentInit(fetchImpl).method).toBe('POST'); + expect(sentBody(fetchImpl)).toMatchObject({ + pub_key: PUBLIC_KEY, prompt: 'a hat', aspect_ratio: [16, 9], filename: 'generated.png', @@ -72,194 +83,177 @@ describe('UploadcareApiClient', () => { }); it('includes store only when provided', async () => { - const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ type: 'job', job_id: 'j' })); - const client = new UploadcareApiClient({ publicKey: 'pk', fetch: fetchImpl }); + const fetchImpl = emulatorFetch(); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: fetchImpl }); + + await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png', store: true }); - const [, init] = fetchImpl.mock.calls[0]! as [string, RequestInit]; - expect(JSON.parse(init.body as string).store).toBe(true); + + expect(sentBody(fetchImpl, 0).store).toBeUndefined(); + expect(sentBody(fetchImpl, 1).store).toBe(true); }); it('includes metadata only when provided', async () => { - const fetchImpl = vi - .fn() - .mockImplementation(async () => jsonResponse({ type: 'job', job_id: 'j' })); - const client = new UploadcareApiClient({ publicKey: 'pk', fetch: fetchImpl }); + const fetchImpl = emulatorFetch(); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: fetchImpl }); await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); - expect(JSON.parse((fetchImpl.mock.calls[0]![1] as RequestInit).body as string).metadata).toBeUndefined(); - await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png', metadata: { source: 'ai-image-editor' }, }); - expect(JSON.parse((fetchImpl.mock.calls[1]![1] as RequestInit).body as string).metadata).toEqual({ - source: 'ai-image-editor', - }); + + expect(sentBody(fetchImpl, 0).metadata).toBeUndefined(); + expect(sentBody(fetchImpl, 1).metadata).toEqual({ source: 'ai-image-editor' }); }); it('honours baseUrl override', async () => { - const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ type: 'job', job_id: 'j' })); + const fetchImpl = emulatorFetch(); const client = new UploadcareApiClient({ - publicKey: 'pk', + publicKey: PUBLIC_KEY, baseUrl: 'https://upload.example.com/', fetch: fetchImpl, }); await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); - const [url] = fetchImpl.mock.calls[0]! as [string, RequestInit]; - expect(url).toBe('https://upload.example.com/derivative/image/generate/'); + expect(fetchImpl.mock.calls[0]![0]).toBe('https://upload.example.com/derivative/image/generate/'); }); - it('throws with status text on non-2xx', async () => { - const fetchImpl = vi - .fn() - .mockResolvedValue(new Response('bad ratio', { status: 400, statusText: 'Bad Request' })); - const client = new UploadcareApiClient({ publicKey: 'pk', fetch: fetchImpl }); + it('throws with status text on a non-2xx response that is not the error envelope', async () => { + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: plainTextFailure(400, 'Bad Request') }); await expect(client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' })).rejects.toThrow(/400/); }); it('forwards the abort signal', async () => { - const fetchImpl = vi.fn().mockImplementation(async (_url, init) => { - expect(init?.signal).toBeInstanceOf(AbortSignal); - return jsonResponse({ type: 'job', job_id: 'j' }); - }); - const client = new UploadcareApiClient({ publicKey: 'pk', fetch: fetchImpl }); + const fetchImpl = emulatorFetch(); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: fetchImpl }); const controller = new AbortController(); await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png', signal: controller.signal }); + expect(sentInit(fetchImpl).signal).toBe(controller.signal); }); it('sends Accept: application/json', async () => { - const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ type: 'job', job_id: 'j' })); - const client = new UploadcareApiClient({ publicKey: 'pk', fetch: fetchImpl }); + const fetchImpl = emulatorFetch(); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: fetchImpl }); await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); - const [, init] = fetchImpl.mock.calls[0]! as [string, RequestInit]; - expect((init.headers as Record).Accept).toBe('application/json'); + expect((sentInit(fetchImpl).headers as Record).Accept).toBe('application/json'); }); it('surfaces a platform error envelope as an AiProviderError with its code', async () => { - const fetchImpl = vi.fn().mockResolvedValue( - jsonResponse({ - error: { status_code: 400, error_code: 'canvas_too_large', content: 'Canvas size exceeds the 4MP limit.' }, - }), - ); - const client = new UploadcareApiClient({ publicKey: 'pk', fetch: fetchImpl }); + const client = new UploadcareApiClient({ publicKey: DERIVATIVE_DISABLED_PUBLIC_KEY, fetch: emulatorFetch() }); await expect(client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' })).rejects.toMatchObject({ name: 'AiProviderError', - errorCode: 'canvas_too_large', - message: 'Canvas size exceeds the 4MP limit.', + errorCode: 'derivative_disabled', + message: 'Derivatives are not enabled for this project.', }); }); }); describe('edit', () => { it('POSTs pub_key + prompt + source uuid + filename to the edit endpoint', async () => { - const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ type: 'job', job_id: 'job-e' })); - const client = new UploadcareApiClient({ publicKey: 'pk', fetch: fetchImpl }); + const fetchImpl = emulatorFetch(); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: fetchImpl }); - const job = await client.edit({ prompt: 'remove the cat', source: 'src-uuid', filename: 'edited.png' }); + const job = await client.edit({ prompt: 'remove the cat', source: SEEDED_IMAGE_UUID, filename: 'edited.png' }); - expect(job).toEqual({ type: 'job', job_id: 'job-e' }); - const [url, init] = fetchImpl.mock.calls[0]! as [string, RequestInit]; - expect(url).toBe('https://upload.uploadcare.com/derivative/image/edit/'); - expect(init.method).toBe('POST'); - expect(JSON.parse(init.body as string)).toMatchObject({ - pub_key: 'pk', + expect(job).toEqual({ type: 'job', job_id: expect.any(String) }); + expect(fetchImpl.mock.calls[0]![0]).toBe('https://upload.uploadcare.com/derivative/image/edit/'); + expect(sentInit(fetchImpl).method).toBe('POST'); + expect(sentBody(fetchImpl)).toMatchObject({ + pub_key: PUBLIC_KEY, prompt: 'remove the cat', - source: 'src-uuid', + source: SEEDED_IMAGE_UUID, filename: 'edited.png', }); }); it('includes aspect_ratio only when provided', async () => { - // Fresh Response per call — a single shared Response body can be read once. - const fetchImpl = vi - .fn() - .mockImplementation(async () => jsonResponse({ type: 'job', job_id: 'j' })); - const client = new UploadcareApiClient({ publicKey: 'pk', fetch: fetchImpl }); + const fetchImpl = emulatorFetch(); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: fetchImpl }); - await client.edit({ prompt: 'x', source: 'u', filename: 'f.png' }); - expect(JSON.parse((fetchImpl.mock.calls[0]![1] as RequestInit).body as string).aspect_ratio).toBeUndefined(); + await client.edit({ prompt: 'x', source: SEEDED_IMAGE_UUID, filename: 'f.png' }); + await client.edit({ prompt: 'x', source: SEEDED_IMAGE_UUID, filename: 'f.png', aspectRatio: [16, 9] }); - await client.edit({ prompt: 'x', source: 'u', filename: 'f.png', aspectRatio: [16, 9] }); - expect(JSON.parse((fetchImpl.mock.calls[1]![1] as RequestInit).body as string).aspect_ratio).toEqual([16, 9]); + expect(sentBody(fetchImpl, 0).aspect_ratio).toBeUndefined(); + expect(sentBody(fetchImpl, 1).aspect_ratio).toEqual([16, 9]); }); it('includes metadata only when provided', async () => { - const fetchImpl = vi - .fn() - .mockImplementation(async () => jsonResponse({ type: 'job', job_id: 'j' })); - const client = new UploadcareApiClient({ publicKey: 'pk', fetch: fetchImpl }); + const fetchImpl = emulatorFetch(); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: fetchImpl }); - await client.edit({ prompt: 'x', source: 'u', filename: 'f.png' }); - expect(JSON.parse((fetchImpl.mock.calls[0]![1] as RequestInit).body as string).metadata).toBeUndefined(); + await client.edit({ prompt: 'x', source: SEEDED_IMAGE_UUID, filename: 'f.png' }); + await client.edit({ + prompt: 'x', + source: SEEDED_IMAGE_UUID, + filename: 'f.png', + metadata: { source: 'ai-image-editor' }, + }); + + expect(sentBody(fetchImpl, 0).metadata).toBeUndefined(); + expect(sentBody(fetchImpl, 1).metadata).toEqual({ source: 'ai-image-editor' }); + }); - await client.edit({ prompt: 'x', source: 'u', filename: 'f.png', metadata: { source: 'ai-image-editor' } }); - expect(JSON.parse((fetchImpl.mock.calls[1]![1] as RequestInit).body as string).metadata).toEqual({ - source: 'ai-image-editor', + it('surfaces a source the project does not have as an AiProviderError', async () => { + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: emulatorFetch() }); + await expect(client.edit({ prompt: 'x', source: 'missing', filename: 'f.png' })).rejects.toMatchObject({ + name: 'AiProviderError', + errorCode: 'source_not_found', }); }); - it('throws with status text on non-2xx', async () => { - const fetchImpl = vi - .fn() - .mockResolvedValue(new Response('bad source', { status: 400, statusText: 'Bad Request' })); - const client = new UploadcareApiClient({ publicKey: 'pk', fetch: fetchImpl }); + it('throws with status text on a non-2xx response that is not the error envelope', async () => { + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: plainTextFailure(400, 'Bad Request') }); await expect(client.edit({ prompt: 'x', source: 'u', filename: 'f.png' })).rejects.toThrow(/400/); }); it('forwards the abort signal', async () => { - const fetchImpl = vi.fn().mockImplementation(async (_url, init) => { - expect(init?.signal).toBeInstanceOf(AbortSignal); - return jsonResponse({ type: 'job', job_id: 'j' }); - }); - const client = new UploadcareApiClient({ publicKey: 'pk', fetch: fetchImpl }); + const fetchImpl = emulatorFetch(); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: fetchImpl }); const controller = new AbortController(); - await client.edit({ prompt: 'x', source: 'u', filename: 'f.png', signal: controller.signal }); + await client.edit({ prompt: 'x', source: SEEDED_IMAGE_UUID, filename: 'f.png', signal: controller.signal }); + expect(sentInit(fetchImpl).signal).toBe(controller.signal); }); }); describe('getJobStatus', () => { + const startJob = async (client: UploadcareApiClient) => + (await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' })).job_id!; + it('GETs the status endpoint with pub_key + job_id and returns the parsed status', async () => { - const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ type: 'job', status: 'processing' })); - const client = new UploadcareApiClient({ publicKey: 'pk', fetch: fetchImpl }); + const fetchImpl = emulatorFetch(); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: fetchImpl }); + const jobId = await startJob(client); - const status = await client.getJobStatus('job-42'); + const status = await client.getJobStatus(jobId); expect(status).toEqual({ type: 'job', status: 'processing' }); - const [url, init] = fetchImpl.mock.calls[0]! as [string, RequestInit]; - expect(url).toBe('https://upload.uploadcare.com/derivative/status/?pub_key=pk&job_id=job-42'); - expect((init?.method ?? 'GET').toUpperCase()).toBe('GET'); + expect(fetchImpl.mock.calls[1]![0]).toBe( + `https://upload.uploadcare.com/derivative/status/?pub_key=${PUBLIC_KEY}&job_id=${jobId}`, + ); + expect(sentInit(fetchImpl, 1).method).toBe('GET'); }); - it('throws with status text on non-2xx', async () => { - const fetchImpl = vi - .fn() - .mockResolvedValue(new Response('nope', { status: 404, statusText: 'Not Found' })); - const client = new UploadcareApiClient({ publicKey: 'pk', fetch: fetchImpl }); + it('throws with status text on a non-2xx response that is not the error envelope', async () => { + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: plainTextFailure(404, 'Not Found') }); await expect(client.getJobStatus('job-1')).rejects.toThrow(/404/); }); it('surfaces a platform error envelope (e.g. job_not_found) as an AiProviderError', async () => { - const fetchImpl = vi.fn().mockResolvedValue( - jsonResponse({ - error: { status_code: 404, error_code: 'job_not_found', content: 'Derivative job is not found.' }, - }), - ); - const client = new UploadcareApiClient({ publicKey: 'pk', fetch: fetchImpl }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: emulatorFetch() }); const err = await client.getJobStatus('job-1').catch((e) => e); expect(err).toBeInstanceOf(AiProviderError); expect(err.errorCode).toBe('job_not_found'); }); it('forwards the abort signal', async () => { - const fetchImpl = vi.fn().mockImplementation(async (_url, init) => { - expect(init?.signal).toBeInstanceOf(AbortSignal); - return jsonResponse({ type: 'job', status: 'processing' }); - }); - const client = new UploadcareApiClient({ publicKey: 'pk', fetch: fetchImpl }); + const fetchImpl = emulatorFetch(); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: fetchImpl }); + const jobId = await startJob(client); const controller = new AbortController(); - await client.getJobStatus('job-1', controller.signal); + await client.getJobStatus(jobId, controller.signal); + expect(sentInit(fetchImpl, 1).signal).toBe(controller.signal); }); }); }); diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts index b95932c..6b96509 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts @@ -1,72 +1,45 @@ +// TEMPORARY: file: dependency, see emulator.testing.ts. +import { CONTENT_MODERATED_PROMPT, resetSession, SIGNED_UPLOADS_PUBLIC_KEY } from '@uploadcare/api-emulator'; +import { createEmulatorServer } from '@uploadcare/api-emulator/listen'; import { getPrefixedCdnBaseAsync } from '@uploadcare/cname-prefix/async'; -import { isReadyPoll } from '@uploadcare/upload-client'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { AiProviderError } from '../model/types'; +import { emulatorFetch, mintAuthToken, SEEDED_IMAGE_UUID } from './emulator.testing'; import { UploadcareDerivativeApi } from './uploadcareDerivativeApi'; -// Keep the real UploadcareFile (used to wrap results) but stub the `isReadyPoll` -// network call (used by getFileInfo) so it's exercised without the Upload API. -vi.mock('@uploadcare/upload-client', async (importActual) => { - const actual = await importActual(); - return { ...actual, isReadyPoll: vi.fn() }; -}); -const mockIsReadyPoll = vi.mocked(isReadyPoll); +const PUBLIC_KEY = 'demopublickey'; +const CDN = 'https://cdn.example.com'; +const NO_DELAY = { pollIntervalMs: 0 } as const; -function jsonResponse(body: unknown, init: ResponseInit = {}): Response { - return new Response(JSON.stringify(body), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - ...init, - }); -} +const sentInit = (fetchImpl: ReturnType>, call = 0) => + fetchImpl.mock.calls[call]![1] as RequestInit; +const sentBody = (fetchImpl: ReturnType>, call = 0) => + JSON.parse(sentInit(fetchImpl, call).body as string) as Record; -function readSentBody(init: RequestInit | undefined): Record { - return JSON.parse(init?.body as string) as Record; -} +const jsonResponse = (body: unknown) => + new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' } }); /** - * A complete `derivative/status/` success frame minus `uuid` and `status` (which - * every test sets). Merged under each success fixture so the strict dev-schema - * validates them and the readiness poll ends — tests set only what they assert. + * A job that never finishes. The emulator's jobs always reach a terminal frame + * within four polls, so it can't keep `poll` waiting long enough to time out. */ -const SUCCESS_FRAME_DEFAULTS = { - file_id: '', - size: 0, - done: 0, - total: 0, - original_filename: '', - filename: '', - mime_type: 'image/png', - is_image: true, - is_stored: false, - is_ready: true, - image_info: null, - video_info: null, - content_info: null, - metadata: {}, -} as const; +const neverFinishingFetch = () => + vi.fn(async (_url, init) => + jsonResponse(init?.method === 'POST' ? { type: 'job', job_id: 'job-slow' } : { type: 'job', status: 'processing' }), + ); /** - * Builds a fetch mock that answers the generate POST with `jobResponse` and - * then walks through `statuses` on each successive status GET. + * `getFileInfo` goes through upload-client's own transport (node:http here), + * not the injected fetch, so it needs the emulator as a real origin. Both + * paths share the emulator's default session. */ -function routedFetch(jobResponse: unknown, statuses: unknown[]): ReturnType> { - let statusIndex = 0; - return vi.fn().mockImplementation(async (url, init) => { - const method = (init?.method ?? 'GET').toUpperCase(); - if (method === 'POST') return jsonResponse(jobResponse); - const body = statuses[Math.min(statusIndex, statuses.length - 1)]; - statusIndex += 1; - void url; - const frame = - body && typeof body === 'object' && 'status' in body && body.status === 'success' - ? { ...SUCCESS_FRAME_DEFAULTS, ...body } - : body; - return jsonResponse(frame); - }); -} - -const NO_DELAY = { pollIntervalMs: 0 } as const; +let uploadOrigin: string; +let closeEmulator: () => Promise; +beforeAll(async () => { + ({ origin: uploadOrigin, close: closeEmulator } = await createEmulatorServer({ delayMs: 0 })); +}); +afterAll(() => closeEmulator()); +beforeEach(() => resetSession()); describe('UploadcareDerivativeApi', () => { it('throws when publicKey is missing', () => { @@ -74,16 +47,15 @@ describe('UploadcareDerivativeApi', () => { }); it('POSTs the prompt + aspect ratio + pub_key to the derivative endpoint', async () => { - const fetchImpl = routedFetch({ type: 'job', job_id: 'job-1' }, [{ status: 'success', uuid: 'abc-123' }]); - const provider = new UploadcareDerivativeApi({ publicKey: 'pk_test', fetch: fetchImpl, ...NO_DELAY }); + const fetchImpl = emulatorFetch(); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: fetchImpl, ...NO_DELAY }); await provider.generate({ prompt: 'a hat', mode: 'generate', aspectRatio: [16, 9] }); - const [url, init] = fetchImpl.mock.calls[0]! as [string, RequestInit]; - expect(url).toBe('https://upload.uploadcare.com/derivative/image/generate/'); - expect(init.method).toBe('POST'); - expect((init.headers as Record)['Content-Type']).toBe('application/json'); - expect(readSentBody(init)).toMatchObject({ - pub_key: 'pk_test', + expect(fetchImpl.mock.calls[0]![0]).toBe('https://upload.uploadcare.com/derivative/image/generate/'); + expect(sentInit(fetchImpl).method).toBe('POST'); + expect((sentInit(fetchImpl).headers as Record)['Content-Type']).toBe('application/json'); + expect(sentBody(fetchImpl)).toMatchObject({ + pub_key: PUBLIC_KEY, prompt: 'a hat', aspect_ratio: [16, 9], filename: 'generated.png', @@ -91,69 +63,65 @@ describe('UploadcareDerivativeApi', () => { }); it('forwards request metadata to the generate endpoint', async () => { - const fetchImpl = routedFetch({ type: 'job', job_id: 'job-1' }, [{ status: 'success', uuid: 'abc-123' }]); - const provider = new UploadcareDerivativeApi({ publicKey: 'pk', fetch: fetchImpl, ...NO_DELAY }); + const fetchImpl = emulatorFetch(); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: fetchImpl, ...NO_DELAY }); await provider.generate({ prompt: 'x', mode: 'generate', metadata: { source: 'ai-image-editor' } }); - const [, init] = fetchImpl.mock.calls[0]! as [string, RequestInit]; - expect(readSentBody(init).metadata).toEqual({ source: 'ai-image-editor' }); + expect(sentBody(fetchImpl).metadata).toEqual({ source: 'ai-image-editor' }); }); it('forwards request metadata to the edit endpoint', async () => { - const fetchImpl = routedFetch({ type: 'job', job_id: 'job-e' }, [{ status: 'success', uuid: 'edited' }]); - const provider = new UploadcareDerivativeApi({ publicKey: 'pk', fetch: fetchImpl, ...NO_DELAY }); - await provider.generate({ prompt: 'x', mode: 'edit', source: 'src', metadata: { source: 'ai-image-editor' } }); - const [url, init] = fetchImpl.mock.calls[0]! as [string, RequestInit]; - expect(url).toBe('https://upload.uploadcare.com/derivative/image/edit/'); - expect(readSentBody(init).metadata).toEqual({ source: 'ai-image-editor' }); + const fetchImpl = emulatorFetch(); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: fetchImpl, ...NO_DELAY }); + await provider.generate({ + prompt: 'x', + mode: 'edit', + source: SEEDED_IMAGE_UUID, + metadata: { source: 'ai-image-editor' }, + }); + expect(fetchImpl.mock.calls[0]![0]).toBe('https://upload.uploadcare.com/derivative/image/edit/'); + expect(sentBody(fetchImpl).metadata).toEqual({ source: 'ai-image-editor' }); }); it('uses 1:1 when aspectRatio is missing or invalid', async () => { - const fetchImpl = routedFetch({ type: 'job', job_id: 'job-1' }, [{ status: 'success', uuid: 'abc-123' }]); - const provider = new UploadcareDerivativeApi({ publicKey: 'pk', fetch: fetchImpl, ...NO_DELAY }); + const fetchImpl = emulatorFetch(); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: fetchImpl, ...NO_DELAY }); await provider.generate({ prompt: 'x', mode: 'generate' }); - const [, init] = fetchImpl.mock.calls[0]! as [string, RequestInit]; - expect(readSentBody(init).aspect_ratio).toEqual([1, 1]); + expect(sentBody(fetchImpl).aspect_ratio).toEqual([1, 1]); }); - it('polls the status endpoint with pub_key + job_id until the job succeeds', async () => { - const fetchImpl = routedFetch({ type: 'job', job_id: 'job-42' }, [ - { type: 'job', status: 'processing' }, - { type: 'job', status: 'uploading' }, - { status: 'success', uuid: 'final-uuid' }, - ]); + it('polls the status endpoint with pub_key + job_id until the success frame reports is_ready', async () => { + const fetchImpl = emulatorFetch(); const provider = new UploadcareDerivativeApi({ - publicKey: 'pk', - cdnBaseUrl: 'https://cdn.example.com', + publicKey: PUBLIC_KEY, + cdnBaseUrl: CDN, fetch: fetchImpl, ...NO_DELAY, }); const result = await provider.generate({ prompt: 'x', mode: 'generate' }); - expect(result.url).toBe('https://cdn.example.com/final-uuid/'); - // 1 POST + 3 status polls - expect(fetchImpl).toHaveBeenCalledTimes(4); - const [statusUrl, statusInit] = fetchImpl.mock.calls[1]! as [string, RequestInit]; - expect(statusUrl).toBe('https://upload.uploadcare.com/derivative/status/?pub_key=pk&job_id=job-42'); - expect((statusInit?.method ?? 'GET').toUpperCase()).toBe('GET'); + expect(result.url).toBe(`${CDN}/${result.uuid}/`); + // 1 POST + 4 status polls: processing, uploading, success not yet ready, success ready. + expect(fetchImpl).toHaveBeenCalledTimes(5); + expect(fetchImpl.mock.calls[1]![0]).toMatch( + new RegExp(`^https://upload\\.uploadcare\\.com/derivative/status/\\?pub_key=${PUBLIC_KEY}&job_id=[\\w-]+$`), + ); + expect(sentInit(fetchImpl, 1).method).toBe('GET'); }); it('throws when the job ends in an error status', async () => { - const fetchImpl = routedFetch({ type: 'job', job_id: 'job-err' }, [ - { type: 'job', status: 'processing' }, - { type: 'job', status: 'error', error_source: 'ai_gateway', error_code: 'content_policy', error: 'blocked' }, - ]); - const provider = new UploadcareDerivativeApi({ publicKey: 'pk', fetch: fetchImpl, ...NO_DELAY }); - await expect(provider.generate({ prompt: 'x', mode: 'generate' })).rejects.toThrow(/content_policy|blocked/); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: emulatorFetch(), ...NO_DELAY }); + await expect(provider.generate({ prompt: CONTENT_MODERATED_PROMPT, mode: 'generate' })).rejects.toMatchObject({ + name: 'AiProviderError', + errorCode: 'content_moderated', + }); }); it('wraps an internal poll timeout as a generation_timeout provider error', async () => { - // Never-successful status + a zero timeout: `poll` gives up on its own, - // without the caller's signal ever aborting. - const fetchImpl = routedFetch({ type: 'job', job_id: 'job-slow' }, [{ type: 'job', status: 'processing' }]); + // A zero timeout: `poll` gives up on its own, without the caller's signal ever aborting. const provider = new UploadcareDerivativeApi({ - publicKey: 'pk', - fetch: fetchImpl, + publicKey: PUBLIC_KEY, + fetch: neverFinishingFetch(), pollIntervalMs: 0, pollTimeoutMs: 0, }); @@ -163,70 +131,59 @@ describe('UploadcareDerivativeApi', () => { expect(err.message).toContain('job-slow'); }); + it('times out when the job never reaches a terminal state', async () => { + const provider = new UploadcareDerivativeApi({ + publicKey: PUBLIC_KEY, + fetch: neverFinishingFetch(), + pollIntervalMs: 0, + pollTimeoutMs: 5, + }); + await expect(provider.generate({ prompt: 'x', mode: 'generate' })).rejects.toThrow(/time/i); + }); + it('honours baseUrl + cdnBaseUrl overrides for both generate and status', async () => { - const fetchImpl = routedFetch({ type: 'job', job_id: 'job-1' }, [{ status: 'success', uuid: 'xyz' }]); + const fetchImpl = emulatorFetch(); const provider = new UploadcareDerivativeApi({ - publicKey: 'pk', + publicKey: PUBLIC_KEY, baseUrl: 'https://upload.example.com', - cdnBaseUrl: 'https://cdn.example.com', + cdnBaseUrl: CDN, fetch: fetchImpl, ...NO_DELAY, }); const result = await provider.generate({ prompt: 'x', mode: 'generate' }); - const [genUrl] = fetchImpl.mock.calls[0]! as [string, RequestInit]; - const [statusUrl] = fetchImpl.mock.calls[1]! as [string, RequestInit]; - expect(genUrl).toBe('https://upload.example.com/derivative/image/generate/'); - expect(statusUrl).toBe('https://upload.example.com/derivative/status/?pub_key=pk&job_id=job-1'); - expect(result.url).toBe('https://cdn.example.com/xyz/'); + expect(fetchImpl.mock.calls[0]![0]).toBe('https://upload.example.com/derivative/image/generate/'); + expect(fetchImpl.mock.calls[1]![0]).toMatch( + new RegExp(`^https://upload\\.example\\.com/derivative/status/\\?pub_key=${PUBLIC_KEY}&job_id=`), + ); + expect(result.url).toBe(`${CDN}/${result.uuid}/`); }); it('returns an UploadcareFile on the result (with camelized fields)', async () => { - const fetchImpl = routedFetch({ type: 'job', job_id: 'job-1' }, [ - { status: 'success', uuid: 'final-uuid', original_filename: 'f.png', is_image: true }, - ]); const provider = new UploadcareDerivativeApi({ - publicKey: 'pk', - cdnBaseUrl: 'https://cdn.example.com', - fetch: fetchImpl, + publicKey: PUBLIC_KEY, + cdnBaseUrl: CDN, + fetch: emulatorFetch(), ...NO_DELAY, }); - const result = await provider.generate({ prompt: 'x', mode: 'generate' }); - expect(result.file.uuid).toBe('final-uuid'); - expect(result.file.cdnUrl).toBe('https://cdn.example.com/final-uuid/'); + const result = await provider.generate({ prompt: 'x', mode: 'generate', filename: 'f.png' }); + expect(result.file.uuid).toBe(result.uuid); + expect(result.file.cdnUrl).toBe(`${CDN}/${result.uuid}/`); expect(result.file.originalFilename).toBe('f.png'); expect(result.file.isImage).toBe(true); - expect(result.url).toBe('https://cdn.example.com/final-uuid/'); - }); - - it('keeps polling the status until the success frame reports is_ready: true', async () => { - const fetchImpl = routedFetch({ type: 'job', job_id: 'job-1' }, [ - { status: 'success', uuid: 'final-uuid', is_ready: false }, - { status: 'success', uuid: 'final-uuid', is_ready: true, original_filename: 'ready.png' }, - ]); - const provider = new UploadcareDerivativeApi({ - publicKey: 'pk', - cdnBaseUrl: 'https://cdn.example.com', - fetch: fetchImpl, - ...NO_DELAY, - }); - - const result = await provider.generate({ prompt: 'x', mode: 'generate' }); - - // 1 POST + 2 status polls: the first `success` frame isn't CDN-ready yet. - expect(fetchImpl).toHaveBeenCalledTimes(3); - expect(result.file.originalFilename).toBe('ready.png'); - expect(result.url).toBe('https://cdn.example.com/final-uuid/'); + expect(result.file.imageInfo).toMatchObject({ width: expect.any(Number), height: expect.any(Number) }); + expect(result.url).toBe(`${CDN}/${result.uuid}/`); }); it('derives the CDN base from the public key when cdnBaseUrl is left at the default', async () => { - const fetchImpl = routedFetch({ type: 'job', job_id: 'job-1' }, [{ status: 'success', uuid: 'final-uuid' }]); - const provider = new UploadcareDerivativeApi({ publicKey: 'pk', fetch: fetchImpl, ...NO_DELAY }); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: emulatorFetch(), ...NO_DELAY }); const result = await provider.generate({ prompt: 'x', mode: 'generate' }); - const base = await getPrefixedCdnBaseAsync('pk', 'https://ucarecd.net'); - expect(result.url).toBe(`${base}/final-uuid/`); + const base = await getPrefixedCdnBaseAsync(PUBLIC_KEY, 'https://ucarecd.net'); + expect(result.url).toBe(`${base}/${result.uuid}/`); }); it('surfaces non-2xx generate responses with status text', async () => { + // The emulator answers refusals with the JSON error envelope; this is the + // bare non-JSON failure a proxy or an outage would send instead. const fetchImpl = vi.fn().mockResolvedValue( new Response('bad ratio', { status: 400, @@ -234,45 +191,30 @@ describe('UploadcareDerivativeApi', () => { headers: { 'Content-Type': 'text/plain' }, }), ); - const provider = new UploadcareDerivativeApi({ publicKey: 'pk', fetch: fetchImpl, ...NO_DELAY }); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: fetchImpl, ...NO_DELAY }); await expect(provider.generate({ prompt: 'x', mode: 'generate' })).rejects.toThrow(/400/); }); - it('times out when the job never reaches a terminal state', async () => { - const fetchImpl = routedFetch({ type: 'job', job_id: 'job-1' }, [{ type: 'job', status: 'processing' }]); - const provider = new UploadcareDerivativeApi({ - publicKey: 'pk', - fetch: fetchImpl, - pollIntervalMs: 0, - pollTimeoutMs: 5, - }); - await expect(provider.generate({ prompt: 'x', mode: 'generate' })).rejects.toThrow(/time/i); - }); - it('passes the abort signal to generate and status fetches', async () => { - const seen: (AbortSignal | undefined)[] = []; - const base = routedFetch({ type: 'job', job_id: 'job-1' }, [{ status: 'success', uuid: 'a' }]); - const fetchImpl = vi.fn().mockImplementation((url, init) => { - seen.push(init?.signal ?? undefined); - return base(url, init); - }); - const provider = new UploadcareDerivativeApi({ publicKey: 'pk', fetch: fetchImpl, ...NO_DELAY }); + const fetchImpl = emulatorFetch(); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: fetchImpl, ...NO_DELAY }); const controller = new AbortController(); await provider.generate({ prompt: 'x', mode: 'generate', signal: controller.signal }); - expect(seen.length).toBeGreaterThanOrEqual(2); - expect(seen.every((s) => s === controller.signal)).toBe(true); + const signals = fetchImpl.mock.calls.map(([, init]) => init?.signal); + expect(signals.length).toBeGreaterThanOrEqual(2); + expect(signals.every((s) => s === controller.signal)).toBe(true); }); it('stops polling when the signal is aborted mid-flight', async () => { const controller = new AbortController(); - const fetchImpl = vi.fn().mockImplementation(async (_url, init) => { - const method = (init?.method ?? 'GET').toUpperCase(); - if (method === 'POST') return jsonResponse({ type: 'job', job_id: 'job-1' }); + const emulator = emulatorFetch(); + const fetchImpl = vi.fn(async (url, init) => { + const response = await emulator(url, init); // Abort while "processing": the next poll must never happen. - controller.abort(); - return jsonResponse({ type: 'job', status: 'processing' }); + if (init?.method === 'GET') controller.abort(); + return response; }); - const provider = new UploadcareDerivativeApi({ publicKey: 'pk', fetch: fetchImpl, ...NO_DELAY }); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: fetchImpl, ...NO_DELAY }); await expect(provider.generate({ prompt: 'x', mode: 'generate', signal: controller.signal })).rejects.toThrow( /cancel/i, @@ -284,17 +226,18 @@ describe('UploadcareDerivativeApi', () => { it('does not start polling when the signal is already aborted', async () => { const controller = new AbortController(); controller.abort(); - const fetchImpl = vi.fn().mockRejectedValue(new DOMException('Aborted', 'AbortError')); - const provider = new UploadcareDerivativeApi({ publicKey: 'pk', fetch: fetchImpl, ...NO_DELAY }); + const fetchImpl = emulatorFetch(); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: fetchImpl, ...NO_DELAY }); await expect(provider.generate({ prompt: 'x', mode: 'generate', signal: controller.signal })).rejects.toThrow(); + expect(fetchImpl).toHaveBeenCalledTimes(1); }); describe('edit mode', () => { it('POSTs prompt + source uuid to the edit endpoint and resolves the result', async () => { - const fetchImpl = routedFetch({ type: 'job', job_id: 'job-e' }, [{ status: 'success', uuid: 'edited-uuid' }]); + const fetchImpl = emulatorFetch(); const provider = new UploadcareDerivativeApi({ - publicKey: 'pk', - cdnBaseUrl: 'https://cdn.example.com', + publicKey: PUBLIC_KEY, + cdnBaseUrl: CDN, fetch: fetchImpl, ...NO_DELAY, }); @@ -302,151 +245,120 @@ describe('UploadcareDerivativeApi', () => { const result = await provider.generate({ prompt: 'remove the cat', mode: 'edit', - source: 'src-uuid', + source: SEEDED_IMAGE_UUID, aspectRatio: [16, 9], }); - const [url, init] = fetchImpl.mock.calls[0]! as [string, RequestInit]; - expect(url).toBe('https://upload.uploadcare.com/derivative/image/edit/'); - expect(readSentBody(init)).toMatchObject({ - pub_key: 'pk', + expect(fetchImpl.mock.calls[0]![0]).toBe('https://upload.uploadcare.com/derivative/image/edit/'); + expect(sentBody(fetchImpl)).toMatchObject({ + pub_key: PUBLIC_KEY, prompt: 'remove the cat', - source: 'src-uuid', + source: SEEDED_IMAGE_UUID, aspect_ratio: [16, 9], }); - expect(result.url).toBe('https://cdn.example.com/edited-uuid/'); + expect(result.uuid).not.toBe(SEEDED_IMAGE_UUID); + expect(result.url).toBe(`${CDN}/${result.uuid}/`); expect(result.mode).toBe('edit'); }); it('omits aspect_ratio when none is provided', async () => { - const fetchImpl = routedFetch({ type: 'job', job_id: 'job-e' }, [{ status: 'success', uuid: 'u' }]); - const provider = new UploadcareDerivativeApi({ publicKey: 'pk', fetch: fetchImpl, ...NO_DELAY }); - await provider.generate({ prompt: 'x', mode: 'edit', source: 'src-uuid' }); - expect(readSentBody(fetchImpl.mock.calls[0]![1] as RequestInit).aspect_ratio).toBeUndefined(); + const fetchImpl = emulatorFetch(); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: fetchImpl, ...NO_DELAY }); + await provider.generate({ prompt: 'x', mode: 'edit', source: SEEDED_IMAGE_UUID }); + expect(sentBody(fetchImpl).aspect_ratio).toBeUndefined(); }); it('throws (without any request) when an edit has no source uuid', async () => { - const fetchImpl = routedFetch({ type: 'job', job_id: 'j' }, [{ status: 'success', uuid: 'x' }]); - const provider = new UploadcareDerivativeApi({ publicKey: 'pk', fetch: fetchImpl, ...NO_DELAY }); + const fetchImpl = emulatorFetch(); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: fetchImpl, ...NO_DELAY }); await expect(provider.generate({ prompt: 'x', mode: 'edit' })).rejects.toThrow(/source/i); expect(fetchImpl).not.toHaveBeenCalled(); }); it('resolveCdnUrl maps a uuid to its CDN URL', async () => { - const provider = new UploadcareDerivativeApi({ - publicKey: 'pk', - cdnBaseUrl: 'https://cdn.example.com', - fetch: vi.fn(), - ...NO_DELAY, - }); - expect(await provider.resolveCdnUrl('some-uuid')).toBe('https://cdn.example.com/some-uuid/'); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, cdnBaseUrl: CDN }); + expect(await provider.resolveCdnUrl('some-uuid')).toBe(`${CDN}/some-uuid/`); }); }); describe('getFileInfo', () => { - afterEach(() => mockIsReadyPoll.mockReset()); - - const fileInfoFixture = { - uuid: 'file-uuid', - originalFilename: 'portrait.png', - isImage: true, - mimeType: 'image/png', - imageInfo: { width: 800, height: 1200, format: 'PNG' }, - } as unknown as Awaited>; - - it('polls isReadyPoll() and wraps the ready file as an UploadcareFile on the CDN base', async () => { - mockIsReadyPoll.mockResolvedValue(fileInfoFixture); - const provider = new UploadcareDerivativeApi({ - publicKey: 'pk', - cdnBaseUrl: 'https://cdn.example.com', - fetch: vi.fn(), - ...NO_DELAY, - }); + it('waits for the file to be ready and wraps it as an UploadcareFile on the CDN base', async () => { + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, baseUrl: uploadOrigin, cdnBaseUrl: CDN }); - const file = await provider.getFileInfo('file-uuid'); + const file = await provider.getFileInfo(SEEDED_IMAGE_UUID); - expect(mockIsReadyPoll).toHaveBeenCalledWith('file-uuid', expect.objectContaining({ publicKey: 'pk' })); - expect(file.uuid).toBe('file-uuid'); - expect(file.originalFilename).toBe('portrait.png'); - expect(file.cdnUrl).toBe('https://cdn.example.com/file-uuid/'); - expect(file.imageInfo).toMatchObject({ width: 800, height: 1200 }); + expect(file.uuid).toBe(SEEDED_IMAGE_UUID); + expect(file.cdnUrl).toBe(`${CDN}/${SEEDED_IMAGE_UUID}/`); + expect(file.imageInfo).toMatchObject({ width: expect.any(Number), height: expect.any(Number) }); }); - it('forwards baseUrl + abort signal to isReadyPoll()', async () => { - mockIsReadyPoll.mockResolvedValue(fileInfoFixture); - const provider = new UploadcareDerivativeApi({ - publicKey: 'pk', - baseUrl: 'https://upload.example.com', - fetch: vi.fn(), - ...NO_DELAY, - }); + it('honours the abort signal', async () => { + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, baseUrl: uploadOrigin }); const controller = new AbortController(); - await provider.getFileInfo('file-uuid', controller.signal); - expect(mockIsReadyPoll).toHaveBeenCalledWith( - 'file-uuid', - expect.objectContaining({ baseURL: 'https://upload.example.com', signal: controller.signal }), - ); + controller.abort(); + await expect(provider.getFileInfo(SEEDED_IMAGE_UUID, controller.signal)).rejects.toThrow(); }); - it('propagates isReadyPoll() failures', async () => { - mockIsReadyPoll.mockRejectedValue(new Error('FileNotFound')); - const provider = new UploadcareDerivativeApi({ publicKey: 'pk', fetch: vi.fn(), ...NO_DELAY }); - await expect(provider.getFileInfo('missing')).rejects.toThrow(/FileNotFound/); + it('propagates a lookup failure', async () => { + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, baseUrl: uploadOrigin }); + await expect(provider.getFileInfo('missing')).rejects.toThrow(/not found/i); }); }); describe('authToken', () => { - afterEach(() => mockIsReadyPoll.mockReset()); + // A signed-uploads project refuses every request without a valid Bearer + // token, so each call below succeeding is the proof the token got there. it('carries the token on both network paths', async () => { // The two paths are independent: `generate`/`edit`/`status` go through // the client's own fetch, while `getFileInfo` hands options to // upload-client's isReadyPoll. Dropping either assignment would leave // half the provider unauthenticated. - mockIsReadyPoll.mockResolvedValue({ uuid: 'file-uuid' } as unknown as Awaited>); - const fetchImpl = routedFetch({ type: 'job', job_id: 'job-1' }, [{ status: 'success', uuid: 'abc-123' }]); + const token = mintAuthToken(); + const fetchImpl = emulatorFetch(); const provider = new UploadcareDerivativeApi({ - publicKey: 'pk', - authToken: 'eyJ.first.sig', + publicKey: SIGNED_UPLOADS_PUBLIC_KEY, + baseUrl: uploadOrigin, + authToken: token, fetch: fetchImpl, ...NO_DELAY, }); await provider.generate({ prompt: 'x', mode: 'generate' }); - const [, init] = fetchImpl.mock.calls[0]! as [string, RequestInit]; - expect(new Headers(init.headers).get('Authorization')).toBe('Bearer eyJ.first.sig'); - - await provider.getFileInfo('file-uuid'); - expect(mockIsReadyPoll).toHaveBeenCalledWith( - 'file-uuid', - expect.objectContaining({ authToken: 'eyJ.first.sig' }), - ); + expect(new Headers(sentInit(fetchImpl).headers).get('Authorization')).toBe(`Bearer ${token}`); + + await expect(provider.getFileInfo(SEEDED_IMAGE_UUID)).resolves.toMatchObject({ uuid: SEEDED_IMAGE_UUID }); + }); + + it('is refused on both network paths without one', async () => { + const provider = new UploadcareDerivativeApi({ + publicKey: SIGNED_UPLOADS_PUBLIC_KEY, + baseUrl: uploadOrigin, + fetch: emulatorFetch(), + ...NO_DELAY, + }); + await expect(provider.generate({ prompt: 'x', mode: 'generate' })).rejects.toThrow(/signature/i); + await expect(provider.getFileInfo(SEEDED_IMAGE_UUID)).rejects.toThrow(/signature/i); }); it('follows a token that changes, on both network paths', async () => { // The provider holds one resolver for its lifetime; whoever owns the // token changes what that resolver returns, and nothing is pushed in. - mockIsReadyPoll.mockResolvedValue({ uuid: 'file-uuid' } as unknown as Awaited>); - const fetchImpl = routedFetch({ type: 'job', job_id: 'job-1' }, [{ status: 'success', uuid: 'abc-123' }]); - let token = 'eyJ.first.sig'; + let token = 'not-a-token'; + const fetchImpl = emulatorFetch(); const provider = new UploadcareDerivativeApi({ - publicKey: 'pk', + publicKey: SIGNED_UPLOADS_PUBLIC_KEY, + baseUrl: uploadOrigin, authToken: () => token, fetch: fetchImpl, ...NO_DELAY, }); - token = 'eyJ.second.sig'; + token = mintAuthToken('second'); await provider.generate({ prompt: 'x', mode: 'generate' }); - const [, init] = fetchImpl.mock.calls[0]! as [string, RequestInit]; - expect(new Headers(init.headers).get('Authorization')).toBe('Bearer eyJ.second.sig'); - - // upload-client is handed the resolver itself and calls it per request, - // so what matters is what it resolves to now. - await provider.getFileInfo('file-uuid'); - const [, pollOptions] = mockIsReadyPoll.mock.calls[0]! as [string, { authToken: () => string }]; - expect(pollOptions.authToken()).toBe('eyJ.second.sig'); + expect(new Headers(sentInit(fetchImpl).headers).get('Authorization')).toBe(`Bearer ${token}`); + await expect(provider.getFileInfo(SEEDED_IMAGE_UUID)).resolves.toMatchObject({ uuid: SEEDED_IMAGE_UUID }); }); }); }); diff --git a/packages/ai-image-editor/tests/editor/aspect-ratio.e2e.test.tsx b/packages/ai-image-editor/tests/editor/aspect-ratio.e2e.test.tsx index c7cb9f0..02f7d2a 100644 --- a/packages/ai-image-editor/tests/editor/aspect-ratio.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/aspect-ratio.e2e.test.tsx @@ -1,10 +1,10 @@ import { describe, expect, it, vi } from 'vitest'; -import { clickSend, editorMode, mount, SAMPLE_UUID, STAGING, stubFetch, typePrompt } from './harness'; +import { clickSend, editorMode, mount, recordRequests, SAMPLE_UUID, STAGING, typePrompt } from './harness'; /** The ratio picker: what it offers per mode, what it sends, what it restores. */ describe(' aspect ratio', () => { it('renders the aspect-ratio picker in generate mode (no Auto) and sends the selected ratio', async () => { - const stub = stubFetch(); + const stub = recordRequests(); const el = mount({ ...STAGING, 'aspect-ratios': '16:9 1:1' }); await el.updateComplete; @@ -28,7 +28,7 @@ describe(' aspect ratio', () => { }); it('defaults edit mode to "Auto" and omits aspect_ratio (preserving the source AR)', async () => { - const stub = stubFetch({ uuid: 'edited' }); + const stub = recordRequests(); const el = mount(STAGING); el.sourceUuid = SAMPLE_UUID; await el.updateComplete; @@ -61,7 +61,7 @@ describe(' aspect ratio', () => { }); it('sends an explicit ratio when the user reshapes in edit mode', async () => { - const stub = stubFetch({ uuid: 'edited' }); + const stub = recordRequests(); const el = mount({ ...STAGING, 'aspect-ratios': '1:1' }); el.sourceUuid = SAMPLE_UUID; await el.updateComplete; @@ -83,7 +83,7 @@ describe(' aspect ratio', () => { }); it('records the aspect ratio on a history entry and restores it when re-selected', async () => { - stubFetch({ uuid: 'r1' }); + recordRequests(); const el = mount({ ...STAGING, 'aspect-ratios': '16:9 1:1' }); await el.updateComplete; diff --git a/packages/ai-image-editor/tests/editor/auth-token.e2e.test.tsx b/packages/ai-image-editor/tests/editor/auth-token.e2e.test.tsx index d53239e..d968fb9 100644 --- a/packages/ai-image-editor/tests/editor/auth-token.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/auth-token.e2e.test.tsx @@ -1,21 +1,13 @@ import { describe, expect, it, vi } from 'vitest'; +import { commands } from 'vitest/browser'; import type { UcAiImageEditorType } from './harness'; -import { clickSend, installFetch, jsonResponse, mount, STAGING, successFrame, typePrompt } from './harness'; +import { clickSend, mount, recordRequests, STAGING, typePrompt } from './harness'; +/** + * The emulator checks every Bearer token it gets, so the tokens here are real + * ones it accepts, and a run that finishes is itself proof the token was good. + */ describe(' authToken', () => { - /** Like `stubFetch`, but records the Authorization header of every request. */ - function stubFetchCapturingAuth(): { auth: Array } { - const auth: Array = []; - installFetch((async (_input: RequestInfo | URL, init?: RequestInit) => { - auth.push(new Headers(init?.headers).get('Authorization')); - if ((init?.method ?? 'GET').toUpperCase() === 'POST') { - return jsonResponse({ type: 'job', job_id: 'job-1' }); - } - return jsonResponse(successFrame('result')); - }) as typeof fetch); - return { auth }; - } - /** * Runs one generation and waits for its requests, counted rather than * observed through the canvas: after the first run the canvas already holds @@ -37,7 +29,7 @@ describe(' authToken', () => { it.each([undefined, null, ''])('sends no Authorization header when authToken is %p', async (value) => { // The common case: a project without signing. The provider gets no token // function, so nothing has to stand in for a missing token. - const { auth } = stubFetchCapturingAuth(); + const { auth } = recordRequests(); const el = mount(STAGING); el.authToken = value as unknown as undefined; await el.updateComplete; @@ -49,9 +41,10 @@ describe(' authToken', () => { }); it('signs once authToken is set and stops once it is unset', async () => { - const { auth } = stubFetchCapturingAuth(); + const { auth } = recordRequests(); + const token = await commands.mintAuthToken('plain'); const el = mount(STAGING); - el.authToken = 'eyJ.plain.sig'; + el.authToken = token; await el.updateComplete; await generate(el, 'a tiger', auth); const signed = auth.length; @@ -60,27 +53,29 @@ describe(' authToken', () => { await el.updateComplete; await generate(el, 'a lion', auth); - expect(auth.slice(0, signed).every((value) => value === 'Bearer eyJ.plain.sig')).toBe(true); + expect(auth.slice(0, signed).every((value) => value === `Bearer ${token}`)).toBe(true); expect(auth.slice(signed).every((value) => value === null)).toBe(true); }); it('sends a plain token on every request', async () => { - const { auth } = stubFetchCapturingAuth(); + const { auth } = recordRequests(); + const token = await commands.mintAuthToken('plain'); const el = mount(STAGING); - el.authToken = 'eyJ.plain.sig'; + el.authToken = token; await el.updateComplete; await generate(el, 'a tiger', auth); expect(auth.length).toBeGreaterThan(1); - expect(auth.every((value) => value === 'Bearer eyJ.plain.sig')).toBe(true); + expect(auth.every((value) => value === `Bearer ${token}`)).toBe(true); }); it('calls an authToken function once and reuses the token across requests', async () => { // Standalone, the editor owns the cache: a generate makes several // authenticated requests and must not ask the app for a token each time. - const { auth } = stubFetchCapturingAuth(); - const fetchToken = vi.fn(async () => 'eyJ.fetched.sig'); + const { auth } = recordRequests(); + const token = await commands.mintAuthToken('fetched'); + const fetchToken = vi.fn(async () => token); const el = mount(STAGING); el.authToken = fetchToken; await el.updateComplete; @@ -88,33 +83,35 @@ describe(' authToken', () => { await generate(el, 'a tiger', auth); expect(auth.length).toBeGreaterThan(1); - expect(auth.every((value) => value === 'Bearer eyJ.fetched.sig')).toBe(true); + expect(auth.every((value) => value === `Bearer ${token}`)).toBe(true); expect(fetchToken).toHaveBeenCalledTimes(1); }); it('keeps the cached token when the function identity changes', async () => { // A React parent hands over a new closure on every render. - const { auth } = stubFetchCapturingAuth(); - const first = vi.fn(async () => 'eyJ.first.sig'); + const { auth } = recordRequests(); + const [firstToken, secondToken] = [await commands.mintAuthToken('first'), await commands.mintAuthToken('second')]; + const first = vi.fn(async () => firstToken); const el = mount(STAGING); el.authToken = first; await el.updateComplete; await generate(el, 'a tiger', auth); - const second = vi.fn(async () => 'eyJ.second.sig'); + const second = vi.fn(async () => secondToken); el.authToken = second; await el.updateComplete; await generate(el, 'a lion', auth); expect(second).not.toHaveBeenCalled(); - expect(auth.every((value) => value === 'Bearer eyJ.first.sig')).toBe(true); + expect(auth.every((value) => value === `Bearer ${firstToken}`)).toBe(true); }); it('refetches after invalidateAuthToken()', async () => { // The escape hatch for a sign-out, and the only recovery when a token's // `exp` cannot be read and so never goes stale on its own. - const { auth } = stubFetchCapturingAuth(); - const fetchToken = vi.fn(async () => 'eyJ.fetched.sig'); + const { auth } = recordRequests(); + const token = await commands.mintAuthToken('fetched'); + const fetchToken = vi.fn(async () => token); const el = mount(STAGING); el.authToken = fetchToken; await el.updateComplete; @@ -130,8 +127,9 @@ describe(' authToken', () => { it('does not cache when cacheAuthToken is false', async () => { // What the file-uploader plugin sets: the uploader already caches, so the // editor must resolve through to it rather than hold its own copy. - const { auth } = stubFetchCapturingAuth(); - const fetchToken = vi.fn(async () => 'eyJ.fetched.sig'); + const { auth } = recordRequests(); + const token = await commands.mintAuthToken('fetched'); + const fetchToken = vi.fn(async () => token); const el = mount(STAGING); el.cacheAuthToken = false; el.authToken = fetchToken; @@ -145,8 +143,9 @@ describe(' authToken', () => { it('accepts a provider and lets it own the caching', async () => { // What the File Uploader plugin hands over: the uploader's cache, which // the editor must use rather than wrap in a second one. - const { auth } = stubFetchCapturingAuth(); - const getToken = vi.fn(async () => 'eyJ.from.provider'); + const { auth } = recordRequests(); + const token = await commands.mintAuthToken('provider'); + const getToken = vi.fn(async () => token); const invalidate = vi.fn(); const el = mount(STAGING); el.authToken = { getToken, invalidate }; @@ -154,7 +153,7 @@ describe(' authToken', () => { await generate(el, 'a tiger', auth); - expect(auth.every((value) => value === 'Bearer eyJ.from.provider')).toBe(true); + expect(auth.every((value) => value === `Bearer ${token}`)).toBe(true); // Called per request: the provider decides what to cache, not the editor. expect(getToken.mock.calls.length).toBeGreaterThan(1); }); diff --git a/packages/ai-image-editor/tests/editor/filename.e2e.test.tsx b/packages/ai-image-editor/tests/editor/filename.e2e.test.tsx index 99bdaca..e97b943 100644 --- a/packages/ai-image-editor/tests/editor/filename.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/filename.e2e.test.tsx @@ -1,11 +1,11 @@ import type { UploadcareFile } from '@uploadcare/upload-client'; import { describe, expect, it, vi } from 'vitest'; -import { clickSend, editorMode, mount, SAMPLE_UUID, STAGING, stubFetch, typePrompt } from './harness'; +import { clickSend, editorMode, mount, recordRequests, SAMPLE_UUID, STAGING, typePrompt } from './harness'; /** What the result is named: the source's name, a resolver, or a fixed string. */ describe(' result filename', () => { it('names the result after the source file in edit mode (preserves the original name)', async () => { - const stub = stubFetch({ uuid: 'edited' }); + const stub = recordRequests(); const el = mount(STAGING); // Inject the source's file info (as the plugin does) — it carries the uuid // (→ edit mode) and its originalFilename is the default output name. @@ -26,7 +26,7 @@ describe(' result filename', () => { }); it('names the result via the outputFilename resolver (original + counter)', async () => { - const stub = stubFetch({ uuid: 'r1' }); + const stub = recordRequests(); const el = mount(STAGING); // A fresh uuid with no persisted lineage → counter starts at 1. el.sourceFileInfo = { @@ -46,7 +46,7 @@ describe(' result filename', () => { }); it('uses a static outputFilename string verbatim', async () => { - const stub = stubFetch({ uuid: 'r1' }); + const stub = recordRequests(); const el = mount(STAGING); el.outputFilename = 'my-art.png'; await el.updateComplete; diff --git a/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx b/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx index cb4296c..911717e 100644 --- a/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx @@ -4,11 +4,12 @@ import { clickPrimary, clickSend, editorMode, + installFetch, mount, primaryBtn, + recordRequests, SAMPLE_UUID, STAGING, - stubFetch, typePrompt, } from './harness'; @@ -18,33 +19,33 @@ import { */ describe(' generation', () => { it('auto-enters edit mode after the first successful generation', async () => { - stubFetch({ uuid: 'result' }); + const { results } = recordRequests(); const el = mount(STAGING); await el.updateComplete; expect(editorMode(el)).toBe('generate'); typePrompt(el, 'a tiger'); await el.updateComplete; clickSend(el); - await vi.waitFor(() => expect(canvasUrl(el)).toBe('https://cdn.example.com/result/')); + await vi.waitFor(() => expect(canvasUrl(el)).toContain(`https://cdn.example.com/${results[0]}/`)); await el.updateComplete; expect(editorMode(el)).toBe('edit'); }); it('clears the prompt after a successful generation', async () => { - stubFetch({ uuid: 'result' }); + const { results } = recordRequests(); const el = mount(STAGING); await el.updateComplete; const input = el.shadowRoot!.querySelector('uc-ai-prompt-row')!.shadowRoot!.querySelector('textarea')!; typePrompt(el, 'a tiger'); await el.updateComplete; clickSend(el); - await vi.waitFor(() => expect(canvasUrl(el)).toBe('https://cdn.example.com/result/')); + await vi.waitFor(() => expect(canvasUrl(el)).toContain(`https://cdn.example.com/${results[0]}/`)); await el.updateComplete; expect(input.value).toBe(''); }); it('generates via the send button, then the primary commits the result with uc:done', async () => { - stubFetch({ uuid: 'result' }); + const { results } = recordRequests(); const el = mount(STAGING); await el.updateComplete; @@ -55,7 +56,7 @@ describe(' generation', () => { await el.updateComplete; clickSend(el); await vi.waitFor(() => { - expect(canvasUrl(el)).toBe('https://cdn.example.com/result/'); + expect(canvasUrl(el)).toContain(`https://cdn.example.com/${results[0]}/`); }); await el.updateComplete; @@ -66,13 +67,13 @@ describe(' generation', () => { clickPrimary(el); expect(onDone).toHaveBeenCalledTimes(1); const detail = onDone.mock.calls[0]![0].detail; - expect(detail.url).toBe('https://cdn.example.com/result/'); - expect(detail.file.uuid).toBe('result'); - expect(detail.file.cdnUrl).toBe('https://cdn.example.com/result/'); + expect(detail.url).toBe(`https://cdn.example.com/${results[0]}/`); + expect(detail.file.uuid).toBe(results[0]); + expect(detail.file.cdnUrl).toBe(`https://cdn.example.com/${results[0]}/`); }); it('includes the UploadcareFile and its uuid in uc:done after a generation', async () => { - stubFetch({ uuid: 'result-123' }); + const { results } = recordRequests(); const el = mount(STAGING); await el.updateComplete; const onDone = vi.fn(); @@ -81,17 +82,17 @@ describe(' generation', () => { typePrompt(el, 'make it pop'); await el.updateComplete; clickSend(el); - await vi.waitFor(() => expect(canvasUrl(el)).toBe('https://cdn.example.com/result-123/')); + await vi.waitFor(() => expect(canvasUrl(el)).toContain(`https://cdn.example.com/${results[0]}/`)); await el.updateComplete; clickPrimary(el); expect(onDone).toHaveBeenCalledTimes(1); - expect(onDone.mock.calls[0]![0].detail.file.uuid).toBe('result-123'); - expect(onDone.mock.calls[0]![0].detail.uuid).toBe('result-123'); + expect(onDone.mock.calls[0]![0].detail.file.uuid).toBe(results[0]); + expect(onDone.mock.calls[0]![0].detail.uuid).toBe(results[0]); }); it.skip('fires uc:change as the current result appears and clears', async () => { - stubFetch({ uuid: 'result' }); + const { results } = recordRequests(); const el = mount(STAGING); await el.updateComplete; const onChange = vi.fn(); @@ -104,8 +105,8 @@ describe(' generation', () => { expect(onChange).toHaveBeenCalledTimes(1); }); const first = onChange.mock.calls[0]![0].detail; - expect(first.result.url).toBe('https://cdn.example.com/result/'); - expect(first.result.file.uuid).toBe('result'); + expect(first.result.url).toBe(`https://cdn.example.com/${results[0]}/`); + expect(first.result.file.uuid).toBe(results[0]); // Start over clears the current result -> uc:change with null. await el.updateComplete; @@ -152,25 +153,36 @@ describe(' generation', () => { }); it('applies an async secure-delivery resolver to the canvas preview', async () => { - stubFetch({ uuid: 'result' }); + const { results } = recordRequests(); const el = mount(STAGING); el.secureDeliveryProxyUrlResolver = async (url: string) => `https://signed.example/${encodeURIComponent(url)}`; await el.updateComplete; typePrompt(el, 'a tiger'); await el.updateComplete; clickSend(el); - const raw = 'https://cdn.example.com/result/'; - await vi.waitFor(() => expect(canvasUrl(el)).toBe(`https://signed.example/${encodeURIComponent(raw)}`)); + await vi.waitFor(() => + expect(canvasUrl(el)).toContain( + `https://signed.example/${encodeURIComponent(`https://cdn.example.com/${results[0]}/`)}`, + ), + ); }); it('aborts in-flight generation and shows the new source when source changes', async () => { - // Status hangs until the request is aborted. - stubFetch({ - status: (signal) => - new Promise((_res, rej) => { - signal?.addEventListener('abort', () => rej(new DOMException('Aborted', 'AbortError')), { once: true }); - }), - }); + // The job must still be running when the source changes, so the status + // poll hangs until it is aborted — a race the emulator, which answers at + // once, can't hold open. The job POST is answered here too: the emulator + // would refuse these made-up sources before the poll ever started. + installFetch(async (_input, init) => + init?.method === 'POST' + ? new Response(JSON.stringify({ type: 'job', job_id: 'job-1' }), { + headers: { 'Content-Type': 'application/json' }, + }) + : new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { + once: true, + }); + }), + ); // Non-UUID-shaped ids keep the CDN preview helper from rewriting the URL, // so the canvas URL is the bare resolved source. const el = mount({ ...STAGING, 'source-uuid': 'first-uuid' }); diff --git a/packages/ai-image-editor/tests/editor/harness.ts b/packages/ai-image-editor/tests/editor/harness.ts index fc8f358..061aebd 100644 --- a/packages/ai-image-editor/tests/editor/harness.ts +++ b/packages/ai-image-editor/tests/editor/harness.ts @@ -1,3 +1,5 @@ +// TEMPORARY: file: dependency, see tests/emulator.ts. +import { DERIVATIVE_INSTANT_PUBLIC_KEY } from '@uploadcare/api-emulator'; import { afterEach } from 'vitest'; import { page } from 'vitest/browser'; import type { UcAiImageEditor as UcAiImageEditorType } from '../../src/index'; @@ -7,9 +9,9 @@ import { cleanup } from '../test-renderer'; /** * Shared setup for the `` browser tests, which are split by * subject (mounting, generation, history, layout, …) and all need the same - * three things: the elements registered, a stubbed Upload API, and a mounted - * editor. Importing this module registers the custom elements and the teardown - * for whichever test file pulls it in. + * three things: the elements registered, the Upload API (the emulator, see + * `tests/emulator.ts`), and a mounted editor. Importing this module registers + * the custom elements and the teardown for whichever test file pulls it in. */ export { UcAiImageEditor }; export type { UcAiImageEditorType }; @@ -25,64 +27,49 @@ afterEach(() => { cleanup(); }); -export const jsonResponse = (body: unknown): Response => - new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }); - -/** A finished job frame, as `derivative/status/` returns it. */ -export const successFrame = (uuid: string): Record => ({ - status: 'success', - uuid, - file_id: uuid, - size: 1, - done: 1, - total: 1, - original_filename: 'generated.png', - filename: 'generated.png', - mime_type: 'image/png', - is_image: true, - is_stored: false, - is_ready: true, - image_info: null, - video_info: null, - content_info: null, - metadata: {}, -}); +/** The page's own fetch, which `tests/emulator.ts` answers from the emulator. */ +const realFetch = globalThis.fetch.bind(globalThis); /** * Swap `globalThis.fetch` for the rest of the test. The provider binds - * `globalThis.fetch` at construction, so install a stub BEFORE setting `pubkey`. + * `globalThis.fetch` at construction, so install it BEFORE setting `pubkey`. */ export function installFetch(handler: typeof fetch): void { - // Put back a stub this test already installed, so `real` is always the real - // one — otherwise teardown would restore a stub and every later test in the - // file would run against it. restoreFetch?.(); - const real = globalThis.fetch; globalThis.fetch = handler; restoreFetch = () => { - globalThis.fetch = real; + globalThis.fetch = realFetch; }; } /** - * Drive the internal UploadcareDerivativeApi: the generate POST returns a job, - * the status GET returns success (or a custom handler). Captures the POST - * bodies for assertions. + * Record what the editor sends to (and gets back from) the derivative API, which + * the emulator answers. Install BEFORE setting `pubkey`, like `installFetch`. */ -export function stubFetch(opts: { uuid?: string; status?: (signal?: AbortSignal) => Promise } = {}): { +export function recordRequests(): { + /** Every derivative POST body, in order. */ generateBodies: Array>; + /** The Authorization header of every request, in order. */ + auth: Array; + /** The uuid of every finished result, in order. */ + results: string[]; } { const generateBodies: Array> = []; - installFetch((async (_input: RequestInfo | URL, init?: RequestInit) => { - const method = (init?.method ?? 'GET').toUpperCase(); - if (method === 'POST') { - generateBodies.push(JSON.parse((init?.body as string) ?? '{}')); - return jsonResponse({ type: 'job', job_id: 'job-1' }); - } - if (opts.status) return opts.status(init?.signal ?? undefined); - return jsonResponse(successFrame(opts.uuid ?? 'result')); - }) as typeof fetch); - return { generateBodies }; + const auth: Array = []; + const results: string[] = []; + installFetch(async (input, init) => { + const response = await realFetch(input, init); + // Recorded once answered, so a test waiting on a count waits for the answer too. + auth.push(new Headers(init?.headers).get('Authorization')); + if (init?.method === 'POST') generateBodies.push(JSON.parse(init.body as string)); + const frame = await response + .clone() + .json() + .catch(() => null); + if (frame?.status === 'success' && frame.is_ready) results.push(frame.uuid); + return response; + }); + return { generateBodies, auth, results }; } export function mount(attrs: Record = {}): UcAiImageEditorType { @@ -98,9 +85,11 @@ export function mount(attrs: Record = {}): UcAiImageEditorType { return el; } -export const STAGING = { pubkey: 'demopublickey', 'cdn-cname': 'https://cdn.example.com' }; +/** Generations finish on their first status poll, so a test doesn't sit through the editor's 1.5s interval. */ +export const STAGING = { pubkey: DERIVATIVE_INSTANT_PUBLIC_KEY, 'cdn-cname': 'https://cdn.example.com' }; -export const SAMPLE_UUID = '11111111-2222-3333-4444-555555555555'; +/** An image every fresh emulator session already holds. */ +export const SAMPLE_UUID = '49b4c5a1-31b3-4349-ba07-d97a2d883c37'; export function typePrompt(el: UcAiImageEditorType, value: string): void { const input = el.shadowRoot!.querySelector('uc-ai-prompt-row')!.shadowRoot!.querySelector('textarea')!; diff --git a/packages/ai-image-editor/tests/editor/history.e2e.test.tsx b/packages/ai-image-editor/tests/editor/history.e2e.test.tsx index b0fe7d4..ebd09bf 100644 --- a/packages/ai-image-editor/tests/editor/history.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/history.e2e.test.tsx @@ -6,9 +6,9 @@ import { editorMode, historyEl, mount, + recordRequests, SAMPLE_UUID, STAGING, - stubFetch, typePrompt, } from './harness'; @@ -18,7 +18,7 @@ import { */ describe(' history', () => { it('populates the history strip after a successful generation', async () => { - stubFetch(); + recordRequests(); const el = mount(STAGING); await el.updateComplete; typePrompt(el, 'a tiger'); @@ -30,7 +30,7 @@ describe(' history', () => { }); it('shows the generated result as a selectable history chip', async () => { - stubFetch(); + recordRequests(); const el = mount(STAGING); await el.updateComplete; typePrompt(el, 'a tiger'); @@ -112,7 +112,7 @@ describe(' history', () => { }); it.skip('returns to generate mode after Start over (from the history strip)', async () => { - stubFetch({ uuid: 'result' }); + recordRequests(); const el = mount(STAGING); await el.updateComplete; typePrompt(el, 'a tiger'); diff --git a/packages/ai-image-editor/tests/editor/layout.e2e.test.tsx b/packages/ai-image-editor/tests/editor/layout.e2e.test.tsx index a605d01..c1320f8 100644 --- a/packages/ai-image-editor/tests/editor/layout.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/layout.e2e.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { canvasUrl, clickSend, editorMode, mount, SAMPLE_UUID, STAGING, stubFetch, typePrompt } from './harness'; +import { canvasUrl, clickSend, editorMode, mount, recordRequests, SAMPLE_UUID, STAGING, typePrompt } from './harness'; /** * Where the composer, history strip and toolbar sit for a given placement, and @@ -82,7 +82,7 @@ describe(' layout', () => { }); it('places the history strip per history-placement (overlay composer)', async () => { - stubFetch({ uuid: 'r' }); + recordRequests(); const el = mount(STAGING); el.canvasFit = 'full'; // relative history rides an overlay composer await el.updateComplete; @@ -120,7 +120,7 @@ describe(' layout', () => { }); it('pins the history over the canvas when the composer is docked-out', async () => { - stubFetch({ uuid: 'r' }); + recordRequests(); const el = mount(STAGING); // docked at the bottom by default await el.updateComplete; typePrompt(el, 'a tiger'); diff --git a/packages/ai-image-editor/tests/editor/sizing.e2e.test.tsx b/packages/ai-image-editor/tests/editor/sizing.e2e.test.tsx index 7b21ec1..6c212c0 100644 --- a/packages/ai-image-editor/tests/editor/sizing.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/sizing.e2e.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { delay } from '../test-renderer'; import type { UcAiImageEditorType } from './harness'; -import { canvasUrl, clickSend, mount, SAMPLE_UUID, STAGING, stubFetch, typePrompt } from './harness'; +import { canvasUrl, clickSend, mount, recordRequests, STAGING, typePrompt } from './harness'; /** How the host sizes itself: fill vs content, the clamp, and the CDN rendition it picks. */ describe(' sizing', () => { @@ -89,7 +89,7 @@ describe(' sizing', () => { }); it('re-picks a sharper CDN preview when the host grows, keeping it when it shrinks', async () => { - stubFetch({ uuid: SAMPLE_UUID }); + recordRequests(); const el = mount({ ...STAGING, style: 'width: 500px; height: 480px' }); await el.updateComplete; typePrompt(el, 'a tiger'); diff --git a/packages/ai-image-editor/tests/emulator.ts b/packages/ai-image-editor/tests/emulator.ts new file mode 100644 index 0000000..f652521 --- /dev/null +++ b/packages/ai-image-editor/tests/emulator.ts @@ -0,0 +1,91 @@ +// TEMPORARY: `@uploadcare/api-emulator` is a `file:` dependency on an unreleased +// checkout (see packages/ai-image-editor/package.json); swap it for a version +// range once the package ships, and drop this comment. +import { handle, resetSession, SESSION_HEADER, SIGNED_UPLOADS_SECRET_KEY } from '@uploadcare/api-emulator'; +import { generateAuthToken } from '@uploadcare/signed-uploads/server'; +import type { Page } from 'playwright'; +import type { BrowserCommand } from 'vitest/node'; +// Types `page` on the command context. +import type {} from '@vitest/browser-playwright'; + +/** + * Node-side browser commands that put the Uploadcare API emulator behind the + * test page, so the browser suite never reaches the real Upload API or CDN. + * + * Only Uploadcare's hosts are routed, plus `cdn.example.com`, the CDN cname the + * editor tests configure. Anything else (e.g. the Unsplash image the uploader + * tests import from a URL) is left alone: the emulator's `from_url` answers for + * that source without fetching it. + */ +const UPLOADCARE = /^https:\/\/(?:[\w-]+\.)*(?:uploadcare\.com|ucarecdn\.com|ucarecd\.net|cdn\.example\.com)\//; + +/** One emulator session per page, so test files running side by side keep their files apart. */ +const sessions = new WeakMap(); +let opened = 0; + +/** + * Answer the page's Uploadcare requests from the emulator, starting from an + * empty session. Call it before every test: the route is installed once per + * page, the session is reset each time. + * + * Requests are fulfilled in place from `handle()` (the README's "As a + * function" shape) rather than redirected to an emulator server: nothing here + * depends on the browser making a real request, so there's no server or TLS + * certificate to manage. + */ +const useEmulator: BrowserCommand<[]> = async ({ page }) => { + const existing = sessions.get(page); + if (existing) { + resetSession(existing); + return; + } + + const session = `session-${++opened}`; + sessions.set(page, session); + resetSession(session); + + await page.route(UPLOADCARE, async (route) => { + const request = route.request(); + const method = request.method(); + // Every request here is cross-origin, as it is for the real API. + const cors = { 'access-control-allow-origin': '*' }; + if (method === 'OPTIONS') { + const allowHeaders = (await request.headerValue('access-control-request-headers')) ?? '*'; + return route.fulfill({ + status: 204, + headers: { + ...cors, + 'access-control-allow-methods': 'GET, POST, PUT, HEAD', + 'access-control-allow-headers': allowHeaders, + }, + }); + } + const body = method === 'GET' || method === 'HEAD' ? null : request.postDataBuffer(); + const response = await handle( + new Request(request.url(), { + method, + headers: { ...request.headers(), [SESSION_HEADER]: session }, + body: body && new Uint8Array(body), + }), + ); + if (!response) return route.abort(); + await route.fulfill({ + status: response.status, + headers: { ...Object.fromEntries(response.headers), ...cors }, + body: Buffer.from(await response.arrayBuffer()), + }); + }); +}; + +/** A Bearer token the emulator accepts; `id` keeps tokens minted in the same second apart. */ +const mintAuthToken: BrowserCommand<[id?: string]> = async (_context, id = 'token') => + generateAuthToken(SIGNED_UPLOADS_SECRET_KEY, { lifetime: 600_000, tokenId: id }); + +export const emulatorCommands = { useEmulator, mintAuthToken }; + +declare module 'vitest/browser' { + interface BrowserCommands { + useEmulator: () => Promise; + mintAuthToken: (id?: string) => Promise; + } +} diff --git a/packages/ai-image-editor/tests/setup.ts b/packages/ai-image-editor/tests/setup.ts new file mode 100644 index 0000000..b295043 --- /dev/null +++ b/packages/ai-image-editor/tests/setup.ts @@ -0,0 +1,7 @@ +import { beforeEach } from 'vitest'; +import { commands } from 'vitest/browser'; + +/** Every test starts against an empty Uploadcare emulator — see `tests/emulator.ts`. */ +beforeEach(async () => { + await commands.useEmulator(); +}); diff --git a/packages/ai-image-editor/vitest.config.ts b/packages/ai-image-editor/vitest.config.ts index 1c6f543..c7863d8 100644 --- a/packages/ai-image-editor/vitest.config.ts +++ b/packages/ai-image-editor/vitest.config.ts @@ -1,5 +1,6 @@ import { playwright } from '@vitest/browser-playwright'; import { defineConfig } from 'vitest/config'; +import { emulatorCommands } from './tests/emulator'; export default defineConfig({ test: { @@ -17,11 +18,13 @@ export default defineConfig({ test: { name: 'e2e', include: ['tests/**/*.test.{ts,tsx}'], + setupFiles: ['tests/setup.ts'], browser: { provider: playwright(), instances: [{ browser: 'chromium' }], enabled: true, headless: true, + commands: emulatorCommands, }, }, }, From c4422c90a6dddce76f241fc6ad081554becf0da0 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Tue, 6 Oct 2026 04:28:45 +0300 Subject: [PATCH 002/104] test(ai-image-editor): drop recordRequests() calls whose result is unused The emulator answers the page's fetch on its own; recordRequests() only records requests, so a bare call does nothing. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ai-image-editor/tests/editor/aspect-ratio.e2e.test.tsx | 1 - packages/ai-image-editor/tests/editor/history.e2e.test.tsx | 4 ---- packages/ai-image-editor/tests/editor/layout.e2e.test.tsx | 4 +--- packages/ai-image-editor/tests/editor/sizing.e2e.test.tsx | 3 +-- 4 files changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/ai-image-editor/tests/editor/aspect-ratio.e2e.test.tsx b/packages/ai-image-editor/tests/editor/aspect-ratio.e2e.test.tsx index 02f7d2a..a67d0ab 100644 --- a/packages/ai-image-editor/tests/editor/aspect-ratio.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/aspect-ratio.e2e.test.tsx @@ -83,7 +83,6 @@ describe(' aspect ratio', () => { }); it('records the aspect ratio on a history entry and restores it when re-selected', async () => { - recordRequests(); const el = mount({ ...STAGING, 'aspect-ratios': '16:9 1:1' }); await el.updateComplete; diff --git a/packages/ai-image-editor/tests/editor/history.e2e.test.tsx b/packages/ai-image-editor/tests/editor/history.e2e.test.tsx index ebd09bf..26151e1 100644 --- a/packages/ai-image-editor/tests/editor/history.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/history.e2e.test.tsx @@ -6,7 +6,6 @@ import { editorMode, historyEl, mount, - recordRequests, SAMPLE_UUID, STAGING, typePrompt, @@ -18,7 +17,6 @@ import { */ describe(' history', () => { it('populates the history strip after a successful generation', async () => { - recordRequests(); const el = mount(STAGING); await el.updateComplete; typePrompt(el, 'a tiger'); @@ -30,7 +28,6 @@ describe(' history', () => { }); it('shows the generated result as a selectable history chip', async () => { - recordRequests(); const el = mount(STAGING); await el.updateComplete; typePrompt(el, 'a tiger'); @@ -112,7 +109,6 @@ describe(' history', () => { }); it.skip('returns to generate mode after Start over (from the history strip)', async () => { - recordRequests(); const el = mount(STAGING); await el.updateComplete; typePrompt(el, 'a tiger'); diff --git a/packages/ai-image-editor/tests/editor/layout.e2e.test.tsx b/packages/ai-image-editor/tests/editor/layout.e2e.test.tsx index c1320f8..6621edc 100644 --- a/packages/ai-image-editor/tests/editor/layout.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/layout.e2e.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { canvasUrl, clickSend, editorMode, mount, recordRequests, SAMPLE_UUID, STAGING, typePrompt } from './harness'; +import { canvasUrl, clickSend, editorMode, mount, SAMPLE_UUID, STAGING, typePrompt } from './harness'; /** * Where the composer, history strip and toolbar sit for a given placement, and @@ -82,7 +82,6 @@ describe(' layout', () => { }); it('places the history strip per history-placement (overlay composer)', async () => { - recordRequests(); const el = mount(STAGING); el.canvasFit = 'full'; // relative history rides an overlay composer await el.updateComplete; @@ -120,7 +119,6 @@ describe(' layout', () => { }); it('pins the history over the canvas when the composer is docked-out', async () => { - recordRequests(); const el = mount(STAGING); // docked at the bottom by default await el.updateComplete; typePrompt(el, 'a tiger'); diff --git a/packages/ai-image-editor/tests/editor/sizing.e2e.test.tsx b/packages/ai-image-editor/tests/editor/sizing.e2e.test.tsx index 6c212c0..a2281ef 100644 --- a/packages/ai-image-editor/tests/editor/sizing.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/sizing.e2e.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { delay } from '../test-renderer'; import type { UcAiImageEditorType } from './harness'; -import { canvasUrl, clickSend, mount, recordRequests, STAGING, typePrompt } from './harness'; +import { canvasUrl, clickSend, mount, STAGING, typePrompt } from './harness'; /** How the host sizes itself: fill vs content, the clamp, and the CDN rendition it picks. */ describe(' sizing', () => { @@ -89,7 +89,6 @@ describe(' sizing', () => { }); it('re-picks a sharper CDN preview when the host grows, keeping it when it shrinks', async () => { - recordRequests(); const el = mount({ ...STAGING, style: 'width: 500px; height: 480px' }); await el.updateComplete; typePrompt(el, 'a tiger'); From 7ce65bf7f4c0f5eb51b8de1c141d5c27256d4ea0 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Tue, 6 Oct 2026 04:28:54 +0300 Subject: [PATCH 003/104] test(ai-image-editor): require the cancel error in the getFileInfo abort test A bare rejects.toThrow() also passed on a 404 or an emulator that never started. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/entities/provider/api/uploadcareDerivativeApi.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts index 6b96509..69074b9 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts @@ -296,7 +296,7 @@ describe('UploadcareDerivativeApi', () => { const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, baseUrl: uploadOrigin }); const controller = new AbortController(); controller.abort(); - await expect(provider.getFileInfo(SEEDED_IMAGE_UUID, controller.signal)).rejects.toThrow(); + await expect(provider.getFileInfo(SEEDED_IMAGE_UUID, controller.signal)).rejects.toThrow(/cancel/i); }); it('propagates a lookup failure', async () => { From c6069f78c37493b0e6f06eadff50ce592a96ad1a Mon Sep 17 00:00:00 2001 From: nd0ut Date: Tue, 6 Oct 2026 04:29:32 +0300 Subject: [PATCH 004/104] test(ai-image-editor): name the request when the emulator has no route for it The page only sees a generic network failure, so log the method and URL before aborting, as the spec-side emulatorFetch does. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/ai-image-editor/tests/emulator.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/ai-image-editor/tests/emulator.ts b/packages/ai-image-editor/tests/emulator.ts index f652521..36e676b 100644 --- a/packages/ai-image-editor/tests/emulator.ts +++ b/packages/ai-image-editor/tests/emulator.ts @@ -68,7 +68,11 @@ const useEmulator: BrowserCommand<[]> = async ({ page }) => { body: body && new Uint8Array(body), }), ); - if (!response) return route.abort(); + if (!response) { + // The page only sees a generic network failure, so name the gap here. + console.warn(`The emulator does not implement ${method} ${request.url()}`); + return route.abort(); + } await route.fulfill({ status: response.status, headers: { ...Object.fromEntries(response.headers), ...cors }, From 7b673f32ac38d2600dafe977023289c8bacf56f0 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Tue, 6 Oct 2026 04:30:00 +0300 Subject: [PATCH 005/104] test(ai-image-editor): wait for a finished result in the auth-token tests Counting requests also passed when the emulator refused the token on a status poll; a finished result proves every request was accepted. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/editor/auth-token.e2e.test.tsx | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/packages/ai-image-editor/tests/editor/auth-token.e2e.test.tsx b/packages/ai-image-editor/tests/editor/auth-token.e2e.test.tsx index d968fb9..2845c62 100644 --- a/packages/ai-image-editor/tests/editor/auth-token.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/auth-token.e2e.test.tsx @@ -9,19 +9,19 @@ import { clickSend, mount, recordRequests, STAGING, typePrompt } from './harness */ describe(' authToken', () => { /** - * Runs one generation and waits for its requests, counted rather than + * Runs one generation and waits for its finished result, counted rather than * observed through the canvas: after the first run the canvas already holds * the result URL, so waiting on it would return immediately and the second * generation would never be awaited. */ - const generate = async (el: UcAiImageEditorType, prompt: string, auth: Array) => { - const before = auth.length; + const generate = async (el: UcAiImageEditorType, prompt: string, results: string[]) => { + const before = results.length; typePrompt(el, prompt); await el.updateComplete; clickSend(el); - // The POST that starts the job, then at least one status GET. + // A finished result, so a status poll the emulator refused fails the test. await vi.waitFor(() => { - expect(auth.length).toBeGreaterThanOrEqual(before + 2); + expect(results).toHaveLength(before + 1); }); await el.updateComplete; }; @@ -29,42 +29,42 @@ describe(' authToken', () => { it.each([undefined, null, ''])('sends no Authorization header when authToken is %p', async (value) => { // The common case: a project without signing. The provider gets no token // function, so nothing has to stand in for a missing token. - const { auth } = recordRequests(); + const { auth, results } = recordRequests(); const el = mount(STAGING); el.authToken = value as unknown as undefined; await el.updateComplete; - await generate(el, 'a tiger', auth); + await generate(el, 'a tiger', results); expect(auth.length).toBeGreaterThan(1); expect(auth.every((value) => value === null)).toBe(true); }); it('signs once authToken is set and stops once it is unset', async () => { - const { auth } = recordRequests(); + const { auth, results } = recordRequests(); const token = await commands.mintAuthToken('plain'); const el = mount(STAGING); el.authToken = token; await el.updateComplete; - await generate(el, 'a tiger', auth); + await generate(el, 'a tiger', results); const signed = auth.length; el.authToken = undefined; await el.updateComplete; - await generate(el, 'a lion', auth); + await generate(el, 'a lion', results); expect(auth.slice(0, signed).every((value) => value === `Bearer ${token}`)).toBe(true); expect(auth.slice(signed).every((value) => value === null)).toBe(true); }); it('sends a plain token on every request', async () => { - const { auth } = recordRequests(); + const { auth, results } = recordRequests(); const token = await commands.mintAuthToken('plain'); const el = mount(STAGING); el.authToken = token; await el.updateComplete; - await generate(el, 'a tiger', auth); + await generate(el, 'a tiger', results); expect(auth.length).toBeGreaterThan(1); expect(auth.every((value) => value === `Bearer ${token}`)).toBe(true); @@ -73,14 +73,14 @@ describe(' authToken', () => { it('calls an authToken function once and reuses the token across requests', async () => { // Standalone, the editor owns the cache: a generate makes several // authenticated requests and must not ask the app for a token each time. - const { auth } = recordRequests(); + const { auth, results } = recordRequests(); const token = await commands.mintAuthToken('fetched'); const fetchToken = vi.fn(async () => token); const el = mount(STAGING); el.authToken = fetchToken; await el.updateComplete; - await generate(el, 'a tiger', auth); + await generate(el, 'a tiger', results); expect(auth.length).toBeGreaterThan(1); expect(auth.every((value) => value === `Bearer ${token}`)).toBe(true); @@ -89,18 +89,18 @@ describe(' authToken', () => { it('keeps the cached token when the function identity changes', async () => { // A React parent hands over a new closure on every render. - const { auth } = recordRequests(); + const { auth, results } = recordRequests(); const [firstToken, secondToken] = [await commands.mintAuthToken('first'), await commands.mintAuthToken('second')]; const first = vi.fn(async () => firstToken); const el = mount(STAGING); el.authToken = first; await el.updateComplete; - await generate(el, 'a tiger', auth); + await generate(el, 'a tiger', results); const second = vi.fn(async () => secondToken); el.authToken = second; await el.updateComplete; - await generate(el, 'a lion', auth); + await generate(el, 'a lion', results); expect(second).not.toHaveBeenCalled(); expect(auth.every((value) => value === `Bearer ${firstToken}`)).toBe(true); @@ -109,17 +109,17 @@ describe(' authToken', () => { it('refetches after invalidateAuthToken()', async () => { // The escape hatch for a sign-out, and the only recovery when a token's // `exp` cannot be read and so never goes stale on its own. - const { auth } = recordRequests(); + const { results } = recordRequests(); const token = await commands.mintAuthToken('fetched'); const fetchToken = vi.fn(async () => token); const el = mount(STAGING); el.authToken = fetchToken; await el.updateComplete; - await generate(el, 'a tiger', auth); + await generate(el, 'a tiger', results); expect(fetchToken).toHaveBeenCalledTimes(1); el.invalidateAuthToken(); - await generate(el, 'a lion', auth); + await generate(el, 'a lion', results); expect(fetchToken).toHaveBeenCalledTimes(2); }); @@ -127,7 +127,7 @@ describe(' authToken', () => { it('does not cache when cacheAuthToken is false', async () => { // What the file-uploader plugin sets: the uploader already caches, so the // editor must resolve through to it rather than hold its own copy. - const { auth } = recordRequests(); + const { results } = recordRequests(); const token = await commands.mintAuthToken('fetched'); const fetchToken = vi.fn(async () => token); const el = mount(STAGING); @@ -135,7 +135,7 @@ describe(' authToken', () => { el.authToken = fetchToken; await el.updateComplete; - await generate(el, 'a tiger', auth); + await generate(el, 'a tiger', results); expect(fetchToken.mock.calls.length).toBeGreaterThan(1); }); @@ -143,7 +143,7 @@ describe(' authToken', () => { it('accepts a provider and lets it own the caching', async () => { // What the File Uploader plugin hands over: the uploader's cache, which // the editor must use rather than wrap in a second one. - const { auth } = recordRequests(); + const { auth, results } = recordRequests(); const token = await commands.mintAuthToken('provider'); const getToken = vi.fn(async () => token); const invalidate = vi.fn(); @@ -151,7 +151,7 @@ describe(' authToken', () => { el.authToken = { getToken, invalidate }; await el.updateComplete; - await generate(el, 'a tiger', auth); + await generate(el, 'a tiger', results); expect(auth.every((value) => value === `Bearer ${token}`)).toBe(true); // Called per request: the provider decides what to cache, not the editor. From 60c310aec74422e7db9580eb90e87cd13c8d9ab8 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Tue, 6 Oct 2026 04:31:02 +0300 Subject: [PATCH 006/104] test(ai-image-editor): cover a refused generation in the browser A moderated prompt from the emulator must reach the host as uc:error, show its own message, and leave the editor in generate mode with the prompt kept. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/editor/generation.e2e.test.tsx | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx b/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx index 911717e..a6267d5 100644 --- a/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx @@ -1,4 +1,7 @@ +// TEMPORARY: file: dependency, see tests/emulator.ts. +import { CONTENT_MODERATED_PROMPT } from '@uploadcare/api-emulator'; import { describe, expect, it, vi } from 'vitest'; +import { enLocale } from '../../src/shared/i18n/en'; import { canvasUrl, clickPrimary, @@ -91,6 +94,28 @@ describe(' generation', () => { expect(onDone.mock.calls[0]![0].detail.uuid).toBe(results[0]); }); + it('reports a refused run with uc:error and its message, and keeps the prompt', async () => { + const el = mount(STAGING); + await el.updateComplete; + const onError = vi.fn(); + el.addEventListener('uc:error', onError); + + typePrompt(el, CONTENT_MODERATED_PROMPT); + await el.updateComplete; + clickSend(el); + await vi.waitFor(() => expect(onError).toHaveBeenCalledTimes(1)); + await el.updateComplete; + + expect(onError.mock.calls[0]![0].detail.error.code).toBe('content_moderated'); + expect(el.shadowRoot!.querySelector('.error-box')?.textContent?.trim()).toBe( + enLocale['ai-image-editor-error-content_moderated'], + ); + expect(canvasUrl(el)).toBeNull(); + expect(editorMode(el)).toBe('generate'); + const input = el.shadowRoot!.querySelector('uc-ai-prompt-row')!.shadowRoot!.querySelector('textarea')!; + expect(input.value).toBe(CONTENT_MODERATED_PROMPT); + }); + it.skip('fires uc:change as the current result appears and clears', async () => { const { results } = recordRequests(); const el = mount(STAGING); From aaee946ee58775eb6fa94c5e9c26efc1b88b655a Mon Sep 17 00:00:00 2001 From: nd0ut Date: Tue, 6 Oct 2026 09:55:38 +0300 Subject: [PATCH 007/104] test(ai-image-editor): use UUID-shaped ids for made-up CDN sources The emulator's CDN route only answers UUID or group-shaped paths, so 'resolver-test-uuid-0001', 'first-uuid' and 'second-uuid' fell through to the harness's "does not implement" warning. UUID-shaped ids get the same quiet 404 the real CDN gives for an unknown file. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ai-image-editor/tests/editor/filename.e2e.test.tsx | 2 +- .../ai-image-editor/tests/editor/generation.e2e.test.tsx | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/ai-image-editor/tests/editor/filename.e2e.test.tsx b/packages/ai-image-editor/tests/editor/filename.e2e.test.tsx index e97b943..b3b805e 100644 --- a/packages/ai-image-editor/tests/editor/filename.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/filename.e2e.test.tsx @@ -30,7 +30,7 @@ describe(' result filename', () => { const el = mount(STAGING); // A fresh uuid with no persisted lineage → counter starts at 1. el.sourceFileInfo = { - uuid: 'resolver-test-uuid-0001', + uuid: '00000000-0000-4000-8000-000000000001', originalFilename: 'cat.png', imageInfo: null, } as unknown as UploadcareFile; diff --git a/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx b/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx index a6267d5..aec2653 100644 --- a/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx @@ -208,9 +208,7 @@ describe(' generation', () => { }); }), ); - // Non-UUID-shaped ids keep the CDN preview helper from rewriting the URL, - // so the canvas URL is the bare resolved source. - const el = mount({ ...STAGING, 'source-uuid': 'first-uuid' }); + const el = mount({ ...STAGING, 'source-uuid': '00000000-0000-4000-8000-0000000000f1' }); await el.updateComplete; typePrompt(el, 'try'); @@ -218,12 +216,12 @@ describe(' generation', () => { clickSend(el); // Change source mid-flight — this aborts the in-flight generation. - el.sourceUuid = 'second-uuid'; + el.sourceUuid = '00000000-0000-4000-8000-0000000000f2'; await el.updateComplete; // After the abort, the displayed image should be the new source (no result override). await vi.waitFor(() => { - expect(canvasUrl(el)).toBe('https://cdn.example.com/second-uuid/'); + expect(canvasUrl(el)).toContain('https://cdn.example.com/00000000-0000-4000-8000-0000000000f2/'); }); }); }); From c5ef71bc9c83a7985bb9abc8c4b76dc9ea08fe9e Mon Sep 17 00:00:00 2001 From: nd0ut Date: Tue, 6 Oct 2026 10:04:11 +0300 Subject: [PATCH 008/104] test(ai-image-editor): let the uploader finish its locale switch before cleanup The locale-switch test waited only for the editor's German label. The editor loads its own strings, while the uploader's LocaleManager loads de.js on its own. If that import was still pending at cleanup(), the destroyed uploader resumed, read the nulled *pluginManager and threw an unhandled rejection that vitest blamed on the next test. Delaying the uploader's de loader by 500ms reproduced it every time. file-uploader 1.34.0 still has this bug (fixed in uploadcare/file-uploader#1081 and not released yet). The test now also waits for the uploader's own 'cancel' string to switch to German. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/ai-image-editor/tests/plugin.e2e.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/ai-image-editor/tests/plugin.e2e.test.ts b/packages/ai-image-editor/tests/plugin.e2e.test.ts index 10c16b9..99cdfff 100644 --- a/packages/ai-image-editor/tests/plugin.e2e.test.ts +++ b/packages/ai-image-editor/tests/plugin.e2e.test.ts @@ -13,6 +13,7 @@ type UploadCtxProvider = HTMLElement & { initFlow: () => void; removeAllFiles: () => void; getOutputCollectionState: () => { allEntries: OutputEntry[] }; + l10n: (key: string) => string; }; }; @@ -158,6 +159,10 @@ describe('AiImageEditorPlugin', () => { // Switching localeName lazy-loads and applies the German strings. (config as unknown as { localeName: string }).localeName = 'de'; await vi.waitFor(() => expect(cancelLabel()).toBe('Abbrechen')); + // The editor loads its own strings; the uploader loads its locale separately + // and may still be resolving. Tearing it down mid-load is a file-uploader bug + // (fixed upstream, not yet released), so let the uploader finish switching too. + await vi.waitFor(() => expect(getApi().l10n('cancel')).toBe('Abbrechen')); cleanup(); }); From 8ea9619c89090b9cd1c069aeb6dcb10dd7b2e94d Mon Sep 17 00:00:00 2001 From: nd0ut Date: Tue, 6 Oct 2026 11:14:07 +0300 Subject: [PATCH 009/104] build: add msw and @mswjs/interceptors for the browser tests msw 2.15.0 (no vite peer; satisfies @vitest/mocker's ^2.4.9) and @mswjs/interceptors 0.45.7 at the root, since msw's nested 0.41.9 fires a single upload progress event where 0.45.x fires one per body chunk. Co-Authored-By: Claude Fable 5.1 --- package-lock.json | 438 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 2 + 2 files changed, 440 insertions(+) diff --git a/package-lock.json b/package-lock.json index 2871af3..e47ca68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,11 +14,13 @@ ], "devDependencies": { "@biomejs/biome": "2.2.6", + "@mswjs/interceptors": "0.45.7", "@vitest/browser-playwright": "^4.1.10", "happy-dom": "^20.9.0", "husky": "^9.0.11", "lerna": "^10.0.1", "lint-staged": "^15.2.2", + "msw": "2.15.0", "typescript": "^5.6.0", "vite": "^6.2.3", "vite-plugin-dts": "^4.5.0", @@ -1787,6 +1789,24 @@ "resolve": "~1.22.2" } }, + "node_modules/@mswjs/interceptors": { + "version": "0.45.7", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.45.7.tgz", + "integrity": "sha512-PPZQBiojRuy1jJQoUqbrJhoac47GC1GwRazDrYwwbE70zfqMkOY/5a+Du4hp3vlPUXLt/um4TMcmRabmooAkxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/until": "^3.0.1", + "@types/debug": "^4.1.13", + "debug": "^4.4.3", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "rettime": "^0.11.12" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.4.tgz", @@ -2768,6 +2788,34 @@ "@octokit/openapi-types": "^24.2.0" } }, + "node_modules/@open-draft/deferred-promise": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz", + "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-3.0.1.tgz", + "integrity": "sha512-s7/9ELP4aP9YZtW7RJaa0Xf3RISaRH9+EFN18FtykJYRHGNrl8f9ymvoNXzjhrjmftFvkQudtuDU9RYhk0xs3A==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, "node_modules/@oxc-resolver/binding-android-arm-eabi": { "version": "11.21.3", "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.21.3.tgz", @@ -4199,6 +4247,23 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/set-cookie-parser": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz", + "integrity": "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -6204,6 +6269,20 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/copy-anything": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", @@ -6923,6 +7002,23 @@ "node": ">= 6" } }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, "node_modules/fast-uri": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", @@ -6940,6 +7036,16 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -7373,6 +7479,16 @@ "dev": true, "license": "ISC" }, + "node_modules/graphql": { + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/handlebars": { "version": "4.7.9", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", @@ -7516,6 +7632,17 @@ "he": "bin/he" } }, + "node_modules/headers-polyfill": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz", + "integrity": "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/set-cookie-parser": "^2.4.10", + "set-cookie-parser": "^3.0.1" + } + }, "node_modules/hookable": { "version": "5.5.3", "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", @@ -8031,6 +8158,13 @@ "node": ">=8" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -10593,6 +10727,209 @@ "dev": true, "license": "MIT" }, + "node_modules/msw": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.15.0.tgz", + "integrity": "sha512-2wQAmKkQKxRuXvYJxVhPGG0wZNBQyD06oJvxqw90XqLvptdqxdlHrFUfEteKkpaNORX3Xzc+HtEl/q0nfmN2wQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^6.0.11", + "@mswjs/interceptors": "^0.41.3", + "@open-draft/deferred-promise": "^3.0.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.1.1", + "graphql": "^16.13.2", + "headers-polyfill": "^5.0.1", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.11.11", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.1", + "type-fest": "^5.5.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/msw/node_modules/@inquirer/ansi": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.9.tgz", + "integrity": "sha512-0Ywt3S8tqM6TWUcsRlBFQ1x+X3/o8kktX0ZiKAnByYb9GFDknnOhse1PVtfiD2BXPI8ar+BWx9PV9R0lQIcygw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/msw/node_modules/@inquirer/confirm": { + "version": "6.3.3", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.3.3.tgz", + "integrity": "sha512-ImOBgpl/sJTFp8naeSkNu4S0Sf8F8pxv3/CUx+LoBAdQvCVqFz6rSB2A/oZYsJge3+yphbEDxEkfaRNYq1OU0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.4", + "@inquirer/type": "4.2.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/msw/node_modules/@inquirer/core": { + "version": "12.0.4", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.4.tgz", + "integrity": "sha512-M5fB67y7jPObI45vhD82RskWwCSm0scOvenV/I6K8tIi2gfr9DXqQqJ+iw+Wi6SgapikdOiJibYzz1gW47fsDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.9", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.2.0", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/msw/node_modules/@inquirer/figures": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.9.tgz", + "integrity": "sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/msw/node_modules/@inquirer/type": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.2.0.tgz", + "integrity": "sha512-2rnHX8RPo4glJYqnFc/v3nQeIhDcjyRRyaT7MGcfL/8xv/8K03SXYoA2tX06WVSKywWOE/ncbG3dtuzLjR5TCQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/msw/node_modules/@mswjs/interceptors": { + "version": "0.41.9", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz", + "integrity": "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/msw/node_modules/@mswjs/interceptors/node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/msw/node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, + "node_modules/msw/node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/msw/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/msw/node_modules/type-fest": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.10.0.tgz", + "integrity": "sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/muggle-string": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", @@ -11334,6 +11671,13 @@ "node": ">=8" } }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, "node_modules/oxc-resolver": { "version": "11.21.3", "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.21.3.tgz", @@ -11817,6 +12161,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -12706,6 +13057,13 @@ "node": ">= 4" } }, + "node_modules/rettime": { + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.12.tgz", + "integrity": "sha512-5ExtR1ppiDBQkVoPw5NAqdbfH4Iq2VOmQKHMtUoaFxA9bo2krzFYBtyq6K40xxbYp+2w/YOr6D+1ToF6tTN53A==", + "dev": true, + "license": "MIT" + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -12924,6 +13282,13 @@ "node": ">=10" } }, + "node_modules/set-cookie-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "dev": true, + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -13264,6 +13629,16 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", @@ -13271,6 +13646,13 @@ "dev": true, "license": "MIT" }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -13400,6 +13782,19 @@ "dev": true, "license": "MIT" }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tar": { "version": "7.5.22", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", @@ -13509,6 +13904,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.16.tgz", + "integrity": "sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.16" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.16.tgz", + "integrity": "sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==", + "dev": true, + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", @@ -13542,6 +13957,19 @@ "node": ">=6" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/treeverse": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/treeverse/-/treeverse-3.0.0.tgz", @@ -13908,6 +14336,16 @@ "node": ">= 10.0.0" } }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index cbff116..3c46b39 100644 --- a/package.json +++ b/package.json @@ -19,11 +19,13 @@ ], "devDependencies": { "@biomejs/biome": "2.2.6", + "@mswjs/interceptors": "0.45.7", "@vitest/browser-playwright": "^4.1.10", "happy-dom": "^20.9.0", "husky": "^9.0.11", "lerna": "^10.0.1", "lint-staged": "^15.2.2", + "msw": "2.15.0", "typescript": "^5.6.0", "vite": "^6.2.3", "vite-plugin-dts": "^4.5.0", From f4f7298ed5513f1f4b618ba8d5a69bd2be7cd0e8 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Tue, 6 Oct 2026 11:14:08 +0300 Subject: [PATCH 010/104] test(ai-image-editor): run the API emulator in the page The browser suite no longer fulfils Uploadcare requests from a Node page.route. tests/emulator.ts now runs handle() inside the page: an MSW Service Worker answers fetch and loads, @mswjs/interceptors' XMLHttpRequestInterceptor answers the uploader's XHR so upload progress fires per chunk. Both share the default session, reset before each test. Only Uploadcare hosts and cdn.example.com are answered (handle() routes by path alone); everything else passes through. The signing command stays Node-side in tests/commands.ts. Co-Authored-By: Claude Fable 5.1 --- packages/ai-image-editor/tests/commands.ts | 19 +++ .../tests/editor/generation.e2e.test.tsx | 2 +- .../ai-image-editor/tests/editor/harness.ts | 2 +- packages/ai-image-editor/tests/emulator.ts | 138 ++++++++---------- packages/ai-image-editor/tests/setup.ts | 4 +- packages/ai-image-editor/vitest.config.ts | 2 +- 6 files changed, 82 insertions(+), 85 deletions(-) create mode 100644 packages/ai-image-editor/tests/commands.ts diff --git a/packages/ai-image-editor/tests/commands.ts b/packages/ai-image-editor/tests/commands.ts new file mode 100644 index 0000000..1de2188 --- /dev/null +++ b/packages/ai-image-editor/tests/commands.ts @@ -0,0 +1,19 @@ +// TEMPORARY: `@uploadcare/api-emulator` is a `file:` dependency on an unreleased +// checkout (see packages/ai-image-editor/package.json); swap it for a version +// range once the package ships, and drop this comment. +import { SIGNED_UPLOADS_SECRET_KEY } from '@uploadcare/api-emulator'; +import { generateAuthToken } from '@uploadcare/signed-uploads/server'; +import type { BrowserCommand } from 'vitest/node'; + +/** A Bearer token the emulator accepts; `id` keeps tokens minted in the same second apart. */ +const mintAuthToken: BrowserCommand<[id?: string]> = async (_context, id = 'token') => + generateAuthToken(SIGNED_UPLOADS_SECRET_KEY, { lifetime: 600_000, tokenId: id }); + +/** Node-side browser commands: signing needs the secret, which stays out of the page. */ +export const emulatorCommands = { mintAuthToken }; + +declare module 'vitest/browser' { + interface BrowserCommands { + mintAuthToken: (id?: string) => Promise; + } +} diff --git a/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx b/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx index aec2653..ec43aea 100644 --- a/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx @@ -1,4 +1,4 @@ -// TEMPORARY: file: dependency, see tests/emulator.ts. +// TEMPORARY: file: dependency, see tests/commands.ts. import { CONTENT_MODERATED_PROMPT } from '@uploadcare/api-emulator'; import { describe, expect, it, vi } from 'vitest'; import { enLocale } from '../../src/shared/i18n/en'; diff --git a/packages/ai-image-editor/tests/editor/harness.ts b/packages/ai-image-editor/tests/editor/harness.ts index 061aebd..6bf99f3 100644 --- a/packages/ai-image-editor/tests/editor/harness.ts +++ b/packages/ai-image-editor/tests/editor/harness.ts @@ -1,4 +1,4 @@ -// TEMPORARY: file: dependency, see tests/emulator.ts. +// TEMPORARY: file: dependency, see tests/commands.ts. import { DERIVATIVE_INSTANT_PUBLIC_KEY } from '@uploadcare/api-emulator'; import { afterEach } from 'vitest'; import { page } from 'vitest/browser'; diff --git a/packages/ai-image-editor/tests/emulator.ts b/packages/ai-image-editor/tests/emulator.ts index 36e676b..44b995e 100644 --- a/packages/ai-image-editor/tests/emulator.ts +++ b/packages/ai-image-editor/tests/emulator.ts @@ -1,95 +1,73 @@ -// TEMPORARY: `@uploadcare/api-emulator` is a `file:` dependency on an unreleased -// checkout (see packages/ai-image-editor/package.json); swap it for a version -// range once the package ships, and drop this comment. -import { handle, resetSession, SESSION_HEADER, SIGNED_UPLOADS_SECRET_KEY } from '@uploadcare/api-emulator'; -import { generateAuthToken } from '@uploadcare/signed-uploads/server'; -import type { Page } from 'playwright'; -import type { BrowserCommand } from 'vitest/node'; -// Types `page` on the command context. -import type {} from '@vitest/browser-playwright'; +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest'; +// TEMPORARY: file: dependency, see tests/commands.ts. +import { handle, resetSession } from '@uploadcare/api-emulator'; +import { http, passthrough } from 'msw'; +import { setupWorker } from 'msw/browser'; /** - * Node-side browser commands that put the Uploadcare API emulator behind the - * test page, so the browser suite never reaches the real Upload API or CDN. + * The Uploadcare API emulator, running in the page. Two entry points, one state: + * - `XMLHttpRequestInterceptor` answers XHR (the file-uploader's uploads) in-process, so `xhr.upload` progress + * fires per body chunk the way MSW's recipe describes + * (https://mswjs.io/docs/recipes/xmlhttprequest-progress-events/); + * - the MSW Service Worker answers `fetch` (the derivative API) and resource loads (`` results), which no + * in-page hook can reach. * - * Only Uploadcare's hosts are routed, plus `cdn.example.com`, the CDN cname the - * editor tests configure. Anything else (e.g. the Unsplash image the uploader - * tests import from a URL) is left alone: the emulator's `from_url` answers for - * that source without fetching it. + * Only Uploadcare's hosts are emulated, plus `cdn.example.com`, the CDN cname the editor tests configure. + * Anything else (the page's own modules from the Vite server, or the Unsplash image the uploader tests import + * from a URL) goes through to the real network; the emulator's `from_url` answers for that source without + * fetching it. */ const UPLOADCARE = /^https:\/\/(?:[\w-]+\.)*(?:uploadcare\.com|ucarecdn\.com|ucarecd\.net|cdn\.example\.com)\//; -/** One emulator session per page, so test files running side by side keep their files apart. */ -const sessions = new WeakMap(); -let opened = 0; +/** `handle` routes by path alone, so the host check is here. */ +const answer = (request: Request) => (UPLOADCARE.test(request.url) ? handle(request) : undefined); + +let started: Promise | undefined; + +const nextTask = () => new Promise((resolve) => setTimeout(resolve)); /** - * Answer the page's Uploadcare requests from the emulator, starting from an - * empty session. Call it before every test: the route is installed once per - * page, the session is reset each time. - * - * Requests are fulfilled in place from `handle()` (the README's "As a - * function" shape) rather than redirected to an emulator server: nothing here - * depends on the browser making a real request, so there's no server or TLS - * certificate to manage. + * Holds the response body back by one macrotask. In-page, the whole XHR — upload progress, response, `load` — + * would otherwise complete within microtasks of `send()`, before the uploader's store flushes (a `setTimeout(0)`), + * and `file-upload-start`/`file-upload-progress` would never be observed. A real network cannot answer before the + * body has gone out; this keeps that order. */ -const useEmulator: BrowserCommand<[]> = async ({ page }) => { - const existing = sessions.get(page); - if (existing) { - resetSession(existing); - return; +const afterUpload = (response: Response): Response => { + if (!response.body) { + return response; } - - const session = `session-${++opened}`; - sessions.set(page, session); - resetSession(session); - - await page.route(UPLOADCARE, async (route) => { - const request = route.request(); - const method = request.method(); - // Every request here is cross-origin, as it is for the real API. - const cors = { 'access-control-allow-origin': '*' }; - if (method === 'OPTIONS') { - const allowHeaders = (await request.headerValue('access-control-request-headers')) ?? '*'; - return route.fulfill({ - status: 204, - headers: { - ...cors, - 'access-control-allow-methods': 'GET, POST, PUT, HEAD', - 'access-control-allow-headers': allowHeaders, - }, - }); - } - const body = method === 'GET' || method === 'HEAD' ? null : request.postDataBuffer(); - const response = await handle( - new Request(request.url(), { - method, - headers: { ...request.headers(), [SESSION_HEADER]: session }, - body: body && new Uint8Array(body), - }), - ); - if (!response) { - // The page only sees a generic network failure, so name the gap here. - console.warn(`The emulator does not implement ${method} ${request.url()}`); - return route.abort(); - } - await route.fulfill({ - status: response.status, - headers: { ...Object.fromEntries(response.headers), ...cors }, - body: Buffer.from(await response.arrayBuffer()), - }); + const body = new ReadableStream({ + async start(controller) { + const bytes = new Uint8Array(await response.arrayBuffer()); + await nextTask(); + controller.enqueue(bytes); + controller.close(); + }, }); + return new Response(body, response); }; -/** A Bearer token the emulator accepts; `id` keeps tokens minted in the same second apart. */ -const mintAuthToken: BrowserCommand<[id?: string]> = async (_context, id = 'token') => - generateAuthToken(SIGNED_UPLOADS_SECRET_KEY, { lifetime: 600_000, tokenId: id }); +const start = async () => { + const xhr = new XMLHttpRequestInterceptor(); + xhr.on('request', async ({ request, controller }) => { + const response = await answer(request); + if (response) { + controller.respondWith(afterUpload(response)); + } + }); + xhr.apply(); -export const emulatorCommands = { useEmulator, mintAuthToken }; + const worker = setupWorker(http.all('*', async ({ request }) => (await answer(request)) ?? passthrough())); + // bypass: the page's own modules come from the Vite server. + await worker.start({ onUnhandledRequest: 'bypass', quiet: true }); +}; -declare module 'vitest/browser' { - interface BrowserCommands { - useEmulator: () => Promise; - mintAuthToken: (id?: string) => Promise; - } -} +/** + * Answer the page's Uploadcare requests from the emulator, starting from an empty session. Call it before every + * test: the worker and interceptor are installed once per page, the session is reset each time. + */ +export const useEmulator = async () => { + started ??= start(); + await started; + resetSession(); +}; diff --git a/packages/ai-image-editor/tests/setup.ts b/packages/ai-image-editor/tests/setup.ts index b295043..2319cf6 100644 --- a/packages/ai-image-editor/tests/setup.ts +++ b/packages/ai-image-editor/tests/setup.ts @@ -1,7 +1,7 @@ import { beforeEach } from 'vitest'; -import { commands } from 'vitest/browser'; +import { useEmulator } from './emulator'; /** Every test starts against an empty Uploadcare emulator — see `tests/emulator.ts`. */ beforeEach(async () => { - await commands.useEmulator(); + await useEmulator(); }); diff --git a/packages/ai-image-editor/vitest.config.ts b/packages/ai-image-editor/vitest.config.ts index c7863d8..3a4f368 100644 --- a/packages/ai-image-editor/vitest.config.ts +++ b/packages/ai-image-editor/vitest.config.ts @@ -1,6 +1,6 @@ import { playwright } from '@vitest/browser-playwright'; import { defineConfig } from 'vitest/config'; -import { emulatorCommands } from './tests/emulator'; +import { emulatorCommands } from './tests/commands'; export default defineConfig({ test: { From 6128bf9f8f3eb65de42342b641d3b7ebb2c749b9 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Tue, 6 Oct 2026 12:07:31 +0300 Subject: [PATCH 011/104] test(ai-image-editor): fail unimplemented Uploadcare endpoints instead of passing them through Co-Authored-By: Claude Fable 5.1 --- packages/ai-image-editor/tests/emulator.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/ai-image-editor/tests/emulator.ts b/packages/ai-image-editor/tests/emulator.ts index 44b995e..48ef7c2 100644 --- a/packages/ai-image-editor/tests/emulator.ts +++ b/packages/ai-image-editor/tests/emulator.ts @@ -19,8 +19,21 @@ import { setupWorker } from 'msw/browser'; */ const UPLOADCARE = /^https:\/\/(?:[\w-]+\.)*(?:uploadcare\.com|ucarecdn\.com|ucarecd\.net|cdn\.example\.com)\//; -/** `handle` routes by path alone, so the host check is here. */ -const answer = (request: Request) => (UPLOADCARE.test(request.url) ? handle(request) : undefined); +/** + * `handle` routes by path alone, so the host check is here. An Uploadcare URL the emulator has no route for fails + * as a network error (and names the gap in the console) rather than reaching the real API. + */ +const answer = async (request: Request): Promise => { + if (!UPLOADCARE.test(request.url)) { + return undefined; + } + const response = await handle(request); + if (!response) { + console.warn(`The emulator does not implement ${request.method} ${request.url}`); + return Response.error(); + } + return response; +}; let started: Promise | undefined; From 32ef0e6b4984a26d2033f66d2df5bf6c968dcebc Mon Sep 17 00:00:00 2001 From: nd0ut Date: Tue, 6 Oct 2026 12:07:32 +0300 Subject: [PATCH 012/104] test(ai-image-editor): name the real reason token signing stays in Node Co-Authored-By: Claude Fable 5.1 --- packages/ai-image-editor/tests/commands.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ai-image-editor/tests/commands.ts b/packages/ai-image-editor/tests/commands.ts index 1de2188..3b9ce08 100644 --- a/packages/ai-image-editor/tests/commands.ts +++ b/packages/ai-image-editor/tests/commands.ts @@ -9,7 +9,7 @@ import type { BrowserCommand } from 'vitest/node'; const mintAuthToken: BrowserCommand<[id?: string]> = async (_context, id = 'token') => generateAuthToken(SIGNED_UPLOADS_SECRET_KEY, { lifetime: 600_000, tokenId: id }); -/** Node-side browser commands: signing needs the secret, which stays out of the page. */ +/** Node-side browser command: `@uploadcare/signed-uploads/server` signs with `node:crypto`, so tokens are minted outside the page. */ export const emulatorCommands = { mintAuthToken }; declare module 'vitest/browser' { From 931f08722734257c919de6acd877c10558bfa0e4 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Tue, 6 Oct 2026 12:07:32 +0300 Subject: [PATCH 013/104] docs: describe the in-page emulator for browser e2e Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6471b59..6356d89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,10 +87,13 @@ responses. call `resetSession()` before each test. `getFileInfo` goes through upload-client's own transport, so those specs start a real emulator server (`@uploadcare/api-emulator/listen`) and pass its origin as `baseUrl`. -- **Browser e2e** get the emulator from `tests/emulator.ts`: browser commands - that `page.route` Uploadcare's hosts (and `cdn.example.com`, the tests' CDN - cname) to `handle()`, with a fresh session before every test - (`tests/setup.ts`). The editor tests use `DERIVATIVE_INSTANT_PUBLIC_KEY` so a +- **Browser e2e** run the emulator in the page (`tests/emulator.ts`): an MSW + Service Worker answers `fetch` and `` loads, `@mswjs/interceptors`' + `XMLHttpRequestInterceptor` answers the uploader's XHR, both from `handle()` + for Uploadcare's hosts (and `cdn.example.com`, the tests' CDN cname); + `tests/setup.ts` resets the session before every test. Signing stays + Node-side in `tests/commands.ts` (`commands.mintAuthToken()`). The editor + tests use `DERIVATIVE_INSTANT_PUBLIC_KEY` so a generation finishes on its first poll instead of after the editor's 1.5s interval several times over. - Bearer tokens must be real: mint them with `mintAuthToken()` (specs) or From 6877dfeaab5f1559e3787edb5325dcc314902716 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Tue, 6 Oct 2026 13:51:30 +0300 Subject: [PATCH 014/104] test(ai-image-editor): wire the emulator into the page with @uploadcare/api-emulator/browser setupEmulator() replaces the local MSW worker, XHR interceptor and response hold. Only the hosts the emulator answers are emulated now, not every *.uploadcare.com; other origins still pass through. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ai-image-editor/tests/editor/harness.ts | 4 +- packages/ai-image-editor/tests/emulator.ts | 86 ------------------- packages/ai-image-editor/tests/setup.ts | 14 ++- 3 files changed, 13 insertions(+), 91 deletions(-) delete mode 100644 packages/ai-image-editor/tests/emulator.ts diff --git a/packages/ai-image-editor/tests/editor/harness.ts b/packages/ai-image-editor/tests/editor/harness.ts index 6bf99f3..99e3cdb 100644 --- a/packages/ai-image-editor/tests/editor/harness.ts +++ b/packages/ai-image-editor/tests/editor/harness.ts @@ -10,7 +10,7 @@ import { cleanup } from '../test-renderer'; * Shared setup for the `` browser tests, which are split by * subject (mounting, generation, history, layout, …) and all need the same * three things: the elements registered, the Upload API (the emulator, see - * `tests/emulator.ts`), and a mounted editor. Importing this module registers + * `tests/setup.ts`), and a mounted editor. Importing this module registers * the custom elements and the teardown for whichever test file pulls it in. */ export { UcAiImageEditor }; @@ -27,7 +27,7 @@ afterEach(() => { cleanup(); }); -/** The page's own fetch, which `tests/emulator.ts` answers from the emulator. */ +/** The page's own fetch, which `tests/setup.ts` answers from the emulator. */ const realFetch = globalThis.fetch.bind(globalThis); /** diff --git a/packages/ai-image-editor/tests/emulator.ts b/packages/ai-image-editor/tests/emulator.ts deleted file mode 100644 index 48ef7c2..0000000 --- a/packages/ai-image-editor/tests/emulator.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest'; -// TEMPORARY: file: dependency, see tests/commands.ts. -import { handle, resetSession } from '@uploadcare/api-emulator'; -import { http, passthrough } from 'msw'; -import { setupWorker } from 'msw/browser'; - -/** - * The Uploadcare API emulator, running in the page. Two entry points, one state: - * - `XMLHttpRequestInterceptor` answers XHR (the file-uploader's uploads) in-process, so `xhr.upload` progress - * fires per body chunk the way MSW's recipe describes - * (https://mswjs.io/docs/recipes/xmlhttprequest-progress-events/); - * - the MSW Service Worker answers `fetch` (the derivative API) and resource loads (`` results), which no - * in-page hook can reach. - * - * Only Uploadcare's hosts are emulated, plus `cdn.example.com`, the CDN cname the editor tests configure. - * Anything else (the page's own modules from the Vite server, or the Unsplash image the uploader tests import - * from a URL) goes through to the real network; the emulator's `from_url` answers for that source without - * fetching it. - */ -const UPLOADCARE = /^https:\/\/(?:[\w-]+\.)*(?:uploadcare\.com|ucarecdn\.com|ucarecd\.net|cdn\.example\.com)\//; - -/** - * `handle` routes by path alone, so the host check is here. An Uploadcare URL the emulator has no route for fails - * as a network error (and names the gap in the console) rather than reaching the real API. - */ -const answer = async (request: Request): Promise => { - if (!UPLOADCARE.test(request.url)) { - return undefined; - } - const response = await handle(request); - if (!response) { - console.warn(`The emulator does not implement ${request.method} ${request.url}`); - return Response.error(); - } - return response; -}; - -let started: Promise | undefined; - -const nextTask = () => new Promise((resolve) => setTimeout(resolve)); - -/** - * Holds the response body back by one macrotask. In-page, the whole XHR — upload progress, response, `load` — - * would otherwise complete within microtasks of `send()`, before the uploader's store flushes (a `setTimeout(0)`), - * and `file-upload-start`/`file-upload-progress` would never be observed. A real network cannot answer before the - * body has gone out; this keeps that order. - */ -const afterUpload = (response: Response): Response => { - if (!response.body) { - return response; - } - const body = new ReadableStream({ - async start(controller) { - const bytes = new Uint8Array(await response.arrayBuffer()); - await nextTask(); - controller.enqueue(bytes); - controller.close(); - }, - }); - return new Response(body, response); -}; - -const start = async () => { - const xhr = new XMLHttpRequestInterceptor(); - xhr.on('request', async ({ request, controller }) => { - const response = await answer(request); - if (response) { - controller.respondWith(afterUpload(response)); - } - }); - xhr.apply(); - - const worker = setupWorker(http.all('*', async ({ request }) => (await answer(request)) ?? passthrough())); - // bypass: the page's own modules come from the Vite server. - await worker.start({ onUnhandledRequest: 'bypass', quiet: true }); -}; - -/** - * Answer the page's Uploadcare requests from the emulator, starting from an empty session. Call it before every - * test: the worker and interceptor are installed once per page, the session is reset each time. - */ -export const useEmulator = async () => { - started ??= start(); - await started; - resetSession(); -}; diff --git a/packages/ai-image-editor/tests/setup.ts b/packages/ai-image-editor/tests/setup.ts index 2319cf6..73bab25 100644 --- a/packages/ai-image-editor/tests/setup.ts +++ b/packages/ai-image-editor/tests/setup.ts @@ -1,7 +1,15 @@ +// TEMPORARY: file: dependency, see tests/commands.ts. +import { setupEmulator } from '@uploadcare/api-emulator/browser'; import { beforeEach } from 'vitest'; -import { useEmulator } from './emulator'; -/** Every test starts against an empty Uploadcare emulator — see `tests/emulator.ts`. */ +/** + * The Uploadcare emulator, running in the page, for Uploadcare's hosts and `cdn.example.com` (the CDN cname the + * editor tests configure). Everything else passes through: the uploader tests import an Unsplash image from a URL, + * and the emulator's `from_url` answers for that source without fetching it. + */ +const emulator = setupEmulator({ cdnHosts: ['cdn.example.com'], unhandled: 'passthrough' }); + +/** Every test starts against an empty session. */ beforeEach(async () => { - await useEmulator(); + await emulator.reset(); }); From 480eadb748d0c32a29ecaf3bbf3cbaf2d5e4b454 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Tue, 6 Oct 2026 13:51:30 +0300 Subject: [PATCH 015/104] docs: point the browser e2e notes at setupEmulator() Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6356d89..aa5cf6c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,11 +87,12 @@ responses. call `resetSession()` before each test. `getFileInfo` goes through upload-client's own transport, so those specs start a real emulator server (`@uploadcare/api-emulator/listen`) and pass its origin as `baseUrl`. -- **Browser e2e** run the emulator in the page (`tests/emulator.ts`): an MSW - Service Worker answers `fetch` and `` loads, `@mswjs/interceptors`' - `XMLHttpRequestInterceptor` answers the uploader's XHR, both from `handle()` - for Uploadcare's hosts (and `cdn.example.com`, the tests' CDN cname); - `tests/setup.ts` resets the session before every test. Signing stays +- **Browser e2e** run the emulator in the page with + `setupEmulator()` from `@uploadcare/api-emulator/browser` (MSW and + `@mswjs/interceptors` underneath, both devDependencies here): + `tests/setup.ts` emulates Uploadcare's hosts and `cdn.example.com` (the + tests' CDN cname), passes every other origin through, and resets the session + before every test. Signing stays Node-side in `tests/commands.ts` (`commands.mintAuthToken()`). The editor tests use `DERIVATIVE_INSTANT_PUBLIC_KEY` so a generation finishes on its first poll instead of after the editor's 1.5s From 407685bf708a53028afb833ea9effb81ae6dccd0 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Tue, 6 Oct 2026 15:22:52 +0300 Subject: [PATCH 016/104] docs: note the emulator fails Uploadcare hosts it doesn't emulate Under `unhandled: 'passthrough'` the emulator now refuses any Uploadcare host it has no answer for (api.uploadcare.com, social.uploadcare.com, a .ucarecdn.com), restoring the guard the old tests/emulator.ts gave; only non-Uploadcare origins such as Unsplash pass through. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 4 ++-- packages/ai-image-editor/tests/setup.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index aa5cf6c..31de878 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,8 +91,8 @@ responses. `setupEmulator()` from `@uploadcare/api-emulator/browser` (MSW and `@mswjs/interceptors` underneath, both devDependencies here): `tests/setup.ts` emulates Uploadcare's hosts and `cdn.example.com` (the - tests' CDN cname), passes every other origin through, and resets the session - before every test. Signing stays + tests' CDN cname), fails any other Uploadcare host, passes every + non-Uploadcare origin through, and resets the session before every test. Signing stays Node-side in `tests/commands.ts` (`commands.mintAuthToken()`). The editor tests use `DERIVATIVE_INSTANT_PUBLIC_KEY` so a generation finishes on its first poll instead of after the editor's 1.5s diff --git a/packages/ai-image-editor/tests/setup.ts b/packages/ai-image-editor/tests/setup.ts index 73bab25..bd7752d 100644 --- a/packages/ai-image-editor/tests/setup.ts +++ b/packages/ai-image-editor/tests/setup.ts @@ -4,7 +4,7 @@ import { beforeEach } from 'vitest'; /** * The Uploadcare emulator, running in the page, for Uploadcare's hosts and `cdn.example.com` (the CDN cname the - * editor tests configure). Everything else passes through: the uploader tests import an Unsplash image from a URL, + * editor tests configure). Any other Uploadcare host fails; every other origin passes through: the uploader tests import an Unsplash image from a URL, * and the emulator's `from_url` answers for that source without fetching it. */ const emulator = setupEmulator({ cdnHosts: ['cdn.example.com'], unhandled: 'passthrough' }); From 2d1e6db57cc427dc5f11fef527e0baaca96a62d0 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Wed, 7 Oct 2026 15:07:51 +0300 Subject: [PATCH 017/104] test(ai-image-editor): steer the emulator with presets instead of magic keys The emulator dropped DERIVATIVE_INSTANT_PUBLIC_KEY, DERIVATIVE_DISABLED_PUBLIC_KEY, SIGNED_UPLOADS_PUBLIC_KEY and CONTENT_MODERATED_PROMPT for per-session presets. Specs keep the handle resetSession() answers and apply derivativesDisabled, derivativeFailure and signedUploads on demopublickey; the editor e2e apply derivativesInstant in the harness. The in-page emulator moves to tests/emulator.ts so a test can import its session: importing the setup file itself loads a second copy of it, and a second XHR interceptor. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 12 ++++++++---- .../provider/api/uploadcareApiClient.test.ts | 10 +++++++--- .../api/uploadcareDerivativeApi.test.ts | 19 +++++++++++++------ .../tests/editor/generation.e2e.test.tsx | 9 +++++---- .../ai-image-editor/tests/editor/harness.ts | 16 ++++++++++------ packages/ai-image-editor/tests/emulator.ts | 19 +++++++++++++++++++ packages/ai-image-editor/tests/setup.ts | 18 +++--------------- 7 files changed, 65 insertions(+), 38 deletions(-) create mode 100644 packages/ai-image-editor/tests/emulator.ts diff --git a/AGENTS.md b/AGENTS.md index 31de878..1e4abba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,13 +90,17 @@ responses. - **Browser e2e** run the emulator in the page with `setupEmulator()` from `@uploadcare/api-emulator/browser` (MSW and `@mswjs/interceptors` underneath, both devDependencies here): - `tests/setup.ts` emulates Uploadcare's hosts and `cdn.example.com` (the + `tests/emulator.ts` emulates Uploadcare's hosts and `cdn.example.com` (the tests' CDN cname), fails any other Uploadcare host, passes every - non-Uploadcare origin through, and resets the session before every test. Signing stays + non-Uploadcare origin through, and resets the session before every test; + import `session` from there to register a scenario or preset. Signing stays Node-side in `tests/commands.ts` (`commands.mintAuthToken()`). The editor - tests use `DERIVATIVE_INSTANT_PUBLIC_KEY` so a - generation finishes on its first poll instead of after the editor's 1.5s + tests apply the `derivativesInstant` preset (`tests/editor/harness.ts`) so + a generation finishes on its first poll instead of after the editor's 1.5s interval several times over. +- Steer the emulator per test with `session.on()` and `session.use(preset)` + (`resetSession()` answers the session in specs); there are no magic keys, + prompts or uuids. - Bearer tokens must be real: mint them with `mintAuthToken()` (specs) or `commands.mintAuthToken()` (e2e). - A hand-written stub is still right for what the emulator can't do (a bare diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts index 90d3d72..4cb6f85 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts @@ -1,5 +1,5 @@ // TEMPORARY: file: dependency, see emulator.testing.ts. -import { DERIVATIVE_DISABLED_PUBLIC_KEY, resetSession } from '@uploadcare/api-emulator'; +import { type EmulatorSession, resetSession } from '@uploadcare/api-emulator'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AiProviderError } from '../model/types'; import { emulatorFetch, mintAuthToken, SEEDED_IMAGE_UUID } from './emulator.testing'; @@ -20,7 +20,10 @@ const sentBody = (fetchImpl: ReturnType, call = 0) => const plainTextFailure = (status: number, statusText: string) => vi.fn().mockResolvedValue(new Response('upstream failure', { status, statusText })); -beforeEach(() => resetSession()); +let session: EmulatorSession; +beforeEach(() => { + session = resetSession(); +}); describe('UploadcareApiClient', () => { it('throws when publicKey is missing', () => { @@ -141,7 +144,8 @@ describe('UploadcareApiClient', () => { }); it('surfaces a platform error envelope as an AiProviderError with its code', async () => { - const client = new UploadcareApiClient({ publicKey: DERIVATIVE_DISABLED_PUBLIC_KEY, fetch: emulatorFetch() }); + session.use('derivativesDisabled'); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: emulatorFetch() }); await expect(client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' })).rejects.toMatchObject({ name: 'AiProviderError', errorCode: 'derivative_disabled', diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts index 69074b9..fcfc836 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts @@ -1,5 +1,5 @@ // TEMPORARY: file: dependency, see emulator.testing.ts. -import { CONTENT_MODERATED_PROMPT, resetSession, SIGNED_UPLOADS_PUBLIC_KEY } from '@uploadcare/api-emulator'; +import { type EmulatorSession, resetSession } from '@uploadcare/api-emulator'; import { createEmulatorServer } from '@uploadcare/api-emulator/listen'; import { getPrefixedCdnBaseAsync } from '@uploadcare/cname-prefix/async'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -39,7 +39,10 @@ beforeAll(async () => { ({ origin: uploadOrigin, close: closeEmulator } = await createEmulatorServer({ delayMs: 0 })); }); afterAll(() => closeEmulator()); -beforeEach(() => resetSession()); +let session: EmulatorSession; +beforeEach(() => { + session = resetSession(); +}); describe('UploadcareDerivativeApi', () => { it('throws when publicKey is missing', () => { @@ -110,8 +113,9 @@ describe('UploadcareDerivativeApi', () => { }); it('throws when the job ends in an error status', async () => { + session.use('derivativeFailure', { code: 'content_moderated' }); const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: emulatorFetch(), ...NO_DELAY }); - await expect(provider.generate({ prompt: CONTENT_MODERATED_PROMPT, mode: 'generate' })).rejects.toMatchObject({ + await expect(provider.generate({ prompt: 'x', mode: 'generate' })).rejects.toMatchObject({ name: 'AiProviderError', errorCode: 'content_moderated', }); @@ -308,6 +312,9 @@ describe('UploadcareDerivativeApi', () => { describe('authToken', () => { // A signed-uploads project refuses every request without a valid Bearer // token, so each call below succeeding is the proof the token got there. + beforeEach(() => { + session.use('signedUploads'); + }); it('carries the token on both network paths', async () => { // The two paths are independent: `generate`/`edit`/`status` go through @@ -317,7 +324,7 @@ describe('UploadcareDerivativeApi', () => { const token = mintAuthToken(); const fetchImpl = emulatorFetch(); const provider = new UploadcareDerivativeApi({ - publicKey: SIGNED_UPLOADS_PUBLIC_KEY, + publicKey: PUBLIC_KEY, baseUrl: uploadOrigin, authToken: token, fetch: fetchImpl, @@ -332,7 +339,7 @@ describe('UploadcareDerivativeApi', () => { it('is refused on both network paths without one', async () => { const provider = new UploadcareDerivativeApi({ - publicKey: SIGNED_UPLOADS_PUBLIC_KEY, + publicKey: PUBLIC_KEY, baseUrl: uploadOrigin, fetch: emulatorFetch(), ...NO_DELAY, @@ -347,7 +354,7 @@ describe('UploadcareDerivativeApi', () => { let token = 'not-a-token'; const fetchImpl = emulatorFetch(); const provider = new UploadcareDerivativeApi({ - publicKey: SIGNED_UPLOADS_PUBLIC_KEY, + publicKey: PUBLIC_KEY, baseUrl: uploadOrigin, authToken: () => token, fetch: fetchImpl, diff --git a/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx b/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx index ec43aea..f0a4dc9 100644 --- a/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx @@ -1,7 +1,6 @@ -// TEMPORARY: file: dependency, see tests/commands.ts. -import { CONTENT_MODERATED_PROMPT } from '@uploadcare/api-emulator'; import { describe, expect, it, vi } from 'vitest'; import { enLocale } from '../../src/shared/i18n/en'; +import { session } from '../emulator'; import { canvasUrl, clickPrimary, @@ -95,12 +94,14 @@ describe(' generation', () => { }); it('reports a refused run with uc:error and its message, and keeps the prompt', async () => { + // derivativesInstant again, so it wraps the failure and the error comes on the first poll. + session.use('derivativeFailure', { code: 'content_moderated' }).use('derivativesInstant'); const el = mount(STAGING); await el.updateComplete; const onError = vi.fn(); el.addEventListener('uc:error', onError); - typePrompt(el, CONTENT_MODERATED_PROMPT); + typePrompt(el, 'a tiger'); await el.updateComplete; clickSend(el); await vi.waitFor(() => expect(onError).toHaveBeenCalledTimes(1)); @@ -113,7 +114,7 @@ describe(' generation', () => { expect(canvasUrl(el)).toBeNull(); expect(editorMode(el)).toBe('generate'); const input = el.shadowRoot!.querySelector('uc-ai-prompt-row')!.shadowRoot!.querySelector('textarea')!; - expect(input.value).toBe(CONTENT_MODERATED_PROMPT); + expect(input.value).toBe('a tiger'); }); it.skip('fires uc:change as the current result appears and clears', async () => { diff --git a/packages/ai-image-editor/tests/editor/harness.ts b/packages/ai-image-editor/tests/editor/harness.ts index 99e3cdb..cf9edf4 100644 --- a/packages/ai-image-editor/tests/editor/harness.ts +++ b/packages/ai-image-editor/tests/editor/harness.ts @@ -1,16 +1,16 @@ // TEMPORARY: file: dependency, see tests/commands.ts. -import { DERIVATIVE_INSTANT_PUBLIC_KEY } from '@uploadcare/api-emulator'; -import { afterEach } from 'vitest'; +import { afterEach, beforeEach } from 'vitest'; import { page } from 'vitest/browser'; import type { UcAiImageEditor as UcAiImageEditorType } from '../../src/index'; import { UcAiImageEditor } from '../../src/index'; +import { session } from '../emulator'; import { cleanup } from '../test-renderer'; /** * Shared setup for the `` browser tests, which are split by * subject (mounting, generation, history, layout, …) and all need the same * three things: the elements registered, the Upload API (the emulator, see - * `tests/setup.ts`), and a mounted editor. Importing this module registers + * `tests/emulator.ts`), and a mounted editor. Importing this module registers * the custom elements and the teardown for whichever test file pulls it in. */ export { UcAiImageEditor }; @@ -18,6 +18,11 @@ export type { UcAiImageEditorType }; let restoreFetch: (() => void) | null = null; +/** Generations finish on their first status poll, so a test doesn't sit through the editor's 1.5s interval. */ +beforeEach(() => { + session.use('derivativesInstant'); +}); + afterEach(() => { restoreFetch?.(); restoreFetch = null; @@ -27,7 +32,7 @@ afterEach(() => { cleanup(); }); -/** The page's own fetch, which `tests/setup.ts` answers from the emulator. */ +/** The page's own fetch, which `tests/emulator.ts` answers from the emulator. */ const realFetch = globalThis.fetch.bind(globalThis); /** @@ -85,8 +90,7 @@ export function mount(attrs: Record = {}): UcAiImageEditorType { return el; } -/** Generations finish on their first status poll, so a test doesn't sit through the editor's 1.5s interval. */ -export const STAGING = { pubkey: DERIVATIVE_INSTANT_PUBLIC_KEY, 'cdn-cname': 'https://cdn.example.com' }; +export const STAGING = { pubkey: 'demopublickey', 'cdn-cname': 'https://cdn.example.com' }; /** An image every fresh emulator session already holds. */ export const SAMPLE_UUID = '49b4c5a1-31b3-4349-ba07-d97a2d883c37'; diff --git a/packages/ai-image-editor/tests/emulator.ts b/packages/ai-image-editor/tests/emulator.ts new file mode 100644 index 0000000..3b33d39 --- /dev/null +++ b/packages/ai-image-editor/tests/emulator.ts @@ -0,0 +1,19 @@ +// TEMPORARY: file: dependency, see tests/commands.ts. +import type { EmulatorSession } from '@uploadcare/api-emulator'; +import { setupEmulator } from '@uploadcare/api-emulator/browser'; +import { beforeEach } from 'vitest'; + +/** + * The Uploadcare emulator, running in the page, for Uploadcare's hosts and `cdn.example.com` (the CDN cname the + * editor tests configure). Any other Uploadcare host fails; every other origin passes through: the uploader tests import an Unsplash image from a URL, + * and the emulator's `from_url` answers for that source without fetching it. + */ +const emulator = setupEmulator({ cdnHosts: ['cdn.example.com'], unhandled: 'passthrough' }); + +/** The current test's emulator session, for a test that registers a scenario or preset on it. */ +export let session: EmulatorSession; + +/** Every test starts against an empty session. */ +beforeEach(async () => { + session = await emulator.reset(); +}); diff --git a/packages/ai-image-editor/tests/setup.ts b/packages/ai-image-editor/tests/setup.ts index bd7752d..64d52f4 100644 --- a/packages/ai-image-editor/tests/setup.ts +++ b/packages/ai-image-editor/tests/setup.ts @@ -1,15 +1,3 @@ -// TEMPORARY: file: dependency, see tests/commands.ts. -import { setupEmulator } from '@uploadcare/api-emulator/browser'; -import { beforeEach } from 'vitest'; - -/** - * The Uploadcare emulator, running in the page, for Uploadcare's hosts and `cdn.example.com` (the CDN cname the - * editor tests configure). Any other Uploadcare host fails; every other origin passes through: the uploader tests import an Unsplash image from a URL, - * and the emulator's `from_url` answers for that source without fetching it. - */ -const emulator = setupEmulator({ cdnHosts: ['cdn.example.com'], unhandled: 'passthrough' }); - -/** Every test starts against an empty session. */ -beforeEach(async () => { - await emulator.reset(); -}); +// The emulator lives in its own module: a test importing `session` from the +// setup file itself would get a second copy of it, and a second emulator. +import './emulator'; From 60044fa8565ae3b9c31b9b96e4501e6d9ec8f0b2 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Wed, 7 Oct 2026 15:08:57 +0300 Subject: [PATCH 018/104] test(ai-image-editor): read sent requests off emulatorFetch instead of re-parsing RequestInit emulatorFetch already builds a Request to route; it now keeps a copy of each one in `requests`, so specs read url, method, headers and body straight off it. That deletes sentInit, sentBody and authOf (two diverging copies) and every headers/body cast. Signal identity still reads the init, since a copied Request carries its own signal. plainTextFailure moves next to emulatorFetch and replaces the inline text/plain stub in the derivative spec; a string body is text/plain already. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../entities/provider/api/emulator.testing.ts | 20 +++++- .../provider/api/uploadcareApiClient.test.ts | 68 +++++++------------ .../api/uploadcareDerivativeApi.test.ts | 56 +++++++-------- 3 files changed, 66 insertions(+), 78 deletions(-) diff --git a/packages/ai-image-editor/src/entities/provider/api/emulator.testing.ts b/packages/ai-image-editor/src/entities/provider/api/emulator.testing.ts index b9d6f50..01504bd 100644 --- a/packages/ai-image-editor/src/entities/provider/api/emulator.testing.ts +++ b/packages/ai-image-editor/src/entities/provider/api/emulator.testing.ts @@ -7,18 +7,32 @@ import { vi } from 'vitest'; /** * A `fetch` answered by the Uploadcare API emulator instead of the network, - * spied so a spec can read back what was sent. Reset the emulator's state with + * spied so a spec can read back what was sent: `requests` holds a copy of every + * request it answered, in order. Reset the emulator's state with * `resetSession()` between tests. */ -export const emulatorFetch = () => - vi.fn(async (input, init) => { +export const emulatorFetch = () => { + const requests: Request[] = []; + const spy = vi.fn(async (input, init) => { // What a real fetch does with a signal that is already aborted. init?.signal?.throwIfAborted(); const request = new Request(input, init); + // A copy, since the emulator reads the body of the one it answers. + requests.push(request.clone()); const response = await handle(request); if (!response) throw new TypeError(`The emulator does not implement ${request.method} ${request.url}`); return response; }); + return Object.assign(spy, { requests }); +}; + +/** + * The emulator answers every refusal with the JSON error envelope, so it can't + * produce the bare non-2xx, non-JSON response a proxy or an outage would. This + * stub stands in for that. + */ +export const plainTextFailure = (status: number, statusText: string) => + vi.fn().mockResolvedValue(new Response('upstream failure', { status, statusText })); /** A Bearer token the emulator accepts; `id` keeps two tokens minted in the same second apart. */ export const mintAuthToken = (id = 'token') => diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts index 4cb6f85..4ef7a5e 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts @@ -2,24 +2,11 @@ import { type EmulatorSession, resetSession } from '@uploadcare/api-emulator'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AiProviderError } from '../model/types'; -import { emulatorFetch, mintAuthToken, SEEDED_IMAGE_UUID } from './emulator.testing'; +import { emulatorFetch, mintAuthToken, plainTextFailure, SEEDED_IMAGE_UUID } from './emulator.testing'; import { UploadcareApiClient } from './uploadcareApiClient'; const PUBLIC_KEY = 'demopublickey'; -const sentInit = (fetchImpl: ReturnType, call = 0) => - fetchImpl.mock.calls[call]![1] as RequestInit; -const sentBody = (fetchImpl: ReturnType, call = 0) => - JSON.parse(sentInit(fetchImpl, call).body as string); - -/** - * The emulator answers every refusal with the JSON error envelope, so it can't - * produce the bare non-2xx, non-JSON response a proxy or an outage would. This - * stub stands in for that. - */ -const plainTextFailure = (status: number, statusText: string) => - vi.fn().mockResolvedValue(new Response('upstream failure', { status, statusText })); - let session: EmulatorSession; beforeEach(() => { session = resetSession(); @@ -31,16 +18,13 @@ describe('UploadcareApiClient', () => { }); describe('authToken', () => { - const authOf = (fetchImpl: ReturnType, call: number) => - new Headers(sentInit(fetchImpl, call).headers).get('Authorization'); - it('sends no Authorization header when unset', async () => { const fetchImpl = emulatorFetch(); const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: fetchImpl }); await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); - expect(authOf(fetchImpl, 0)).toBeNull(); + expect(fetchImpl.requests[0].headers.get('Authorization')).toBeNull(); }); it('sends a plain token as a bearer header', async () => { @@ -50,7 +34,7 @@ describe('UploadcareApiClient', () => { await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); - expect(authOf(fetchImpl, 0)).toBe(`Bearer ${token}`); + expect(fetchImpl.requests[0].headers.get('Authorization')).toBe(`Bearer ${token}`); }); it('re-resolves a resolver per request, so a job can rotate tokens mid-flight', async () => { @@ -62,8 +46,8 @@ describe('UploadcareApiClient', () => { const { job_id } = await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); await client.getJobStatus(job_id!); - expect(authOf(fetchImpl, 0)).toBe(`Bearer ${first}`); - expect(authOf(fetchImpl, 1)).toBe(`Bearer ${second}`); + expect(fetchImpl.requests[0].headers.get('Authorization')).toBe(`Bearer ${first}`); + expect(fetchImpl.requests[1].headers.get('Authorization')).toBe(`Bearer ${second}`); }); }); @@ -75,9 +59,9 @@ describe('UploadcareApiClient', () => { const job = await client.generate({ prompt: 'a hat', aspectRatio: [16, 9], filename: 'generated.png' }); expect(job).toEqual({ type: 'job', job_id: expect.any(String) }); - expect(fetchImpl.mock.calls[0]![0]).toBe('https://upload.uploadcare.com/derivative/image/generate/'); - expect(sentInit(fetchImpl).method).toBe('POST'); - expect(sentBody(fetchImpl)).toMatchObject({ + expect(fetchImpl.requests[0].url).toBe('https://upload.uploadcare.com/derivative/image/generate/'); + expect(fetchImpl.requests[0].method).toBe('POST'); + expect(await fetchImpl.requests[0].json()).toMatchObject({ pub_key: PUBLIC_KEY, prompt: 'a hat', aspect_ratio: [16, 9], @@ -92,8 +76,8 @@ describe('UploadcareApiClient', () => { await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png', store: true }); - expect(sentBody(fetchImpl, 0).store).toBeUndefined(); - expect(sentBody(fetchImpl, 1).store).toBe(true); + expect((await fetchImpl.requests[0].json()).store).toBeUndefined(); + expect((await fetchImpl.requests[1].json()).store).toBe(true); }); it('includes metadata only when provided', async () => { @@ -108,8 +92,8 @@ describe('UploadcareApiClient', () => { metadata: { source: 'ai-image-editor' }, }); - expect(sentBody(fetchImpl, 0).metadata).toBeUndefined(); - expect(sentBody(fetchImpl, 1).metadata).toEqual({ source: 'ai-image-editor' }); + expect((await fetchImpl.requests[0].json()).metadata).toBeUndefined(); + expect((await fetchImpl.requests[1].json()).metadata).toEqual({ source: 'ai-image-editor' }); }); it('honours baseUrl override', async () => { @@ -120,7 +104,7 @@ describe('UploadcareApiClient', () => { fetch: fetchImpl, }); await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); - expect(fetchImpl.mock.calls[0]![0]).toBe('https://upload.example.com/derivative/image/generate/'); + expect(fetchImpl.requests[0].url).toBe('https://upload.example.com/derivative/image/generate/'); }); it('throws with status text on a non-2xx response that is not the error envelope', async () => { @@ -133,14 +117,14 @@ describe('UploadcareApiClient', () => { const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: fetchImpl }); const controller = new AbortController(); await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png', signal: controller.signal }); - expect(sentInit(fetchImpl).signal).toBe(controller.signal); + expect(fetchImpl.mock.calls[0]![1]?.signal).toBe(controller.signal); }); it('sends Accept: application/json', async () => { const fetchImpl = emulatorFetch(); const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: fetchImpl }); await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); - expect((sentInit(fetchImpl).headers as Record).Accept).toBe('application/json'); + expect(fetchImpl.requests[0].headers.get('Accept')).toBe('application/json'); }); it('surfaces a platform error envelope as an AiProviderError with its code', async () => { @@ -162,9 +146,9 @@ describe('UploadcareApiClient', () => { const job = await client.edit({ prompt: 'remove the cat', source: SEEDED_IMAGE_UUID, filename: 'edited.png' }); expect(job).toEqual({ type: 'job', job_id: expect.any(String) }); - expect(fetchImpl.mock.calls[0]![0]).toBe('https://upload.uploadcare.com/derivative/image/edit/'); - expect(sentInit(fetchImpl).method).toBe('POST'); - expect(sentBody(fetchImpl)).toMatchObject({ + expect(fetchImpl.requests[0].url).toBe('https://upload.uploadcare.com/derivative/image/edit/'); + expect(fetchImpl.requests[0].method).toBe('POST'); + expect(await fetchImpl.requests[0].json()).toMatchObject({ pub_key: PUBLIC_KEY, prompt: 'remove the cat', source: SEEDED_IMAGE_UUID, @@ -179,8 +163,8 @@ describe('UploadcareApiClient', () => { await client.edit({ prompt: 'x', source: SEEDED_IMAGE_UUID, filename: 'f.png' }); await client.edit({ prompt: 'x', source: SEEDED_IMAGE_UUID, filename: 'f.png', aspectRatio: [16, 9] }); - expect(sentBody(fetchImpl, 0).aspect_ratio).toBeUndefined(); - expect(sentBody(fetchImpl, 1).aspect_ratio).toEqual([16, 9]); + expect((await fetchImpl.requests[0].json()).aspect_ratio).toBeUndefined(); + expect((await fetchImpl.requests[1].json()).aspect_ratio).toEqual([16, 9]); }); it('includes metadata only when provided', async () => { @@ -195,8 +179,8 @@ describe('UploadcareApiClient', () => { metadata: { source: 'ai-image-editor' }, }); - expect(sentBody(fetchImpl, 0).metadata).toBeUndefined(); - expect(sentBody(fetchImpl, 1).metadata).toEqual({ source: 'ai-image-editor' }); + expect((await fetchImpl.requests[0].json()).metadata).toBeUndefined(); + expect((await fetchImpl.requests[1].json()).metadata).toEqual({ source: 'ai-image-editor' }); }); it('surfaces a source the project does not have as an AiProviderError', async () => { @@ -217,7 +201,7 @@ describe('UploadcareApiClient', () => { const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: fetchImpl }); const controller = new AbortController(); await client.edit({ prompt: 'x', source: SEEDED_IMAGE_UUID, filename: 'f.png', signal: controller.signal }); - expect(sentInit(fetchImpl).signal).toBe(controller.signal); + expect(fetchImpl.mock.calls[0]![1]?.signal).toBe(controller.signal); }); }); @@ -233,10 +217,10 @@ describe('UploadcareApiClient', () => { const status = await client.getJobStatus(jobId); expect(status).toEqual({ type: 'job', status: 'processing' }); - expect(fetchImpl.mock.calls[1]![0]).toBe( + expect(fetchImpl.requests[1].url).toBe( `https://upload.uploadcare.com/derivative/status/?pub_key=${PUBLIC_KEY}&job_id=${jobId}`, ); - expect(sentInit(fetchImpl, 1).method).toBe('GET'); + expect(fetchImpl.requests[1].method).toBe('GET'); }); it('throws with status text on a non-2xx response that is not the error envelope', async () => { @@ -257,7 +241,7 @@ describe('UploadcareApiClient', () => { const jobId = await startJob(client); const controller = new AbortController(); await client.getJobStatus(jobId, controller.signal); - expect(sentInit(fetchImpl, 1).signal).toBe(controller.signal); + expect(fetchImpl.mock.calls[1]![1]?.signal).toBe(controller.signal); }); }); }); diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts index fcfc836..4243163 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts @@ -4,18 +4,13 @@ import { createEmulatorServer } from '@uploadcare/api-emulator/listen'; import { getPrefixedCdnBaseAsync } from '@uploadcare/cname-prefix/async'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { AiProviderError } from '../model/types'; -import { emulatorFetch, mintAuthToken, SEEDED_IMAGE_UUID } from './emulator.testing'; +import { emulatorFetch, mintAuthToken, plainTextFailure, SEEDED_IMAGE_UUID } from './emulator.testing'; import { UploadcareDerivativeApi } from './uploadcareDerivativeApi'; const PUBLIC_KEY = 'demopublickey'; const CDN = 'https://cdn.example.com'; const NO_DELAY = { pollIntervalMs: 0 } as const; -const sentInit = (fetchImpl: ReturnType>, call = 0) => - fetchImpl.mock.calls[call]![1] as RequestInit; -const sentBody = (fetchImpl: ReturnType>, call = 0) => - JSON.parse(sentInit(fetchImpl, call).body as string) as Record; - const jsonResponse = (body: unknown) => new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' } }); @@ -54,10 +49,10 @@ describe('UploadcareDerivativeApi', () => { const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: fetchImpl, ...NO_DELAY }); await provider.generate({ prompt: 'a hat', mode: 'generate', aspectRatio: [16, 9] }); - expect(fetchImpl.mock.calls[0]![0]).toBe('https://upload.uploadcare.com/derivative/image/generate/'); - expect(sentInit(fetchImpl).method).toBe('POST'); - expect((sentInit(fetchImpl).headers as Record)['Content-Type']).toBe('application/json'); - expect(sentBody(fetchImpl)).toMatchObject({ + expect(fetchImpl.requests[0].url).toBe('https://upload.uploadcare.com/derivative/image/generate/'); + expect(fetchImpl.requests[0].method).toBe('POST'); + expect(fetchImpl.requests[0].headers.get('Content-Type')).toBe('application/json'); + expect(await fetchImpl.requests[0].json()).toMatchObject({ pub_key: PUBLIC_KEY, prompt: 'a hat', aspect_ratio: [16, 9], @@ -69,7 +64,7 @@ describe('UploadcareDerivativeApi', () => { const fetchImpl = emulatorFetch(); const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: fetchImpl, ...NO_DELAY }); await provider.generate({ prompt: 'x', mode: 'generate', metadata: { source: 'ai-image-editor' } }); - expect(sentBody(fetchImpl).metadata).toEqual({ source: 'ai-image-editor' }); + expect((await fetchImpl.requests[0].json()).metadata).toEqual({ source: 'ai-image-editor' }); }); it('forwards request metadata to the edit endpoint', async () => { @@ -81,15 +76,15 @@ describe('UploadcareDerivativeApi', () => { source: SEEDED_IMAGE_UUID, metadata: { source: 'ai-image-editor' }, }); - expect(fetchImpl.mock.calls[0]![0]).toBe('https://upload.uploadcare.com/derivative/image/edit/'); - expect(sentBody(fetchImpl).metadata).toEqual({ source: 'ai-image-editor' }); + expect(fetchImpl.requests[0].url).toBe('https://upload.uploadcare.com/derivative/image/edit/'); + expect((await fetchImpl.requests[0].json()).metadata).toEqual({ source: 'ai-image-editor' }); }); it('uses 1:1 when aspectRatio is missing or invalid', async () => { const fetchImpl = emulatorFetch(); const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: fetchImpl, ...NO_DELAY }); await provider.generate({ prompt: 'x', mode: 'generate' }); - expect(sentBody(fetchImpl).aspect_ratio).toEqual([1, 1]); + expect((await fetchImpl.requests[0].json()).aspect_ratio).toEqual([1, 1]); }); it('polls the status endpoint with pub_key + job_id until the success frame reports is_ready', async () => { @@ -106,10 +101,10 @@ describe('UploadcareDerivativeApi', () => { expect(result.url).toBe(`${CDN}/${result.uuid}/`); // 1 POST + 4 status polls: processing, uploading, success not yet ready, success ready. expect(fetchImpl).toHaveBeenCalledTimes(5); - expect(fetchImpl.mock.calls[1]![0]).toMatch( + expect(fetchImpl.requests[1].url).toMatch( new RegExp(`^https://upload\\.uploadcare\\.com/derivative/status/\\?pub_key=${PUBLIC_KEY}&job_id=[\\w-]+$`), ); - expect(sentInit(fetchImpl, 1).method).toBe('GET'); + expect(fetchImpl.requests[1].method).toBe('GET'); }); it('throws when the job ends in an error status', async () => { @@ -155,8 +150,8 @@ describe('UploadcareDerivativeApi', () => { ...NO_DELAY, }); const result = await provider.generate({ prompt: 'x', mode: 'generate' }); - expect(fetchImpl.mock.calls[0]![0]).toBe('https://upload.example.com/derivative/image/generate/'); - expect(fetchImpl.mock.calls[1]![0]).toMatch( + expect(fetchImpl.requests[0].url).toBe('https://upload.example.com/derivative/image/generate/'); + expect(fetchImpl.requests[1].url).toMatch( new RegExp(`^https://upload\\.example\\.com/derivative/status/\\?pub_key=${PUBLIC_KEY}&job_id=`), ); expect(result.url).toBe(`${CDN}/${result.uuid}/`); @@ -186,16 +181,11 @@ describe('UploadcareDerivativeApi', () => { }); it('surfaces non-2xx generate responses with status text', async () => { - // The emulator answers refusals with the JSON error envelope; this is the - // bare non-JSON failure a proxy or an outage would send instead. - const fetchImpl = vi.fn().mockResolvedValue( - new Response('bad ratio', { - status: 400, - statusText: 'Bad Request', - headers: { 'Content-Type': 'text/plain' }, - }), - ); - const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: fetchImpl, ...NO_DELAY }); + const provider = new UploadcareDerivativeApi({ + publicKey: PUBLIC_KEY, + fetch: plainTextFailure(400, 'Bad Request'), + ...NO_DELAY, + }); await expect(provider.generate({ prompt: 'x', mode: 'generate' })).rejects.toThrow(/400/); }); @@ -253,8 +243,8 @@ describe('UploadcareDerivativeApi', () => { aspectRatio: [16, 9], }); - expect(fetchImpl.mock.calls[0]![0]).toBe('https://upload.uploadcare.com/derivative/image/edit/'); - expect(sentBody(fetchImpl)).toMatchObject({ + expect(fetchImpl.requests[0].url).toBe('https://upload.uploadcare.com/derivative/image/edit/'); + expect(await fetchImpl.requests[0].json()).toMatchObject({ pub_key: PUBLIC_KEY, prompt: 'remove the cat', source: SEEDED_IMAGE_UUID, @@ -269,7 +259,7 @@ describe('UploadcareDerivativeApi', () => { const fetchImpl = emulatorFetch(); const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: fetchImpl, ...NO_DELAY }); await provider.generate({ prompt: 'x', mode: 'edit', source: SEEDED_IMAGE_UUID }); - expect(sentBody(fetchImpl).aspect_ratio).toBeUndefined(); + expect((await fetchImpl.requests[0].json()).aspect_ratio).toBeUndefined(); }); it('throws (without any request) when an edit has no source uuid', async () => { @@ -332,7 +322,7 @@ describe('UploadcareDerivativeApi', () => { }); await provider.generate({ prompt: 'x', mode: 'generate' }); - expect(new Headers(sentInit(fetchImpl).headers).get('Authorization')).toBe(`Bearer ${token}`); + expect(fetchImpl.requests[0].headers.get('Authorization')).toBe(`Bearer ${token}`); await expect(provider.getFileInfo(SEEDED_IMAGE_UUID)).resolves.toMatchObject({ uuid: SEEDED_IMAGE_UUID }); }); @@ -364,7 +354,7 @@ describe('UploadcareDerivativeApi', () => { token = mintAuthToken('second'); await provider.generate({ prompt: 'x', mode: 'generate' }); - expect(new Headers(sentInit(fetchImpl).headers).get('Authorization')).toBe(`Bearer ${token}`); + expect(fetchImpl.requests[0].headers.get('Authorization')).toBe(`Bearer ${token}`); await expect(provider.getFileInfo(SEEDED_IMAGE_UUID)).resolves.toMatchObject({ uuid: SEEDED_IMAGE_UUID }); }); }); From 4d04ea830b2050596cb85e92ac2835788f0ddc3a Mon Sep 17 00:00:00 2001 From: nd0ut Date: Wed, 7 Oct 2026 15:10:22 +0300 Subject: [PATCH 019/104] test(ai-image-editor): mint tokens and name seeded files with the emulator's own exports mintAuthToken was wrapped twice around generateAuthToken and the emulator's secret, with lifetimes that had drifted (60s vs 600s), and the seeded image uuid was hard-coded twice. The emulator now exports mintAuthToken (WebCrypto, so it runs in the page) and DEMO_FILES; specs and e2e import them, and the Node-side browser command in tests/commands.ts goes away. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 8 ++++---- .../entities/provider/api/emulator.testing.ts | 9 ++------- .../provider/api/uploadcareApiClient.test.ts | 8 ++++---- .../api/uploadcareDerivativeApi.test.ts | 8 ++++---- packages/ai-image-editor/tests/commands.ts | 19 ------------------- .../tests/editor/auth-token.e2e.test.tsx | 19 +++++++++++-------- .../ai-image-editor/tests/editor/harness.ts | 5 +++-- packages/ai-image-editor/tests/emulator.ts | 2 +- packages/ai-image-editor/vitest.config.ts | 2 -- 9 files changed, 29 insertions(+), 51 deletions(-) delete mode 100644 packages/ai-image-editor/tests/commands.ts diff --git a/AGENTS.md b/AGENTS.md index 1e4abba..4455ffa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,16 +93,16 @@ responses. `tests/emulator.ts` emulates Uploadcare's hosts and `cdn.example.com` (the tests' CDN cname), fails any other Uploadcare host, passes every non-Uploadcare origin through, and resets the session before every test; - import `session` from there to register a scenario or preset. Signing stays - Node-side in `tests/commands.ts` (`commands.mintAuthToken()`). The editor + import `session` from there to register a scenario or preset. The editor tests apply the `derivativesInstant` preset (`tests/editor/harness.ts`) so a generation finishes on its first poll instead of after the editor's 1.5s interval several times over. - Steer the emulator per test with `session.on()` and `session.use(preset)` (`resetSession()` answers the session in specs); there are no magic keys, prompts or uuids. -- Bearer tokens must be real: mint them with `mintAuthToken()` (specs) or - `commands.mintAuthToken()` (e2e). +- Bearer tokens must be real: mint them with the emulator's `mintAuthToken()` + (WebCrypto, so it works in the page too). The images every fresh session + holds are its `DEMO_FILES`. - A hand-written stub is still right for what the emulator can't do (a bare non-JSON failure, a poll that hangs or never finishes); say why next to it. diff --git a/packages/ai-image-editor/src/entities/provider/api/emulator.testing.ts b/packages/ai-image-editor/src/entities/provider/api/emulator.testing.ts index 01504bd..10586eb 100644 --- a/packages/ai-image-editor/src/entities/provider/api/emulator.testing.ts +++ b/packages/ai-image-editor/src/entities/provider/api/emulator.testing.ts @@ -1,8 +1,7 @@ // TEMPORARY: `@uploadcare/api-emulator` is a `file:` dependency on an unreleased // checkout (see packages/ai-image-editor/package.json); swap it for a version // range once the package ships, and drop this comment. -import { handle, SIGNED_UPLOADS_SECRET_KEY } from '@uploadcare/api-emulator'; -import { generateAuthToken } from '@uploadcare/signed-uploads/server'; +import { DEMO_FILES, handle } from '@uploadcare/api-emulator'; import { vi } from 'vitest'; /** @@ -34,9 +33,5 @@ export const emulatorFetch = () => { export const plainTextFailure = (status: number, statusText: string) => vi.fn().mockResolvedValue(new Response('upstream failure', { status, statusText })); -/** A Bearer token the emulator accepts; `id` keeps two tokens minted in the same second apart. */ -export const mintAuthToken = (id = 'token') => - generateAuthToken(SIGNED_UPLOADS_SECRET_KEY, { lifetime: 60_000, tokenId: id }); - /** A stored image every fresh emulator session starts with. */ -export const SEEDED_IMAGE_UUID = '49b4c5a1-31b3-4349-ba07-d97a2d883c37'; +export const SEEDED_IMAGE_UUID = DEMO_FILES[0]; diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts index 4ef7a5e..be5fc1a 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts @@ -1,8 +1,8 @@ // TEMPORARY: file: dependency, see emulator.testing.ts. -import { type EmulatorSession, resetSession } from '@uploadcare/api-emulator'; +import { type EmulatorSession, mintAuthToken, resetSession } from '@uploadcare/api-emulator'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AiProviderError } from '../model/types'; -import { emulatorFetch, mintAuthToken, plainTextFailure, SEEDED_IMAGE_UUID } from './emulator.testing'; +import { emulatorFetch, plainTextFailure, SEEDED_IMAGE_UUID } from './emulator.testing'; import { UploadcareApiClient } from './uploadcareApiClient'; const PUBLIC_KEY = 'demopublickey'; @@ -28,7 +28,7 @@ describe('UploadcareApiClient', () => { }); it('sends a plain token as a bearer header', async () => { - const token = mintAuthToken(); + const token = await mintAuthToken(); const fetchImpl = emulatorFetch(); const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: fetchImpl, authToken: token }); @@ -38,7 +38,7 @@ describe('UploadcareApiClient', () => { }); it('re-resolves a resolver per request, so a job can rotate tokens mid-flight', async () => { - const [first, second] = [mintAuthToken('first'), mintAuthToken('second')]; + const [first, second] = [await mintAuthToken({ tokenId: 'first' }), await mintAuthToken({ tokenId: 'second' })]; const fetchImpl = emulatorFetch(); const authToken = vi.fn().mockResolvedValueOnce(first).mockResolvedValueOnce(second); const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: fetchImpl, authToken }); diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts index 4243163..0d5710b 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts @@ -1,10 +1,10 @@ // TEMPORARY: file: dependency, see emulator.testing.ts. -import { type EmulatorSession, resetSession } from '@uploadcare/api-emulator'; +import { type EmulatorSession, mintAuthToken, resetSession } from '@uploadcare/api-emulator'; import { createEmulatorServer } from '@uploadcare/api-emulator/listen'; import { getPrefixedCdnBaseAsync } from '@uploadcare/cname-prefix/async'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { AiProviderError } from '../model/types'; -import { emulatorFetch, mintAuthToken, plainTextFailure, SEEDED_IMAGE_UUID } from './emulator.testing'; +import { emulatorFetch, plainTextFailure, SEEDED_IMAGE_UUID } from './emulator.testing'; import { UploadcareDerivativeApi } from './uploadcareDerivativeApi'; const PUBLIC_KEY = 'demopublickey'; @@ -311,7 +311,7 @@ describe('UploadcareDerivativeApi', () => { // the client's own fetch, while `getFileInfo` hands options to // upload-client's isReadyPoll. Dropping either assignment would leave // half the provider unauthenticated. - const token = mintAuthToken(); + const token = await mintAuthToken(); const fetchImpl = emulatorFetch(); const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, @@ -351,7 +351,7 @@ describe('UploadcareDerivativeApi', () => { ...NO_DELAY, }); - token = mintAuthToken('second'); + token = await mintAuthToken({ tokenId: 'second' }); await provider.generate({ prompt: 'x', mode: 'generate' }); expect(fetchImpl.requests[0].headers.get('Authorization')).toBe(`Bearer ${token}`); diff --git a/packages/ai-image-editor/tests/commands.ts b/packages/ai-image-editor/tests/commands.ts deleted file mode 100644 index 3b9ce08..0000000 --- a/packages/ai-image-editor/tests/commands.ts +++ /dev/null @@ -1,19 +0,0 @@ -// TEMPORARY: `@uploadcare/api-emulator` is a `file:` dependency on an unreleased -// checkout (see packages/ai-image-editor/package.json); swap it for a version -// range once the package ships, and drop this comment. -import { SIGNED_UPLOADS_SECRET_KEY } from '@uploadcare/api-emulator'; -import { generateAuthToken } from '@uploadcare/signed-uploads/server'; -import type { BrowserCommand } from 'vitest/node'; - -/** A Bearer token the emulator accepts; `id` keeps tokens minted in the same second apart. */ -const mintAuthToken: BrowserCommand<[id?: string]> = async (_context, id = 'token') => - generateAuthToken(SIGNED_UPLOADS_SECRET_KEY, { lifetime: 600_000, tokenId: id }); - -/** Node-side browser command: `@uploadcare/signed-uploads/server` signs with `node:crypto`, so tokens are minted outside the page. */ -export const emulatorCommands = { mintAuthToken }; - -declare module 'vitest/browser' { - interface BrowserCommands { - mintAuthToken: (id?: string) => Promise; - } -} diff --git a/packages/ai-image-editor/tests/editor/auth-token.e2e.test.tsx b/packages/ai-image-editor/tests/editor/auth-token.e2e.test.tsx index 2845c62..90d82e6 100644 --- a/packages/ai-image-editor/tests/editor/auth-token.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/auth-token.e2e.test.tsx @@ -1,5 +1,5 @@ +import { mintAuthToken } from '@uploadcare/api-emulator'; import { describe, expect, it, vi } from 'vitest'; -import { commands } from 'vitest/browser'; import type { UcAiImageEditorType } from './harness'; import { clickSend, mount, recordRequests, STAGING, typePrompt } from './harness'; @@ -42,7 +42,7 @@ describe(' authToken', () => { it('signs once authToken is set and stops once it is unset', async () => { const { auth, results } = recordRequests(); - const token = await commands.mintAuthToken('plain'); + const token = await mintAuthToken({ tokenId: 'plain' }); const el = mount(STAGING); el.authToken = token; await el.updateComplete; @@ -59,7 +59,7 @@ describe(' authToken', () => { it('sends a plain token on every request', async () => { const { auth, results } = recordRequests(); - const token = await commands.mintAuthToken('plain'); + const token = await mintAuthToken({ tokenId: 'plain' }); const el = mount(STAGING); el.authToken = token; await el.updateComplete; @@ -74,7 +74,7 @@ describe(' authToken', () => { // Standalone, the editor owns the cache: a generate makes several // authenticated requests and must not ask the app for a token each time. const { auth, results } = recordRequests(); - const token = await commands.mintAuthToken('fetched'); + const token = await mintAuthToken({ tokenId: 'fetched' }); const fetchToken = vi.fn(async () => token); const el = mount(STAGING); el.authToken = fetchToken; @@ -90,7 +90,10 @@ describe(' authToken', () => { it('keeps the cached token when the function identity changes', async () => { // A React parent hands over a new closure on every render. const { auth, results } = recordRequests(); - const [firstToken, secondToken] = [await commands.mintAuthToken('first'), await commands.mintAuthToken('second')]; + const [firstToken, secondToken] = [ + await mintAuthToken({ tokenId: 'first' }), + await mintAuthToken({ tokenId: 'second' }), + ]; const first = vi.fn(async () => firstToken); const el = mount(STAGING); el.authToken = first; @@ -110,7 +113,7 @@ describe(' authToken', () => { // The escape hatch for a sign-out, and the only recovery when a token's // `exp` cannot be read and so never goes stale on its own. const { results } = recordRequests(); - const token = await commands.mintAuthToken('fetched'); + const token = await mintAuthToken({ tokenId: 'fetched' }); const fetchToken = vi.fn(async () => token); const el = mount(STAGING); el.authToken = fetchToken; @@ -128,7 +131,7 @@ describe(' authToken', () => { // What the file-uploader plugin sets: the uploader already caches, so the // editor must resolve through to it rather than hold its own copy. const { results } = recordRequests(); - const token = await commands.mintAuthToken('fetched'); + const token = await mintAuthToken({ tokenId: 'fetched' }); const fetchToken = vi.fn(async () => token); const el = mount(STAGING); el.cacheAuthToken = false; @@ -144,7 +147,7 @@ describe(' authToken', () => { // What the File Uploader plugin hands over: the uploader's cache, which // the editor must use rather than wrap in a second one. const { auth, results } = recordRequests(); - const token = await commands.mintAuthToken('provider'); + const token = await mintAuthToken({ tokenId: 'provider' }); const getToken = vi.fn(async () => token); const invalidate = vi.fn(); const el = mount(STAGING); diff --git a/packages/ai-image-editor/tests/editor/harness.ts b/packages/ai-image-editor/tests/editor/harness.ts index cf9edf4..f94c57c 100644 --- a/packages/ai-image-editor/tests/editor/harness.ts +++ b/packages/ai-image-editor/tests/editor/harness.ts @@ -1,4 +1,5 @@ -// TEMPORARY: file: dependency, see tests/commands.ts. +// TEMPORARY: file: dependency, see src/entities/provider/api/emulator.testing.ts. +import { DEMO_FILES } from '@uploadcare/api-emulator'; import { afterEach, beforeEach } from 'vitest'; import { page } from 'vitest/browser'; import type { UcAiImageEditor as UcAiImageEditorType } from '../../src/index'; @@ -93,7 +94,7 @@ export function mount(attrs: Record = {}): UcAiImageEditorType { export const STAGING = { pubkey: 'demopublickey', 'cdn-cname': 'https://cdn.example.com' }; /** An image every fresh emulator session already holds. */ -export const SAMPLE_UUID = '49b4c5a1-31b3-4349-ba07-d97a2d883c37'; +export const SAMPLE_UUID = DEMO_FILES[0]; export function typePrompt(el: UcAiImageEditorType, value: string): void { const input = el.shadowRoot!.querySelector('uc-ai-prompt-row')!.shadowRoot!.querySelector('textarea')!; diff --git a/packages/ai-image-editor/tests/emulator.ts b/packages/ai-image-editor/tests/emulator.ts index 3b33d39..660ee39 100644 --- a/packages/ai-image-editor/tests/emulator.ts +++ b/packages/ai-image-editor/tests/emulator.ts @@ -1,4 +1,4 @@ -// TEMPORARY: file: dependency, see tests/commands.ts. +// TEMPORARY: file: dependency, see src/entities/provider/api/emulator.testing.ts. import type { EmulatorSession } from '@uploadcare/api-emulator'; import { setupEmulator } from '@uploadcare/api-emulator/browser'; import { beforeEach } from 'vitest'; diff --git a/packages/ai-image-editor/vitest.config.ts b/packages/ai-image-editor/vitest.config.ts index 3a4f368..ee503e4 100644 --- a/packages/ai-image-editor/vitest.config.ts +++ b/packages/ai-image-editor/vitest.config.ts @@ -1,6 +1,5 @@ import { playwright } from '@vitest/browser-playwright'; import { defineConfig } from 'vitest/config'; -import { emulatorCommands } from './tests/commands'; export default defineConfig({ test: { @@ -24,7 +23,6 @@ export default defineConfig({ instances: [{ browser: 'chromium' }], enabled: true, headless: true, - commands: emulatorCommands, }, }, }, From 4d63f19c1175a4f8e43bc4e496cd197494162594 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Wed, 7 Oct 2026 15:12:01 +0300 Subject: [PATCH 020/104] docs: keep the file: dependency note in AGENTS.md only The TEMPORARY comment was copied into five source files on top of AGENTS.md; the swap to a released version touches package.json, not them. AGENTS.md now also says msw and @mswjs/interceptors are root devDependencies, which is where they are. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 8 +++++--- .../src/entities/provider/api/emulator.testing.ts | 3 --- .../src/entities/provider/api/uploadcareApiClient.test.ts | 1 - .../entities/provider/api/uploadcareDerivativeApi.test.ts | 1 - packages/ai-image-editor/tests/editor/harness.ts | 1 - packages/ai-image-editor/tests/emulator.ts | 1 - 6 files changed, 5 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4455ffa..44c098e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,7 +89,8 @@ responses. (`@uploadcare/api-emulator/listen`) and pass its origin as `baseUrl`. - **Browser e2e** run the emulator in the page with `setupEmulator()` from `@uploadcare/api-emulator/browser` (MSW and - `@mswjs/interceptors` underneath, both devDependencies here): + `@mswjs/interceptors` underneath, both devDependencies of the root + `package.json`): `tests/emulator.ts` emulates Uploadcare's hosts and `cdn.example.com` (the tests' CDN cname), fails any other Uploadcare host, passes every non-Uploadcare origin through, and resets the session before every test; @@ -106,8 +107,9 @@ responses. - A hand-written stub is still right for what the emulator can't do (a bare non-JSON failure, a poll that hangs or never finishes); say why next to it. -The dependency is a TEMPORARY `file:` link to an unreleased checkout until the -package ships; swap it for a version range then and drop the TEMPORARY comments. +The dependency is a TEMPORARY `file:` link to an unreleased checkout, so +`npm ci` only resolves it on that machine; swap it for a version once the +package ships. ## Docs layout diff --git a/packages/ai-image-editor/src/entities/provider/api/emulator.testing.ts b/packages/ai-image-editor/src/entities/provider/api/emulator.testing.ts index 10586eb..61affb9 100644 --- a/packages/ai-image-editor/src/entities/provider/api/emulator.testing.ts +++ b/packages/ai-image-editor/src/entities/provider/api/emulator.testing.ts @@ -1,6 +1,3 @@ -// TEMPORARY: `@uploadcare/api-emulator` is a `file:` dependency on an unreleased -// checkout (see packages/ai-image-editor/package.json); swap it for a version -// range once the package ships, and drop this comment. import { DEMO_FILES, handle } from '@uploadcare/api-emulator'; import { vi } from 'vitest'; diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts index be5fc1a..ab3fd76 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts @@ -1,4 +1,3 @@ -// TEMPORARY: file: dependency, see emulator.testing.ts. import { type EmulatorSession, mintAuthToken, resetSession } from '@uploadcare/api-emulator'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AiProviderError } from '../model/types'; diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts index 0d5710b..9b3e813 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts @@ -1,4 +1,3 @@ -// TEMPORARY: file: dependency, see emulator.testing.ts. import { type EmulatorSession, mintAuthToken, resetSession } from '@uploadcare/api-emulator'; import { createEmulatorServer } from '@uploadcare/api-emulator/listen'; import { getPrefixedCdnBaseAsync } from '@uploadcare/cname-prefix/async'; diff --git a/packages/ai-image-editor/tests/editor/harness.ts b/packages/ai-image-editor/tests/editor/harness.ts index f94c57c..337f054 100644 --- a/packages/ai-image-editor/tests/editor/harness.ts +++ b/packages/ai-image-editor/tests/editor/harness.ts @@ -1,4 +1,3 @@ -// TEMPORARY: file: dependency, see src/entities/provider/api/emulator.testing.ts. import { DEMO_FILES } from '@uploadcare/api-emulator'; import { afterEach, beforeEach } from 'vitest'; import { page } from 'vitest/browser'; diff --git a/packages/ai-image-editor/tests/emulator.ts b/packages/ai-image-editor/tests/emulator.ts index 660ee39..3e2d6ce 100644 --- a/packages/ai-image-editor/tests/emulator.ts +++ b/packages/ai-image-editor/tests/emulator.ts @@ -1,4 +1,3 @@ -// TEMPORARY: file: dependency, see src/entities/provider/api/emulator.testing.ts. import type { EmulatorSession } from '@uploadcare/api-emulator'; import { setupEmulator } from '@uploadcare/api-emulator/browser'; import { beforeEach } from 'vitest'; From d6dbaa2a71e334075f2bc9123a30fb31a270340d Mon Sep 17 00:00:00 2001 From: nd0ut Date: Wed, 7 Oct 2026 16:50:35 +0300 Subject: [PATCH 021/104] test(ai-image-editor): run the abort-on-source-change e2e against the emulator The test stubbed all of fetch because it used made-up source uuids the emulator refuses. It now edits DEMO_FILES[0], switches to DEMO_FILES[1], lets the emulator answer the job POST, and only hangs the status poll with a session.on() handler that never settles; the page's fetch still rejects when the editor aborts it. installFetch has no other user outside harness.ts, so it is no longer exported. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/editor/generation.e2e.test.tsx | 25 +++++-------------- .../ai-image-editor/tests/editor/harness.ts | 2 +- 2 files changed, 7 insertions(+), 20 deletions(-) diff --git a/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx b/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx index f0a4dc9..6adfdf1 100644 --- a/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx @@ -1,3 +1,4 @@ +import { DEMO_FILES } from '@uploadcare/api-emulator'; import { describe, expect, it, vi } from 'vitest'; import { enLocale } from '../../src/shared/i18n/en'; import { session } from '../emulator'; @@ -6,7 +7,6 @@ import { clickPrimary, clickSend, editorMode, - installFetch, mount, primaryBtn, recordRequests, @@ -194,22 +194,9 @@ describe(' generation', () => { }); it('aborts in-flight generation and shows the new source when source changes', async () => { - // The job must still be running when the source changes, so the status - // poll hangs until it is aborted — a race the emulator, which answers at - // once, can't hold open. The job POST is answered here too: the emulator - // would refuse these made-up sources before the poll ever started. - installFetch(async (_input, init) => - init?.method === 'POST' - ? new Response(JSON.stringify({ type: 'job', job_id: 'job-1' }), { - headers: { 'Content-Type': 'application/json' }, - }) - : new Promise((_resolve, reject) => { - init?.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { - once: true, - }); - }), - ); - const el = mount({ ...STAGING, 'source-uuid': '00000000-0000-4000-8000-0000000000f1' }); + // The status poll never answers, so the job is still running when the source changes. + session.on('GET /derivative/status/', () => new Promise(() => {})); + const el = mount({ ...STAGING, 'source-uuid': DEMO_FILES[0] }); await el.updateComplete; typePrompt(el, 'try'); @@ -217,12 +204,12 @@ describe(' generation', () => { clickSend(el); // Change source mid-flight — this aborts the in-flight generation. - el.sourceUuid = '00000000-0000-4000-8000-0000000000f2'; + el.sourceUuid = DEMO_FILES[1]; await el.updateComplete; // After the abort, the displayed image should be the new source (no result override). await vi.waitFor(() => { - expect(canvasUrl(el)).toContain('https://cdn.example.com/00000000-0000-4000-8000-0000000000f2/'); + expect(canvasUrl(el)).toContain(`https://cdn.example.com/${DEMO_FILES[1]}/`); }); }); }); diff --git a/packages/ai-image-editor/tests/editor/harness.ts b/packages/ai-image-editor/tests/editor/harness.ts index 337f054..0e8eb04 100644 --- a/packages/ai-image-editor/tests/editor/harness.ts +++ b/packages/ai-image-editor/tests/editor/harness.ts @@ -39,7 +39,7 @@ const realFetch = globalThis.fetch.bind(globalThis); * Swap `globalThis.fetch` for the rest of the test. The provider binds * `globalThis.fetch` at construction, so install it BEFORE setting `pubkey`. */ -export function installFetch(handler: typeof fetch): void { +function installFetch(handler: typeof fetch): void { restoreFetch?.(); globalThis.fetch = handler; restoreFetch = () => { From 94676bee9813d677157d2d55165197dabe918a6e Mon Sep 17 00:00:00 2001 From: nd0ut Date: Wed, 7 Oct 2026 16:51:41 +0300 Subject: [PATCH 022/104] test(ai-image-editor): time out polls with a session scenario instead of a job-slow stub The comments and AGENTS.md said the emulator can't keep a job running or answer a bare non-JSON failure, but session.on() handlers run ahead of its routes and can answer either. The two poll-timeout specs now go through emulatorFetch with the status poll stuck in `processing`, and the generation_timeout spec checks for the job id the emulator handed out instead of a hand-written 'job-slow'. plainTextFailure stays as a stub; its comment and AGENTS.md now say a stub is fine only where it is simpler. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 6 ++-- .../entities/provider/api/emulator.testing.ts | 6 ++-- .../api/uploadcareDerivativeApi.test.ts | 29 ++++++++++--------- 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 44c098e..79891be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,8 +104,10 @@ responses. - Bearer tokens must be real: mint them with the emulator's `mintAuthToken()` (WebCrypto, so it works in the page too). The images every fresh session holds are its `DEMO_FILES`. -- A hand-written stub is still right for what the emulator can't do (a bare - non-JSON failure, a poll that hangs or never finishes); say why next to it. +- A `session.on()` handler runs before the emulator's routes and can answer + any `Response` or a promise that never settles, so a bare non-JSON failure, + a job stuck in `processing` or a poll that hangs is a scenario, not a stub. + Hand-write a `fetch` only where it is simpler, and say why next to it. The dependency is a TEMPORARY `file:` link to an unreleased checkout, so `npm ci` only resolves it on that machine; swap it for a version once the diff --git a/packages/ai-image-editor/src/entities/provider/api/emulator.testing.ts b/packages/ai-image-editor/src/entities/provider/api/emulator.testing.ts index 61affb9..c534ed9 100644 --- a/packages/ai-image-editor/src/entities/provider/api/emulator.testing.ts +++ b/packages/ai-image-editor/src/entities/provider/api/emulator.testing.ts @@ -23,9 +23,9 @@ export const emulatorFetch = () => { }; /** - * The emulator answers every refusal with the JSON error envelope, so it can't - * produce the bare non-2xx, non-JSON response a proxy or an outage would. This - * stub stands in for that. + * The bare non-2xx, non-JSON response a proxy or an outage would give. A + * `session.on()` scenario could answer it too; a stub is shorter when the + * spec needs nothing else from the emulator. */ export const plainTextFailure = (status: number, statusText: string) => vi.fn().mockResolvedValue(new Response('upstream failure', { status, statusText })); diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts index 9b3e813..8dcf5ab 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts @@ -10,17 +10,9 @@ const PUBLIC_KEY = 'demopublickey'; const CDN = 'https://cdn.example.com'; const NO_DELAY = { pollIntervalMs: 0 } as const; -const jsonResponse = (body: unknown) => - new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' } }); - -/** - * A job that never finishes. The emulator's jobs always reach a terminal frame - * within four polls, so it can't keep `poll` waiting long enough to time out. - */ -const neverFinishingFetch = () => - vi.fn(async (_url, init) => - jsonResponse(init?.method === 'POST' ? { type: 'job', job_id: 'job-slow' } : { type: 'job', status: 'processing' }), - ); +/** A job that never finishes: every status poll answers `processing`. */ +const neverFinish = () => + session.on('GET /derivative/status/', () => Response.json({ type: 'job', status: 'processing' })); /** * `getFileInfo` goes through upload-client's own transport (node:http here), @@ -117,22 +109,31 @@ describe('UploadcareDerivativeApi', () => { it('wraps an internal poll timeout as a generation_timeout provider error', async () => { // A zero timeout: `poll` gives up on its own, without the caller's signal ever aborting. + neverFinish(); + let jobId: string | undefined; + session.on('POST /derivative/image/generate/', async ({ next }) => { + const answer = await next(); + jobId = (await answer?.clone().json())?.job_id; + return answer; + }); const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, - fetch: neverFinishingFetch(), + fetch: emulatorFetch(), pollIntervalMs: 0, pollTimeoutMs: 0, }); const err = await provider.generate({ prompt: 'x', mode: 'generate' }).catch((e) => e); expect(err).toBeInstanceOf(AiProviderError); expect(err.errorCode).toBe('generation_timeout'); - expect(err.message).toContain('job-slow'); + expect(jobId).toBeTruthy(); + expect(err.message).toContain(jobId); }); it('times out when the job never reaches a terminal state', async () => { + neverFinish(); const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, - fetch: neverFinishingFetch(), + fetch: emulatorFetch(), pollIntervalMs: 0, pollTimeoutMs: 5, }); From 21118f582463fa798eea7a881207674ce8886a78 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Wed, 7 Oct 2026 16:52:03 +0300 Subject: [PATCH 023/104] test(ai-image-editor): edit a seeded image in the outputFilename resolver e2e The test edited a made-up uuid, which the emulator refuses with source_not_found, and passed on the refused run because recordRequests logs the POST whatever the answer. It now edits DEMO_FILES[1], which no test has edited, and waits for the result so the run has to succeed. Co-Authored-By: Claude Opus 5.5 (1M context) --- ...Filename-resolver--original---counter--1.png | Bin 0 -> 34461 bytes .../tests/editor/filename.e2e.test.tsx | 8 +++++--- 2 files changed, 5 insertions(+), 3 deletions(-) create mode 100644 packages/ai-image-editor/tests/editor/__screenshots__/filename.e2e.test.tsx/-uc-ai-image-editor--result-filename-names-the-result-via-the-outputFilename-resolver--original---counter--1.png diff --git a/packages/ai-image-editor/tests/editor/__screenshots__/filename.e2e.test.tsx/-uc-ai-image-editor--result-filename-names-the-result-via-the-outputFilename-resolver--original---counter--1.png b/packages/ai-image-editor/tests/editor/__screenshots__/filename.e2e.test.tsx/-uc-ai-image-editor--result-filename-names-the-result-via-the-outputFilename-resolver--original---counter--1.png new file mode 100644 index 0000000000000000000000000000000000000000..d8c96cce0ef76cf5cd7253476036caba7672fd6a GIT binary patch literal 34461 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 literal 0 HcmV?d00001 diff --git a/packages/ai-image-editor/tests/editor/filename.e2e.test.tsx b/packages/ai-image-editor/tests/editor/filename.e2e.test.tsx index b3b805e..3f49354 100644 --- a/packages/ai-image-editor/tests/editor/filename.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/filename.e2e.test.tsx @@ -1,4 +1,5 @@ import type { UploadcareFile } from '@uploadcare/upload-client'; +import { DEMO_FILES } from '@uploadcare/api-emulator'; import { describe, expect, it, vi } from 'vitest'; import { clickSend, editorMode, mount, recordRequests, SAMPLE_UUID, STAGING, typePrompt } from './harness'; @@ -28,9 +29,9 @@ describe(' result filename', () => { it('names the result via the outputFilename resolver (original + counter)', async () => { const stub = recordRequests(); const el = mount(STAGING); - // A fresh uuid with no persisted lineage → counter starts at 1. + // A seeded image no test has edited, so no persisted lineage → counter starts at 1. el.sourceFileInfo = { - uuid: '00000000-0000-4000-8000-000000000001', + uuid: DEMO_FILES[1], originalFilename: 'cat.png', imageInfo: null, } as unknown as UploadcareFile; @@ -41,7 +42,8 @@ describe(' result filename', () => { typePrompt(el, 'add a hat'); await el.updateComplete; clickSend(el); - await vi.waitFor(() => expect(stub.generateBodies.length).toBe(1)); + // Waits for the result too, so the run behind the request succeeded. + await vi.waitFor(() => expect(stub.results.length).toBe(1)); expect(stub.generateBodies[0]!.filename).toBe('cat.png-edit-1'); }); From 171c4d5c563ec0cae8c343d1a7c944a1a8eb5989 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Wed, 7 Oct 2026 23:44:48 +0300 Subject: [PATCH 024/104] chore: ignore vitest failure screenshots in every test directory Vitest browser mode writes a failure screenshot next to the failing test file, so tests/editor/ got its own __screenshots__/ that the tests/__screenshots__/ pattern did not cover, and one was committed by accident. Ignore the directory name anywhere instead, and drop the file. Co-Authored-By: Claude Opus 5.5 --- packages/ai-image-editor/.gitignore | 2 +- ...Filename-resolver--original---counter--1.png | Bin 34461 -> 0 bytes packages/react-ai-image-editor/.gitignore | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) delete mode 100644 packages/ai-image-editor/tests/editor/__screenshots__/filename.e2e.test.tsx/-uc-ai-image-editor--result-filename-names-the-result-via-the-outputFilename-resolver--original---counter--1.png diff --git a/packages/ai-image-editor/.gitignore b/packages/ai-image-editor/.gitignore index d4ef08f..ceeafeb 100644 --- a/packages/ai-image-editor/.gitignore +++ b/packages/ai-image-editor/.gitignore @@ -1,6 +1,6 @@ # Vitest browser failure screenshots (debug artifacts) -tests/__screenshots__/ +__screenshots__/ # Generated API reference (regenerated by `docs:api`) + VitePress build output docs/api/custom-elements.json diff --git a/packages/ai-image-editor/tests/editor/__screenshots__/filename.e2e.test.tsx/-uc-ai-image-editor--result-filename-names-the-result-via-the-outputFilename-resolver--original---counter--1.png b/packages/ai-image-editor/tests/editor/__screenshots__/filename.e2e.test.tsx/-uc-ai-image-editor--result-filename-names-the-result-via-the-outputFilename-resolver--original---counter--1.png deleted file mode 100644 index d8c96cce0ef76cf5cd7253476036caba7672fd6a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 34461 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 diff --git a/packages/react-ai-image-editor/.gitignore b/packages/react-ai-image-editor/.gitignore index 4200423..ac35091 100644 --- a/packages/react-ai-image-editor/.gitignore +++ b/packages/react-ai-image-editor/.gitignore @@ -1,2 +1,2 @@ -tests/__screenshots__/ +__screenshots__/ .vitest-attachments/ From b8c54bf001a70d0a10ef638ba80330a057dcd2c0 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Thu, 8 Oct 2026 00:00:40 +0300 Subject: [PATCH 025/104] test(ai-image-editor): drive specs with the emulator's createFetch and session requests Specs now pass `createFetch()` as the client's fetch and read sent requests from the session handle `resetSession()` returns. Plain-text outages are `session.on()` scenarios, so they take the real emulator request path. `vi.fn(createFetch())` stays only where a spec checks the AbortSignal passed in or counts calls the emulator never sees. emulator.testing.ts is gone. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 6 +- .../entities/provider/api/emulator.testing.ts | 34 ----- .../provider/api/uploadcareApiClient.test.ts | 127 +++++++++--------- .../api/uploadcareDerivativeApi.test.ts | 122 ++++++++--------- 4 files changed, 123 insertions(+), 166 deletions(-) delete mode 100644 packages/ai-image-editor/src/entities/provider/api/emulator.testing.ts diff --git a/AGENTS.md b/AGENTS.md index 79891be..ce547e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,9 +82,9 @@ second time, pushing the original one level deeper on `cause`. stands in for the Upload API and CDN; tests should not hand-write Uploadcare responses. -- **Specs** (happy-dom) inject `emulatorFetch()` from - `src/entities/provider/api/emulator.testing.ts` as the client's `fetch`, and - call `resetSession()` before each test. `getFileInfo` goes through +- **Specs** (happy-dom) inject the emulator's `createFetch()` as the client's + `fetch`, take the session from `resetSession()` before each test, and read + what was sent from its `requests`. `getFileInfo` goes through upload-client's own transport, so those specs start a real emulator server (`@uploadcare/api-emulator/listen`) and pass its origin as `baseUrl`. - **Browser e2e** run the emulator in the page with diff --git a/packages/ai-image-editor/src/entities/provider/api/emulator.testing.ts b/packages/ai-image-editor/src/entities/provider/api/emulator.testing.ts deleted file mode 100644 index c534ed9..0000000 --- a/packages/ai-image-editor/src/entities/provider/api/emulator.testing.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { DEMO_FILES, handle } from '@uploadcare/api-emulator'; -import { vi } from 'vitest'; - -/** - * A `fetch` answered by the Uploadcare API emulator instead of the network, - * spied so a spec can read back what was sent: `requests` holds a copy of every - * request it answered, in order. Reset the emulator's state with - * `resetSession()` between tests. - */ -export const emulatorFetch = () => { - const requests: Request[] = []; - const spy = vi.fn(async (input, init) => { - // What a real fetch does with a signal that is already aborted. - init?.signal?.throwIfAborted(); - const request = new Request(input, init); - // A copy, since the emulator reads the body of the one it answers. - requests.push(request.clone()); - const response = await handle(request); - if (!response) throw new TypeError(`The emulator does not implement ${request.method} ${request.url}`); - return response; - }); - return Object.assign(spy, { requests }); -}; - -/** - * The bare non-2xx, non-JSON response a proxy or an outage would give. A - * `session.on()` scenario could answer it too; a stub is shorter when the - * spec needs nothing else from the emulator. - */ -export const plainTextFailure = (status: number, statusText: string) => - vi.fn().mockResolvedValue(new Response('upstream failure', { status, statusText })); - -/** A stored image every fresh emulator session starts with. */ -export const SEEDED_IMAGE_UUID = DEMO_FILES[0]; diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts index ab3fd76..966df10 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts @@ -1,7 +1,6 @@ -import { type EmulatorSession, mintAuthToken, resetSession } from '@uploadcare/api-emulator'; +import { createFetch, DEMO_FILES, type EmulatorSession, mintAuthToken, resetSession } from '@uploadcare/api-emulator'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AiProviderError } from '../model/types'; -import { emulatorFetch, plainTextFailure, SEEDED_IMAGE_UUID } from './emulator.testing'; import { UploadcareApiClient } from './uploadcareApiClient'; const PUBLIC_KEY = 'demopublickey'; @@ -18,49 +17,45 @@ describe('UploadcareApiClient', () => { describe('authToken', () => { it('sends no Authorization header when unset', async () => { - const fetchImpl = emulatorFetch(); - const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: fetchImpl }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: createFetch() }); await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); - expect(fetchImpl.requests[0].headers.get('Authorization')).toBeNull(); + expect(session.requests[0].headers.get('Authorization')).toBeNull(); }); it('sends a plain token as a bearer header', async () => { const token = await mintAuthToken(); - const fetchImpl = emulatorFetch(); - const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: fetchImpl, authToken: token }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: createFetch(), authToken: token }); await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); - expect(fetchImpl.requests[0].headers.get('Authorization')).toBe(`Bearer ${token}`); + expect(session.requests[0].headers.get('Authorization')).toBe(`Bearer ${token}`); }); it('re-resolves a resolver per request, so a job can rotate tokens mid-flight', async () => { const [first, second] = [await mintAuthToken({ tokenId: 'first' }), await mintAuthToken({ tokenId: 'second' })]; - const fetchImpl = emulatorFetch(); const authToken = vi.fn().mockResolvedValueOnce(first).mockResolvedValueOnce(second); - const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: fetchImpl, authToken }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: createFetch(), authToken }); const { job_id } = await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); await client.getJobStatus(job_id!); - expect(fetchImpl.requests[0].headers.get('Authorization')).toBe(`Bearer ${first}`); - expect(fetchImpl.requests[1].headers.get('Authorization')).toBe(`Bearer ${second}`); + expect(session.requests[0].headers.get('Authorization')).toBe(`Bearer ${first}`); + expect(session.requests[1].headers.get('Authorization')).toBe(`Bearer ${second}`); }); }); describe('generate', () => { it('POSTs pub_key + prompt + aspect_ratio + filename and returns the job', async () => { - const fetchImpl = emulatorFetch(); - const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: fetchImpl }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: createFetch() }); const job = await client.generate({ prompt: 'a hat', aspectRatio: [16, 9], filename: 'generated.png' }); expect(job).toEqual({ type: 'job', job_id: expect.any(String) }); - expect(fetchImpl.requests[0].url).toBe('https://upload.uploadcare.com/derivative/image/generate/'); - expect(fetchImpl.requests[0].method).toBe('POST'); - expect(await fetchImpl.requests[0].json()).toMatchObject({ + expect(session.requests[0].url).toBe('https://upload.uploadcare.com/derivative/image/generate/'); + expect(session.requests[0].method).toBe('POST'); + expect(await session.requests[0].json()).toMatchObject({ pub_key: PUBLIC_KEY, prompt: 'a hat', aspect_ratio: [16, 9], @@ -69,19 +64,17 @@ describe('UploadcareApiClient', () => { }); it('includes store only when provided', async () => { - const fetchImpl = emulatorFetch(); - const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: fetchImpl }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: createFetch() }); await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png', store: true }); - expect((await fetchImpl.requests[0].json()).store).toBeUndefined(); - expect((await fetchImpl.requests[1].json()).store).toBe(true); + expect((await session.requests[0].json()).store).toBeUndefined(); + expect((await session.requests[1].json()).store).toBe(true); }); it('includes metadata only when provided', async () => { - const fetchImpl = emulatorFetch(); - const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: fetchImpl }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: createFetch() }); await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); await client.generate({ @@ -91,28 +84,31 @@ describe('UploadcareApiClient', () => { metadata: { source: 'ai-image-editor' }, }); - expect((await fetchImpl.requests[0].json()).metadata).toBeUndefined(); - expect((await fetchImpl.requests[1].json()).metadata).toEqual({ source: 'ai-image-editor' }); + expect((await session.requests[0].json()).metadata).toBeUndefined(); + expect((await session.requests[1].json()).metadata).toEqual({ source: 'ai-image-editor' }); }); it('honours baseUrl override', async () => { - const fetchImpl = emulatorFetch(); const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, baseUrl: 'https://upload.example.com/', - fetch: fetchImpl, + fetch: createFetch(), }); await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); - expect(fetchImpl.requests[0].url).toBe('https://upload.example.com/derivative/image/generate/'); + expect(session.requests[0].url).toBe('https://upload.example.com/derivative/image/generate/'); }); it('throws with status text on a non-2xx response that is not the error envelope', async () => { - const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: plainTextFailure(400, 'Bad Request') }); + session.on( + 'POST /derivative/image/generate/', + () => new Response('upstream failure', { status: 400, statusText: 'Bad Request' }), + ); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: createFetch() }); await expect(client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' })).rejects.toThrow(/400/); }); it('forwards the abort signal', async () => { - const fetchImpl = emulatorFetch(); + const fetchImpl = vi.fn(createFetch()); const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: fetchImpl }); const controller = new AbortController(); await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png', signal: controller.signal }); @@ -120,15 +116,14 @@ describe('UploadcareApiClient', () => { }); it('sends Accept: application/json', async () => { - const fetchImpl = emulatorFetch(); - const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: fetchImpl }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: createFetch() }); await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); - expect(fetchImpl.requests[0].headers.get('Accept')).toBe('application/json'); + expect(session.requests[0].headers.get('Accept')).toBe('application/json'); }); it('surfaces a platform error envelope as an AiProviderError with its code', async () => { session.use('derivativesDisabled'); - const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: emulatorFetch() }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: createFetch() }); await expect(client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' })).rejects.toMatchObject({ name: 'AiProviderError', errorCode: 'derivative_disabled', @@ -139,51 +134,48 @@ describe('UploadcareApiClient', () => { describe('edit', () => { it('POSTs pub_key + prompt + source uuid + filename to the edit endpoint', async () => { - const fetchImpl = emulatorFetch(); - const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: fetchImpl }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: createFetch() }); - const job = await client.edit({ prompt: 'remove the cat', source: SEEDED_IMAGE_UUID, filename: 'edited.png' }); + const job = await client.edit({ prompt: 'remove the cat', source: DEMO_FILES[0], filename: 'edited.png' }); expect(job).toEqual({ type: 'job', job_id: expect.any(String) }); - expect(fetchImpl.requests[0].url).toBe('https://upload.uploadcare.com/derivative/image/edit/'); - expect(fetchImpl.requests[0].method).toBe('POST'); - expect(await fetchImpl.requests[0].json()).toMatchObject({ + expect(session.requests[0].url).toBe('https://upload.uploadcare.com/derivative/image/edit/'); + expect(session.requests[0].method).toBe('POST'); + expect(await session.requests[0].json()).toMatchObject({ pub_key: PUBLIC_KEY, prompt: 'remove the cat', - source: SEEDED_IMAGE_UUID, + source: DEMO_FILES[0], filename: 'edited.png', }); }); it('includes aspect_ratio only when provided', async () => { - const fetchImpl = emulatorFetch(); - const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: fetchImpl }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: createFetch() }); - await client.edit({ prompt: 'x', source: SEEDED_IMAGE_UUID, filename: 'f.png' }); - await client.edit({ prompt: 'x', source: SEEDED_IMAGE_UUID, filename: 'f.png', aspectRatio: [16, 9] }); + await client.edit({ prompt: 'x', source: DEMO_FILES[0], filename: 'f.png' }); + await client.edit({ prompt: 'x', source: DEMO_FILES[0], filename: 'f.png', aspectRatio: [16, 9] }); - expect((await fetchImpl.requests[0].json()).aspect_ratio).toBeUndefined(); - expect((await fetchImpl.requests[1].json()).aspect_ratio).toEqual([16, 9]); + expect((await session.requests[0].json()).aspect_ratio).toBeUndefined(); + expect((await session.requests[1].json()).aspect_ratio).toEqual([16, 9]); }); it('includes metadata only when provided', async () => { - const fetchImpl = emulatorFetch(); - const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: fetchImpl }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: createFetch() }); - await client.edit({ prompt: 'x', source: SEEDED_IMAGE_UUID, filename: 'f.png' }); + await client.edit({ prompt: 'x', source: DEMO_FILES[0], filename: 'f.png' }); await client.edit({ prompt: 'x', - source: SEEDED_IMAGE_UUID, + source: DEMO_FILES[0], filename: 'f.png', metadata: { source: 'ai-image-editor' }, }); - expect((await fetchImpl.requests[0].json()).metadata).toBeUndefined(); - expect((await fetchImpl.requests[1].json()).metadata).toEqual({ source: 'ai-image-editor' }); + expect((await session.requests[0].json()).metadata).toBeUndefined(); + expect((await session.requests[1].json()).metadata).toEqual({ source: 'ai-image-editor' }); }); it('surfaces a source the project does not have as an AiProviderError', async () => { - const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: emulatorFetch() }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: createFetch() }); await expect(client.edit({ prompt: 'x', source: 'missing', filename: 'f.png' })).rejects.toMatchObject({ name: 'AiProviderError', errorCode: 'source_not_found', @@ -191,15 +183,19 @@ describe('UploadcareApiClient', () => { }); it('throws with status text on a non-2xx response that is not the error envelope', async () => { - const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: plainTextFailure(400, 'Bad Request') }); + session.on( + 'POST /derivative/image/edit/', + () => new Response('upstream failure', { status: 400, statusText: 'Bad Request' }), + ); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: createFetch() }); await expect(client.edit({ prompt: 'x', source: 'u', filename: 'f.png' })).rejects.toThrow(/400/); }); it('forwards the abort signal', async () => { - const fetchImpl = emulatorFetch(); + const fetchImpl = vi.fn(createFetch()); const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: fetchImpl }); const controller = new AbortController(); - await client.edit({ prompt: 'x', source: SEEDED_IMAGE_UUID, filename: 'f.png', signal: controller.signal }); + await client.edit({ prompt: 'x', source: DEMO_FILES[0], filename: 'f.png', signal: controller.signal }); expect(fetchImpl.mock.calls[0]![1]?.signal).toBe(controller.signal); }); }); @@ -209,33 +205,36 @@ describe('UploadcareApiClient', () => { (await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' })).job_id!; it('GETs the status endpoint with pub_key + job_id and returns the parsed status', async () => { - const fetchImpl = emulatorFetch(); - const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: fetchImpl }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: createFetch() }); const jobId = await startJob(client); const status = await client.getJobStatus(jobId); expect(status).toEqual({ type: 'job', status: 'processing' }); - expect(fetchImpl.requests[1].url).toBe( + expect(session.requests[1].url).toBe( `https://upload.uploadcare.com/derivative/status/?pub_key=${PUBLIC_KEY}&job_id=${jobId}`, ); - expect(fetchImpl.requests[1].method).toBe('GET'); + expect(session.requests[1].method).toBe('GET'); }); it('throws with status text on a non-2xx response that is not the error envelope', async () => { - const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: plainTextFailure(404, 'Not Found') }); + session.on( + 'GET /derivative/status/', + () => new Response('upstream failure', { status: 404, statusText: 'Not Found' }), + ); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: createFetch() }); await expect(client.getJobStatus('job-1')).rejects.toThrow(/404/); }); it('surfaces a platform error envelope (e.g. job_not_found) as an AiProviderError', async () => { - const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: emulatorFetch() }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: createFetch() }); const err = await client.getJobStatus('job-1').catch((e) => e); expect(err).toBeInstanceOf(AiProviderError); expect(err.errorCode).toBe('job_not_found'); }); it('forwards the abort signal', async () => { - const fetchImpl = emulatorFetch(); + const fetchImpl = vi.fn(createFetch()); const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: fetchImpl }); const jobId = await startJob(client); const controller = new AbortController(); diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts index 8dcf5ab..0cb6890 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts @@ -1,9 +1,8 @@ -import { type EmulatorSession, mintAuthToken, resetSession } from '@uploadcare/api-emulator'; +import { createFetch, DEMO_FILES, type EmulatorSession, mintAuthToken, resetSession } from '@uploadcare/api-emulator'; import { createEmulatorServer } from '@uploadcare/api-emulator/listen'; import { getPrefixedCdnBaseAsync } from '@uploadcare/cname-prefix/async'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { AiProviderError } from '../model/types'; -import { emulatorFetch, plainTextFailure, SEEDED_IMAGE_UUID } from './emulator.testing'; import { UploadcareDerivativeApi } from './uploadcareDerivativeApi'; const PUBLIC_KEY = 'demopublickey'; @@ -36,14 +35,13 @@ describe('UploadcareDerivativeApi', () => { }); it('POSTs the prompt + aspect ratio + pub_key to the derivative endpoint', async () => { - const fetchImpl = emulatorFetch(); - const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: fetchImpl, ...NO_DELAY }); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: createFetch(), ...NO_DELAY }); await provider.generate({ prompt: 'a hat', mode: 'generate', aspectRatio: [16, 9] }); - expect(fetchImpl.requests[0].url).toBe('https://upload.uploadcare.com/derivative/image/generate/'); - expect(fetchImpl.requests[0].method).toBe('POST'); - expect(fetchImpl.requests[0].headers.get('Content-Type')).toBe('application/json'); - expect(await fetchImpl.requests[0].json()).toMatchObject({ + expect(session.requests[0].url).toBe('https://upload.uploadcare.com/derivative/image/generate/'); + expect(session.requests[0].method).toBe('POST'); + expect(session.requests[0].headers.get('Content-Type')).toBe('application/json'); + expect(await session.requests[0].json()).toMatchObject({ pub_key: PUBLIC_KEY, prompt: 'a hat', aspect_ratio: [16, 9], @@ -52,38 +50,34 @@ describe('UploadcareDerivativeApi', () => { }); it('forwards request metadata to the generate endpoint', async () => { - const fetchImpl = emulatorFetch(); - const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: fetchImpl, ...NO_DELAY }); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: createFetch(), ...NO_DELAY }); await provider.generate({ prompt: 'x', mode: 'generate', metadata: { source: 'ai-image-editor' } }); - expect((await fetchImpl.requests[0].json()).metadata).toEqual({ source: 'ai-image-editor' }); + expect((await session.requests[0].json()).metadata).toEqual({ source: 'ai-image-editor' }); }); it('forwards request metadata to the edit endpoint', async () => { - const fetchImpl = emulatorFetch(); - const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: fetchImpl, ...NO_DELAY }); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: createFetch(), ...NO_DELAY }); await provider.generate({ prompt: 'x', mode: 'edit', - source: SEEDED_IMAGE_UUID, + source: DEMO_FILES[0], metadata: { source: 'ai-image-editor' }, }); - expect(fetchImpl.requests[0].url).toBe('https://upload.uploadcare.com/derivative/image/edit/'); - expect((await fetchImpl.requests[0].json()).metadata).toEqual({ source: 'ai-image-editor' }); + expect(session.requests[0].url).toBe('https://upload.uploadcare.com/derivative/image/edit/'); + expect((await session.requests[0].json()).metadata).toEqual({ source: 'ai-image-editor' }); }); it('uses 1:1 when aspectRatio is missing or invalid', async () => { - const fetchImpl = emulatorFetch(); - const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: fetchImpl, ...NO_DELAY }); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: createFetch(), ...NO_DELAY }); await provider.generate({ prompt: 'x', mode: 'generate' }); - expect((await fetchImpl.requests[0].json()).aspect_ratio).toEqual([1, 1]); + expect((await session.requests[0].json()).aspect_ratio).toEqual([1, 1]); }); it('polls the status endpoint with pub_key + job_id until the success frame reports is_ready', async () => { - const fetchImpl = emulatorFetch(); const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, cdnBaseUrl: CDN, - fetch: fetchImpl, + fetch: createFetch(), ...NO_DELAY, }); @@ -91,16 +85,16 @@ describe('UploadcareDerivativeApi', () => { expect(result.url).toBe(`${CDN}/${result.uuid}/`); // 1 POST + 4 status polls: processing, uploading, success not yet ready, success ready. - expect(fetchImpl).toHaveBeenCalledTimes(5); - expect(fetchImpl.requests[1].url).toMatch( + expect(session.requests).toHaveLength(5); + expect(session.requests[1].url).toMatch( new RegExp(`^https://upload\\.uploadcare\\.com/derivative/status/\\?pub_key=${PUBLIC_KEY}&job_id=[\\w-]+$`), ); - expect(fetchImpl.requests[1].method).toBe('GET'); + expect(session.requests[1].method).toBe('GET'); }); it('throws when the job ends in an error status', async () => { session.use('derivativeFailure', { code: 'content_moderated' }); - const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: emulatorFetch(), ...NO_DELAY }); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: createFetch(), ...NO_DELAY }); await expect(provider.generate({ prompt: 'x', mode: 'generate' })).rejects.toMatchObject({ name: 'AiProviderError', errorCode: 'content_moderated', @@ -118,7 +112,7 @@ describe('UploadcareDerivativeApi', () => { }); const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, - fetch: emulatorFetch(), + fetch: createFetch(), pollIntervalMs: 0, pollTimeoutMs: 0, }); @@ -133,7 +127,7 @@ describe('UploadcareDerivativeApi', () => { neverFinish(); const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, - fetch: emulatorFetch(), + fetch: createFetch(), pollIntervalMs: 0, pollTimeoutMs: 5, }); @@ -141,17 +135,16 @@ describe('UploadcareDerivativeApi', () => { }); it('honours baseUrl + cdnBaseUrl overrides for both generate and status', async () => { - const fetchImpl = emulatorFetch(); const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, baseUrl: 'https://upload.example.com', cdnBaseUrl: CDN, - fetch: fetchImpl, + fetch: createFetch(), ...NO_DELAY, }); const result = await provider.generate({ prompt: 'x', mode: 'generate' }); - expect(fetchImpl.requests[0].url).toBe('https://upload.example.com/derivative/image/generate/'); - expect(fetchImpl.requests[1].url).toMatch( + expect(session.requests[0].url).toBe('https://upload.example.com/derivative/image/generate/'); + expect(session.requests[1].url).toMatch( new RegExp(`^https://upload\\.example\\.com/derivative/status/\\?pub_key=${PUBLIC_KEY}&job_id=`), ); expect(result.url).toBe(`${CDN}/${result.uuid}/`); @@ -161,7 +154,7 @@ describe('UploadcareDerivativeApi', () => { const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, cdnBaseUrl: CDN, - fetch: emulatorFetch(), + fetch: createFetch(), ...NO_DELAY, }); const result = await provider.generate({ prompt: 'x', mode: 'generate', filename: 'f.png' }); @@ -174,23 +167,27 @@ describe('UploadcareDerivativeApi', () => { }); it('derives the CDN base from the public key when cdnBaseUrl is left at the default', async () => { - const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: emulatorFetch(), ...NO_DELAY }); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: createFetch(), ...NO_DELAY }); const result = await provider.generate({ prompt: 'x', mode: 'generate' }); const base = await getPrefixedCdnBaseAsync(PUBLIC_KEY, 'https://ucarecd.net'); expect(result.url).toBe(`${base}/${result.uuid}/`); }); it('surfaces non-2xx generate responses with status text', async () => { + session.on( + 'POST /derivative/image/generate/', + () => new Response('upstream failure', { status: 400, statusText: 'Bad Request' }), + ); const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, - fetch: plainTextFailure(400, 'Bad Request'), + fetch: createFetch(), ...NO_DELAY, }); await expect(provider.generate({ prompt: 'x', mode: 'generate' })).rejects.toThrow(/400/); }); it('passes the abort signal to generate and status fetches', async () => { - const fetchImpl = emulatorFetch(); + const fetchImpl = vi.fn(createFetch()); const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: fetchImpl, ...NO_DELAY }); const controller = new AbortController(); await provider.generate({ prompt: 'x', mode: 'generate', signal: controller.signal }); @@ -201,7 +198,7 @@ describe('UploadcareDerivativeApi', () => { it('stops polling when the signal is aborted mid-flight', async () => { const controller = new AbortController(); - const emulator = emulatorFetch(); + const emulator = createFetch(); const fetchImpl = vi.fn(async (url, init) => { const response = await emulator(url, init); // Abort while "processing": the next poll must never happen. @@ -218,9 +215,9 @@ describe('UploadcareDerivativeApi', () => { }); it('does not start polling when the signal is already aborted', async () => { + const fetchImpl = vi.fn(createFetch()); const controller = new AbortController(); controller.abort(); - const fetchImpl = emulatorFetch(); const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: fetchImpl, ...NO_DELAY }); await expect(provider.generate({ prompt: 'x', mode: 'generate', signal: controller.signal })).rejects.toThrow(); expect(fetchImpl).toHaveBeenCalledTimes(1); @@ -228,45 +225,42 @@ describe('UploadcareDerivativeApi', () => { describe('edit mode', () => { it('POSTs prompt + source uuid to the edit endpoint and resolves the result', async () => { - const fetchImpl = emulatorFetch(); const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, cdnBaseUrl: CDN, - fetch: fetchImpl, + fetch: createFetch(), ...NO_DELAY, }); const result = await provider.generate({ prompt: 'remove the cat', mode: 'edit', - source: SEEDED_IMAGE_UUID, + source: DEMO_FILES[0], aspectRatio: [16, 9], }); - expect(fetchImpl.requests[0].url).toBe('https://upload.uploadcare.com/derivative/image/edit/'); - expect(await fetchImpl.requests[0].json()).toMatchObject({ + expect(session.requests[0].url).toBe('https://upload.uploadcare.com/derivative/image/edit/'); + expect(await session.requests[0].json()).toMatchObject({ pub_key: PUBLIC_KEY, prompt: 'remove the cat', - source: SEEDED_IMAGE_UUID, + source: DEMO_FILES[0], aspect_ratio: [16, 9], }); - expect(result.uuid).not.toBe(SEEDED_IMAGE_UUID); + expect(result.uuid).not.toBe(DEMO_FILES[0]); expect(result.url).toBe(`${CDN}/${result.uuid}/`); expect(result.mode).toBe('edit'); }); it('omits aspect_ratio when none is provided', async () => { - const fetchImpl = emulatorFetch(); - const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: fetchImpl, ...NO_DELAY }); - await provider.generate({ prompt: 'x', mode: 'edit', source: SEEDED_IMAGE_UUID }); - expect((await fetchImpl.requests[0].json()).aspect_ratio).toBeUndefined(); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: createFetch(), ...NO_DELAY }); + await provider.generate({ prompt: 'x', mode: 'edit', source: DEMO_FILES[0] }); + expect((await session.requests[0].json()).aspect_ratio).toBeUndefined(); }); it('throws (without any request) when an edit has no source uuid', async () => { - const fetchImpl = emulatorFetch(); - const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: fetchImpl, ...NO_DELAY }); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: createFetch(), ...NO_DELAY }); await expect(provider.generate({ prompt: 'x', mode: 'edit' })).rejects.toThrow(/source/i); - expect(fetchImpl).not.toHaveBeenCalled(); + expect(session.requests).toHaveLength(0); }); it('resolveCdnUrl maps a uuid to its CDN URL', async () => { @@ -279,10 +273,10 @@ describe('UploadcareDerivativeApi', () => { it('waits for the file to be ready and wraps it as an UploadcareFile on the CDN base', async () => { const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, baseUrl: uploadOrigin, cdnBaseUrl: CDN }); - const file = await provider.getFileInfo(SEEDED_IMAGE_UUID); + const file = await provider.getFileInfo(DEMO_FILES[0]); - expect(file.uuid).toBe(SEEDED_IMAGE_UUID); - expect(file.cdnUrl).toBe(`${CDN}/${SEEDED_IMAGE_UUID}/`); + expect(file.uuid).toBe(DEMO_FILES[0]); + expect(file.cdnUrl).toBe(`${CDN}/${DEMO_FILES[0]}/`); expect(file.imageInfo).toMatchObject({ width: expect.any(Number), height: expect.any(Number) }); }); @@ -290,7 +284,7 @@ describe('UploadcareDerivativeApi', () => { const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, baseUrl: uploadOrigin }); const controller = new AbortController(); controller.abort(); - await expect(provider.getFileInfo(SEEDED_IMAGE_UUID, controller.signal)).rejects.toThrow(/cancel/i); + await expect(provider.getFileInfo(DEMO_FILES[0], controller.signal)).rejects.toThrow(/cancel/i); }); it('propagates a lookup failure', async () => { @@ -312,50 +306,48 @@ describe('UploadcareDerivativeApi', () => { // upload-client's isReadyPoll. Dropping either assignment would leave // half the provider unauthenticated. const token = await mintAuthToken(); - const fetchImpl = emulatorFetch(); const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, baseUrl: uploadOrigin, authToken: token, - fetch: fetchImpl, + fetch: createFetch(), ...NO_DELAY, }); await provider.generate({ prompt: 'x', mode: 'generate' }); - expect(fetchImpl.requests[0].headers.get('Authorization')).toBe(`Bearer ${token}`); + expect(session.requests[0].headers.get('Authorization')).toBe(`Bearer ${token}`); - await expect(provider.getFileInfo(SEEDED_IMAGE_UUID)).resolves.toMatchObject({ uuid: SEEDED_IMAGE_UUID }); + await expect(provider.getFileInfo(DEMO_FILES[0])).resolves.toMatchObject({ uuid: DEMO_FILES[0] }); }); it('is refused on both network paths without one', async () => { const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, baseUrl: uploadOrigin, - fetch: emulatorFetch(), + fetch: createFetch(), ...NO_DELAY, }); await expect(provider.generate({ prompt: 'x', mode: 'generate' })).rejects.toThrow(/signature/i); - await expect(provider.getFileInfo(SEEDED_IMAGE_UUID)).rejects.toThrow(/signature/i); + await expect(provider.getFileInfo(DEMO_FILES[0])).rejects.toThrow(/signature/i); }); it('follows a token that changes, on both network paths', async () => { // The provider holds one resolver for its lifetime; whoever owns the // token changes what that resolver returns, and nothing is pushed in. let token = 'not-a-token'; - const fetchImpl = emulatorFetch(); const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, baseUrl: uploadOrigin, authToken: () => token, - fetch: fetchImpl, + fetch: createFetch(), ...NO_DELAY, }); token = await mintAuthToken({ tokenId: 'second' }); await provider.generate({ prompt: 'x', mode: 'generate' }); - expect(fetchImpl.requests[0].headers.get('Authorization')).toBe(`Bearer ${token}`); - await expect(provider.getFileInfo(SEEDED_IMAGE_UUID)).resolves.toMatchObject({ uuid: SEEDED_IMAGE_UUID }); + expect(session.requests[0].headers.get('Authorization')).toBe(`Bearer ${token}`); + await expect(provider.getFileInfo(DEMO_FILES[0])).resolves.toMatchObject({ uuid: DEMO_FILES[0] }); }); }); }); From e152b7326c0585f61579e4f987697fcc135ab201 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Thu, 8 Oct 2026 01:02:26 +0300 Subject: [PATCH 026/104] test(ai-image-editor): run the specs against the emulator's ./node setup The specs project now starts @uploadcare/api-emulator/node once in tests/specs/setup.ts and resets it before every test. The client specs use the global fetch and the session from that setup file instead of injecting createFetch(); the abort-signal and call-count checks spy on globalThis.fetch. getFileInfo goes through the same emulator over node:https, so the derivative spec no longer starts a ./listen server. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 19 +++-- .../provider/api/uploadcareApiClient.test.ts | 63 ++++++++------- .../api/uploadcareDerivativeApi.test.ts | 79 +++++++------------ packages/ai-image-editor/tests/specs/setup.ts | 18 +++++ packages/ai-image-editor/vitest.config.ts | 4 + 5 files changed, 92 insertions(+), 91 deletions(-) create mode 100644 packages/ai-image-editor/tests/specs/setup.ts diff --git a/AGENTS.md b/AGENTS.md index ce547e3..fa9f577 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,11 +82,16 @@ second time, pushing the original one level deeper on `cause`. stands in for the Upload API and CDN; tests should not hand-write Uploadcare responses. -- **Specs** (happy-dom) inject the emulator's `createFetch()` as the client's - `fetch`, take the session from `resetSession()` before each test, and read - what was sent from its `requests`. `getFileInfo` goes through - upload-client's own transport, so those specs start a real emulator server - (`@uploadcare/api-emulator/listen`) and pass its origin as `baseUrl`. +- **Specs** (happy-dom) run against `setupEmulator()` from + `@uploadcare/api-emulator/node`, started once in `tests/specs/setup.ts` + (the `specs` project's setup file). It answers the global `fetch` and + `node:http(s)` (upload-client's transport, so `getFileInfo` too), refuses + every other origin, and resets the session before every test: import + `session` from there to register a scenario or read what was sent from its + `requests`. The clients call the global `fetch`; a spec that checks what + they pass it uses `vi.spyOn(globalThis, 'fetch')` and restores it after the + test. The project sets happy-dom's `disableSameOriginPolicy`, because the + emulator answers no CORS preflight. - **Browser e2e** run the emulator in the page with `setupEmulator()` from `@uploadcare/api-emulator/browser` (MSW and `@mswjs/interceptors` underneath, both devDependencies of the root @@ -99,7 +104,7 @@ responses. a generation finishes on its first poll instead of after the editor's 1.5s interval several times over. - Steer the emulator per test with `session.on()` and `session.use(preset)` - (`resetSession()` answers the session in specs); there are no magic keys, + (`session` from the project's setup file); there are no magic keys, prompts or uuids. - Bearer tokens must be real: mint them with the emulator's `mintAuthToken()` (WebCrypto, so it works in the page too). The images every fresh session @@ -107,7 +112,7 @@ responses. - A `session.on()` handler runs before the emulator's routes and can answer any `Response` or a promise that never settles, so a bare non-JSON failure, a job stuck in `processing` or a poll that hangs is a scenario, not a stub. - Hand-write a `fetch` only where it is simpler, and say why next to it. + Stub `fetch` only where it is simpler, and say why next to it. The dependency is a TEMPORARY `file:` link to an unreleased checkout, so `npm ci` only resolves it on that machine; swap it for a version once the diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts index 966df10..7e460d7 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts @@ -1,13 +1,13 @@ -import { createFetch, DEMO_FILES, type EmulatorSession, mintAuthToken, resetSession } from '@uploadcare/api-emulator'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { DEMO_FILES, mintAuthToken } from '@uploadcare/api-emulator'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { session } from '../../../../tests/specs/setup'; import { AiProviderError } from '../model/types'; import { UploadcareApiClient } from './uploadcareApiClient'; const PUBLIC_KEY = 'demopublickey'; -let session: EmulatorSession; -beforeEach(() => { - session = resetSession(); +afterEach(() => { + vi.restoreAllMocks(); }); describe('UploadcareApiClient', () => { @@ -17,7 +17,7 @@ describe('UploadcareApiClient', () => { describe('authToken', () => { it('sends no Authorization header when unset', async () => { - const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: createFetch() }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); @@ -26,7 +26,7 @@ describe('UploadcareApiClient', () => { it('sends a plain token as a bearer header', async () => { const token = await mintAuthToken(); - const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: createFetch(), authToken: token }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, authToken: token }); await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); @@ -36,7 +36,7 @@ describe('UploadcareApiClient', () => { it('re-resolves a resolver per request, so a job can rotate tokens mid-flight', async () => { const [first, second] = [await mintAuthToken({ tokenId: 'first' }), await mintAuthToken({ tokenId: 'second' })]; const authToken = vi.fn().mockResolvedValueOnce(first).mockResolvedValueOnce(second); - const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: createFetch(), authToken }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, authToken }); const { job_id } = await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); await client.getJobStatus(job_id!); @@ -48,7 +48,7 @@ describe('UploadcareApiClient', () => { describe('generate', () => { it('POSTs pub_key + prompt + aspect_ratio + filename and returns the job', async () => { - const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: createFetch() }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); const job = await client.generate({ prompt: 'a hat', aspectRatio: [16, 9], filename: 'generated.png' }); @@ -64,7 +64,7 @@ describe('UploadcareApiClient', () => { }); it('includes store only when provided', async () => { - const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: createFetch() }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png', store: true }); @@ -74,7 +74,7 @@ describe('UploadcareApiClient', () => { }); it('includes metadata only when provided', async () => { - const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: createFetch() }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); await client.generate({ @@ -92,7 +92,6 @@ describe('UploadcareApiClient', () => { const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, baseUrl: 'https://upload.example.com/', - fetch: createFetch(), }); await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); expect(session.requests[0].url).toBe('https://upload.example.com/derivative/image/generate/'); @@ -103,27 +102,27 @@ describe('UploadcareApiClient', () => { 'POST /derivative/image/generate/', () => new Response('upstream failure', { status: 400, statusText: 'Bad Request' }), ); - const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: createFetch() }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); await expect(client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' })).rejects.toThrow(/400/); }); it('forwards the abort signal', async () => { - const fetchImpl = vi.fn(createFetch()); - const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: fetchImpl }); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); const controller = new AbortController(); await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png', signal: controller.signal }); - expect(fetchImpl.mock.calls[0]![1]?.signal).toBe(controller.signal); + expect(fetchSpy.mock.calls[0]![1]?.signal).toBe(controller.signal); }); it('sends Accept: application/json', async () => { - const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: createFetch() }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); expect(session.requests[0].headers.get('Accept')).toBe('application/json'); }); it('surfaces a platform error envelope as an AiProviderError with its code', async () => { session.use('derivativesDisabled'); - const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: createFetch() }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); await expect(client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' })).rejects.toMatchObject({ name: 'AiProviderError', errorCode: 'derivative_disabled', @@ -134,7 +133,7 @@ describe('UploadcareApiClient', () => { describe('edit', () => { it('POSTs pub_key + prompt + source uuid + filename to the edit endpoint', async () => { - const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: createFetch() }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); const job = await client.edit({ prompt: 'remove the cat', source: DEMO_FILES[0], filename: 'edited.png' }); @@ -150,7 +149,7 @@ describe('UploadcareApiClient', () => { }); it('includes aspect_ratio only when provided', async () => { - const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: createFetch() }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); await client.edit({ prompt: 'x', source: DEMO_FILES[0], filename: 'f.png' }); await client.edit({ prompt: 'x', source: DEMO_FILES[0], filename: 'f.png', aspectRatio: [16, 9] }); @@ -160,7 +159,7 @@ describe('UploadcareApiClient', () => { }); it('includes metadata only when provided', async () => { - const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: createFetch() }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); await client.edit({ prompt: 'x', source: DEMO_FILES[0], filename: 'f.png' }); await client.edit({ @@ -175,7 +174,7 @@ describe('UploadcareApiClient', () => { }); it('surfaces a source the project does not have as an AiProviderError', async () => { - const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: createFetch() }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); await expect(client.edit({ prompt: 'x', source: 'missing', filename: 'f.png' })).rejects.toMatchObject({ name: 'AiProviderError', errorCode: 'source_not_found', @@ -187,16 +186,16 @@ describe('UploadcareApiClient', () => { 'POST /derivative/image/edit/', () => new Response('upstream failure', { status: 400, statusText: 'Bad Request' }), ); - const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: createFetch() }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); await expect(client.edit({ prompt: 'x', source: 'u', filename: 'f.png' })).rejects.toThrow(/400/); }); it('forwards the abort signal', async () => { - const fetchImpl = vi.fn(createFetch()); - const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: fetchImpl }); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); const controller = new AbortController(); await client.edit({ prompt: 'x', source: DEMO_FILES[0], filename: 'f.png', signal: controller.signal }); - expect(fetchImpl.mock.calls[0]![1]?.signal).toBe(controller.signal); + expect(fetchSpy.mock.calls[0]![1]?.signal).toBe(controller.signal); }); }); @@ -205,7 +204,7 @@ describe('UploadcareApiClient', () => { (await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' })).job_id!; it('GETs the status endpoint with pub_key + job_id and returns the parsed status', async () => { - const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: createFetch() }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); const jobId = await startJob(client); const status = await client.getJobStatus(jobId); @@ -222,24 +221,24 @@ describe('UploadcareApiClient', () => { 'GET /derivative/status/', () => new Response('upstream failure', { status: 404, statusText: 'Not Found' }), ); - const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: createFetch() }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); await expect(client.getJobStatus('job-1')).rejects.toThrow(/404/); }); it('surfaces a platform error envelope (e.g. job_not_found) as an AiProviderError', async () => { - const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: createFetch() }); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); const err = await client.getJobStatus('job-1').catch((e) => e); expect(err).toBeInstanceOf(AiProviderError); expect(err.errorCode).toBe('job_not_found'); }); it('forwards the abort signal', async () => { - const fetchImpl = vi.fn(createFetch()); - const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY, fetch: fetchImpl }); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); const jobId = await startJob(client); const controller = new AbortController(); await client.getJobStatus(jobId, controller.signal); - expect(fetchImpl.mock.calls[1]![1]?.signal).toBe(controller.signal); + expect(fetchSpy.mock.calls[1]![1]?.signal).toBe(controller.signal); }); }); }); diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts index 0cb6890..00c8fcd 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts @@ -1,7 +1,7 @@ -import { createFetch, DEMO_FILES, type EmulatorSession, mintAuthToken, resetSession } from '@uploadcare/api-emulator'; -import { createEmulatorServer } from '@uploadcare/api-emulator/listen'; +import { DEMO_FILES, mintAuthToken } from '@uploadcare/api-emulator'; import { getPrefixedCdnBaseAsync } from '@uploadcare/cname-prefix/async'; -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { session } from '../../../../tests/specs/setup'; import { AiProviderError } from '../model/types'; import { UploadcareDerivativeApi } from './uploadcareDerivativeApi'; @@ -13,20 +13,8 @@ const NO_DELAY = { pollIntervalMs: 0 } as const; const neverFinish = () => session.on('GET /derivative/status/', () => Response.json({ type: 'job', status: 'processing' })); -/** - * `getFileInfo` goes through upload-client's own transport (node:http here), - * not the injected fetch, so it needs the emulator as a real origin. Both - * paths share the emulator's default session. - */ -let uploadOrigin: string; -let closeEmulator: () => Promise; -beforeAll(async () => { - ({ origin: uploadOrigin, close: closeEmulator } = await createEmulatorServer({ delayMs: 0 })); -}); -afterAll(() => closeEmulator()); -let session: EmulatorSession; -beforeEach(() => { - session = resetSession(); +afterEach(() => { + vi.restoreAllMocks(); }); describe('UploadcareDerivativeApi', () => { @@ -35,7 +23,7 @@ describe('UploadcareDerivativeApi', () => { }); it('POSTs the prompt + aspect ratio + pub_key to the derivative endpoint', async () => { - const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: createFetch(), ...NO_DELAY }); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); await provider.generate({ prompt: 'a hat', mode: 'generate', aspectRatio: [16, 9] }); expect(session.requests[0].url).toBe('https://upload.uploadcare.com/derivative/image/generate/'); @@ -50,13 +38,13 @@ describe('UploadcareDerivativeApi', () => { }); it('forwards request metadata to the generate endpoint', async () => { - const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: createFetch(), ...NO_DELAY }); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); await provider.generate({ prompt: 'x', mode: 'generate', metadata: { source: 'ai-image-editor' } }); expect((await session.requests[0].json()).metadata).toEqual({ source: 'ai-image-editor' }); }); it('forwards request metadata to the edit endpoint', async () => { - const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: createFetch(), ...NO_DELAY }); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); await provider.generate({ prompt: 'x', mode: 'edit', @@ -68,7 +56,7 @@ describe('UploadcareDerivativeApi', () => { }); it('uses 1:1 when aspectRatio is missing or invalid', async () => { - const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: createFetch(), ...NO_DELAY }); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); await provider.generate({ prompt: 'x', mode: 'generate' }); expect((await session.requests[0].json()).aspect_ratio).toEqual([1, 1]); }); @@ -77,7 +65,6 @@ describe('UploadcareDerivativeApi', () => { const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, cdnBaseUrl: CDN, - fetch: createFetch(), ...NO_DELAY, }); @@ -94,7 +81,7 @@ describe('UploadcareDerivativeApi', () => { it('throws when the job ends in an error status', async () => { session.use('derivativeFailure', { code: 'content_moderated' }); - const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: createFetch(), ...NO_DELAY }); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); await expect(provider.generate({ prompt: 'x', mode: 'generate' })).rejects.toMatchObject({ name: 'AiProviderError', errorCode: 'content_moderated', @@ -112,7 +99,6 @@ describe('UploadcareDerivativeApi', () => { }); const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, - fetch: createFetch(), pollIntervalMs: 0, pollTimeoutMs: 0, }); @@ -127,7 +113,6 @@ describe('UploadcareDerivativeApi', () => { neverFinish(); const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, - fetch: createFetch(), pollIntervalMs: 0, pollTimeoutMs: 5, }); @@ -139,7 +124,6 @@ describe('UploadcareDerivativeApi', () => { publicKey: PUBLIC_KEY, baseUrl: 'https://upload.example.com', cdnBaseUrl: CDN, - fetch: createFetch(), ...NO_DELAY, }); const result = await provider.generate({ prompt: 'x', mode: 'generate' }); @@ -154,7 +138,6 @@ describe('UploadcareDerivativeApi', () => { const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, cdnBaseUrl: CDN, - fetch: createFetch(), ...NO_DELAY, }); const result = await provider.generate({ prompt: 'x', mode: 'generate', filename: 'f.png' }); @@ -167,7 +150,7 @@ describe('UploadcareDerivativeApi', () => { }); it('derives the CDN base from the public key when cdnBaseUrl is left at the default', async () => { - const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: createFetch(), ...NO_DELAY }); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); const result = await provider.generate({ prompt: 'x', mode: 'generate' }); const base = await getPrefixedCdnBaseAsync(PUBLIC_KEY, 'https://ucarecd.net'); expect(result.url).toBe(`${base}/${result.uuid}/`); @@ -180,47 +163,46 @@ describe('UploadcareDerivativeApi', () => { ); const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, - fetch: createFetch(), ...NO_DELAY, }); await expect(provider.generate({ prompt: 'x', mode: 'generate' })).rejects.toThrow(/400/); }); it('passes the abort signal to generate and status fetches', async () => { - const fetchImpl = vi.fn(createFetch()); - const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: fetchImpl, ...NO_DELAY }); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); const controller = new AbortController(); await provider.generate({ prompt: 'x', mode: 'generate', signal: controller.signal }); - const signals = fetchImpl.mock.calls.map(([, init]) => init?.signal); + const signals = fetchSpy.mock.calls.map(([, init]) => init?.signal); expect(signals.length).toBeGreaterThanOrEqual(2); expect(signals.every((s) => s === controller.signal)).toBe(true); }); it('stops polling when the signal is aborted mid-flight', async () => { const controller = new AbortController(); - const emulator = createFetch(); - const fetchImpl = vi.fn(async (url, init) => { - const response = await emulator(url, init); + const emulated = globalThis.fetch; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => { + const response = await emulated(url, init); // Abort while "processing": the next poll must never happen. if (init?.method === 'GET') controller.abort(); return response; }); - const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: fetchImpl, ...NO_DELAY }); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); await expect(provider.generate({ prompt: 'x', mode: 'generate', signal: controller.signal })).rejects.toThrow( /cancel/i, ); // 1 POST + exactly 1 status poll, then it bails — no further polling. - expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(fetchSpy).toHaveBeenCalledTimes(2); }); it('does not start polling when the signal is already aborted', async () => { - const fetchImpl = vi.fn(createFetch()); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); const controller = new AbortController(); controller.abort(); - const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: fetchImpl, ...NO_DELAY }); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); await expect(provider.generate({ prompt: 'x', mode: 'generate', signal: controller.signal })).rejects.toThrow(); - expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledTimes(1); }); describe('edit mode', () => { @@ -228,7 +210,6 @@ describe('UploadcareDerivativeApi', () => { const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, cdnBaseUrl: CDN, - fetch: createFetch(), ...NO_DELAY, }); @@ -252,13 +233,13 @@ describe('UploadcareDerivativeApi', () => { }); it('omits aspect_ratio when none is provided', async () => { - const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: createFetch(), ...NO_DELAY }); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); await provider.generate({ prompt: 'x', mode: 'edit', source: DEMO_FILES[0] }); expect((await session.requests[0].json()).aspect_ratio).toBeUndefined(); }); it('throws (without any request) when an edit has no source uuid', async () => { - const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, fetch: createFetch(), ...NO_DELAY }); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); await expect(provider.generate({ prompt: 'x', mode: 'edit' })).rejects.toThrow(/source/i); expect(session.requests).toHaveLength(0); }); @@ -271,7 +252,7 @@ describe('UploadcareDerivativeApi', () => { describe('getFileInfo', () => { it('waits for the file to be ready and wraps it as an UploadcareFile on the CDN base', async () => { - const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, baseUrl: uploadOrigin, cdnBaseUrl: CDN }); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, cdnBaseUrl: CDN }); const file = await provider.getFileInfo(DEMO_FILES[0]); @@ -281,14 +262,14 @@ describe('UploadcareDerivativeApi', () => { }); it('honours the abort signal', async () => { - const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, baseUrl: uploadOrigin }); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY }); const controller = new AbortController(); controller.abort(); await expect(provider.getFileInfo(DEMO_FILES[0], controller.signal)).rejects.toThrow(/cancel/i); }); it('propagates a lookup failure', async () => { - const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, baseUrl: uploadOrigin }); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY }); await expect(provider.getFileInfo('missing')).rejects.toThrow(/not found/i); }); }); @@ -308,9 +289,7 @@ describe('UploadcareDerivativeApi', () => { const token = await mintAuthToken(); const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, - baseUrl: uploadOrigin, authToken: token, - fetch: createFetch(), ...NO_DELAY, }); @@ -323,8 +302,6 @@ describe('UploadcareDerivativeApi', () => { it('is refused on both network paths without one', async () => { const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, - baseUrl: uploadOrigin, - fetch: createFetch(), ...NO_DELAY, }); await expect(provider.generate({ prompt: 'x', mode: 'generate' })).rejects.toThrow(/signature/i); @@ -337,9 +314,7 @@ describe('UploadcareDerivativeApi', () => { let token = 'not-a-token'; const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, - baseUrl: uploadOrigin, authToken: () => token, - fetch: createFetch(), ...NO_DELAY, }); diff --git a/packages/ai-image-editor/tests/specs/setup.ts b/packages/ai-image-editor/tests/specs/setup.ts new file mode 100644 index 0000000..7b6d9c9 --- /dev/null +++ b/packages/ai-image-editor/tests/specs/setup.ts @@ -0,0 +1,18 @@ +import type { EmulatorSession } from '@uploadcare/api-emulator'; +import { setupEmulator } from '@uploadcare/api-emulator/node'; +import { beforeEach } from 'vitest'; + +/** + * The Uploadcare emulator behind this process's `fetch` and `node:http(s)`, for Uploadcare's hosts and + * `upload.example.com` (the specs' custom `baseUrl`; the emulator routes by path, so it answers there too). Every + * other origin fails. + */ +const emulator = setupEmulator({ cdnHosts: ['upload.example.com'] }); + +/** The current test's emulator session, for a spec that registers a scenario or reads `requests`. */ +export let session: EmulatorSession; + +/** Every test starts against an empty session. */ +beforeEach(async () => { + session = await emulator.reset(); +}); diff --git a/packages/ai-image-editor/vitest.config.ts b/packages/ai-image-editor/vitest.config.ts index ee503e4..c2260bf 100644 --- a/packages/ai-image-editor/vitest.config.ts +++ b/packages/ai-image-editor/vitest.config.ts @@ -10,6 +10,10 @@ export default defineConfig({ name: 'specs', include: ['src/**/*.test.ts'], environment: 'happy-dom', + // happy-dom's fetch preflights a cross-origin request, and the emulator answers no OPTIONS; the specs test + // the clients, not CORS. + environmentOptions: { happyDOM: { settings: { fetch: { disableSameOriginPolicy: true } } } }, + setupFiles: ['tests/specs/setup.ts'], }, }, { From 44773a7f6de3891a935034ce9298e43c3e7c162d Mon Sep 17 00:00:00 2001 From: nd0ut Date: Thu, 8 Oct 2026 01:02:49 +0300 Subject: [PATCH 027/104] refactor(ai-image-editor): drop the test-only fetch option from the API clients UploadcareApiClient and UploadcareDerivativeApi call the global fetch at request time. The option was only ever set by specs, which now run against the emulator behind the global fetch. Co-Authored-By: Claude Opus 5.5 --- .../src/entities/provider/api/uploadcareApiClient.ts | 8 ++------ .../src/entities/provider/api/uploadcareDerivativeApi.ts | 3 --- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.ts index ef0bdbf..54da2f6 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.ts @@ -21,8 +21,6 @@ export type UploadcareApiClientOptions = { publicKey: string; /** Base URL for the upload API. Defaults to https://upload.uploadcare.com. */ baseUrl?: string; - /** Override the global fetch — useful for tests. */ - fetch?: typeof fetch; /** * JWT for the `Authorization: Bearer` scheme. A plain token, or a resolver * called before every request so a long-running job can rotate tokens @@ -161,7 +159,6 @@ async function readJson(response: Response, action: string): Promise { export class UploadcareApiClient { private readonly publicKey: string; private readonly baseUrl: string; - private readonly doFetch: typeof fetch; private readonly authToken: AuthToken | undefined; constructor(options: UploadcareApiClientOptions) { @@ -170,7 +167,6 @@ export class UploadcareApiClient { } this.publicKey = options.publicKey; this.baseUrl = options.baseUrl ?? 'https://upload.uploadcare.com'; - this.doFetch = options.fetch ?? globalThis.fetch.bind(globalThis); this.authToken = options.authToken; } @@ -211,7 +207,7 @@ export class UploadcareApiClient { url.searchParams.set('pub_key', this.publicKey); url.searchParams.set('job_id', jobId); - const response = await this.doFetch(url.href, { + const response = await fetch(url.href, { method: 'GET', headers: { Accept: 'application/json', ...(await this.authHeaders()) }, signal, @@ -233,7 +229,7 @@ export class UploadcareApiClient { action: string, signal?: AbortSignal, ): Promise { - const response = await this.doFetch(endpoint.href, { + const response = await fetch(endpoint.href, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.ts index 16f6e57..0823792 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.ts @@ -33,8 +33,6 @@ export type UploadcareDerivativeApiOptions = { filename?: string; /** Whether to store the generated image. Defaults to "auto" (per server default). */ store?: 'auto' | boolean; - /** Override the global fetch — useful for tests. */ - fetch?: typeof fetch; /** * CDN cname for resolving `{uuid}` responses. When left at the default * `https://ucarecdn.com` (or already a prefixed base), the actual base is @@ -91,7 +89,6 @@ export class UploadcareDerivativeApi implements AiProvider { this.api = new UploadcareApiClient({ publicKey: options.publicKey, baseUrl: options.baseUrl, - fetch: options.fetch, authToken: options.authToken, }); this.publicKey = options.publicKey; From 228a204f5c9488e9c35c12c6af938def95ce3436 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Thu, 8 Oct 2026 01:17:52 +0300 Subject: [PATCH 028/104] test(ai-image-editor): drop the happy-dom same-origin override from the specs The emulator's ./node now answers CORS preflights, so the specs no longer need happy-dom's disableSameOriginPolicy. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 3 +-- packages/ai-image-editor/vitest.config.ts | 3 --- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fa9f577..a8f89c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,8 +90,7 @@ responses. `session` from there to register a scenario or read what was sent from its `requests`. The clients call the global `fetch`; a spec that checks what they pass it uses `vi.spyOn(globalThis, 'fetch')` and restores it after the - test. The project sets happy-dom's `disableSameOriginPolicy`, because the - emulator answers no CORS preflight. + test. - **Browser e2e** run the emulator in the page with `setupEmulator()` from `@uploadcare/api-emulator/browser` (MSW and `@mswjs/interceptors` underneath, both devDependencies of the root diff --git a/packages/ai-image-editor/vitest.config.ts b/packages/ai-image-editor/vitest.config.ts index c2260bf..471ad33 100644 --- a/packages/ai-image-editor/vitest.config.ts +++ b/packages/ai-image-editor/vitest.config.ts @@ -10,9 +10,6 @@ export default defineConfig({ name: 'specs', include: ['src/**/*.test.ts'], environment: 'happy-dom', - // happy-dom's fetch preflights a cross-origin request, and the emulator answers no OPTIONS; the specs test - // the clients, not CORS. - environmentOptions: { happyDOM: { settings: { fetch: { disableSameOriginPolicy: true } } } }, setupFiles: ['tests/specs/setup.ts'], }, }, From 0429bec673455ce87ea7d8a9ac03fe609f0bc499 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Thu, 8 Oct 2026 01:17:58 +0300 Subject: [PATCH 029/104] test(ai-image-editor): stop the spec emulator after each file Its interceptors patch globals, so a worker shared across files (isolate: false) failed on the next file's apply(). Co-Authored-By: Claude Opus 5.5 --- packages/ai-image-editor/tests/specs/setup.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/ai-image-editor/tests/specs/setup.ts b/packages/ai-image-editor/tests/specs/setup.ts index 7b6d9c9..6492949 100644 --- a/packages/ai-image-editor/tests/specs/setup.ts +++ b/packages/ai-image-editor/tests/specs/setup.ts @@ -1,6 +1,6 @@ import type { EmulatorSession } from '@uploadcare/api-emulator'; import { setupEmulator } from '@uploadcare/api-emulator/node'; -import { beforeEach } from 'vitest'; +import { afterAll, beforeEach } from 'vitest'; /** * The Uploadcare emulator behind this process's `fetch` and `node:http(s)`, for Uploadcare's hosts and @@ -16,3 +16,6 @@ export let session: EmulatorSession; beforeEach(async () => { session = await emulator.reset(); }); + +/** Removes the emulator's global patches, so a worker shared with a later file starts clean. */ +afterAll(() => emulator.stop()); From d85871ed76e5500d6ac04e0b304a245c69d9d117 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Thu, 8 Oct 2026 03:15:36 +0300 Subject: [PATCH 030/104] build: move to msw 3 Bump the root msw dev dependency from 2.15.0 to 3.0.2 to match the emulator's msw 3 peer. Every @vitest/mocker declares an optional msw ^2.4.9 peer, so npm ls flagged msw 3 as invalid; an override lets @vitest/mocker accept the root msw. Vitest 4 browser mode mocks modules over RPC and does not load msw at runtime. Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 374 ++++++++++++++++++++++++++++++++-------------- package.json | 7 +- 2 files changed, 268 insertions(+), 113 deletions(-) diff --git a/package-lock.json b/package-lock.json index e47ca68..605152d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "husky": "^9.0.11", "lerna": "^10.0.1", "lint-staged": "^15.2.2", - "msw": "2.15.0", + "msw": "3.0.2", "typescript": "^5.6.0", "vite": "^6.2.3", "vite-plugin-dts": "^4.5.0", @@ -36,18 +36,34 @@ "dev": true, "license": "MIT", "devDependencies": { + "@mswjs/interceptors": "^0.45.7", + "@vitest/browser-playwright": "^4.1.11", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", + "msw": "^3.0.2", "oxfmt": "^0.53.0", "oxlint": "^1.68.0", "oxlint-tsgolint": "^0.23.0", + "playwright": "^1.63.0", "vite": "^7.0.0", "vite-plugin-dts": "^4.5.4", - "vitest": "^3.2.4", + "vitest": "^4.1.11", "yaml": "^2.9.1" }, "engines": { "node": ">=20" + }, + "peerDependencies": { + "@mswjs/interceptors": "^0.45.7", + "msw": "^3.0.2" + }, + "peerDependenciesMeta": { + "@mswjs/interceptors": { + "optional": true + }, + "msw": { + "optional": true + } } }, "node_modules/@algolia/abtesting": { @@ -1789,6 +1805,13 @@ "resolve": "~1.22.2" } }, + "node_modules/@msw/url": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@msw/url/-/url-0.1.2.tgz", + "integrity": "sha512-DdeBLQxQ+ZrtmwEmaj7GQ7lbzPCgbbbyAqOz/I4ulo68hwBpfdNvQJ5bmihJUkAAKOeCmqCIG3OFZGosqFN4mA==", + "dev": true, + "license": "MIT" + }, "node_modules/@mswjs/interceptors": { "version": "0.45.7", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.45.7.tgz", @@ -2788,24 +2811,6 @@ "@octokit/openapi-types": "^24.2.0" } }, - "node_modules/@open-draft/deferred-promise": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz", - "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@open-draft/logger": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", - "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-node-process": "^1.2.0", - "outvariant": "^1.4.0" - } - }, "node_modules/@open-draft/until": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-3.0.1.tgz", @@ -4257,13 +4262,6 @@ "@types/node": "*" } }, - "node_modules/@types/statuses": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", - "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -4450,25 +4448,34 @@ } } }, - "node_modules/@vitest/expect": { + "node_modules/@vitest/browser-playwright/node_modules/@vitest/mocker": { "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", "dev": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "node_modules/@vitest/mocker": { + "node_modules/@vitest/browser/node_modules/@vitest/mocker": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", @@ -4495,6 +4502,24 @@ } } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vitest/pretty-format": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", @@ -6270,13 +6295,13 @@ "license": "MIT" }, "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=22" }, "funding": { "type": "opencollective", @@ -7479,16 +7504,6 @@ "dev": true, "license": "ISC" }, - "node_modules/graphql": { - "version": "16.14.2", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", - "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" - } - }, "node_modules/handlebars": { "version": "4.7.9", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", @@ -10728,47 +10743,48 @@ "license": "MIT" }, "node_modules/msw": { - "version": "2.15.0", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.15.0.tgz", - "integrity": "sha512-2wQAmKkQKxRuXvYJxVhPGG0wZNBQyD06oJvxqw90XqLvptdqxdlHrFUfEteKkpaNORX3Xzc+HtEl/q0nfmN2wQ==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/msw/-/msw-3.0.2.tgz", + "integrity": "sha512-/74rddlgCmlHvrXUkuYKoUjDJTiP4Nrz2uyIpOTLtxJNCS+2yY+DlLfzzRUc61cvRjCo6Jvi2+vRNZyZQa1j4A==", "dev": true, - "hasInstallScript": true, "license": "MIT", "dependencies": { "@inquirer/confirm": "^6.0.11", - "@mswjs/interceptors": "^0.41.3", - "@open-draft/deferred-promise": "^3.0.0", - "@types/statuses": "^2.0.6", - "cookie": "^1.1.1", - "graphql": "^16.13.2", + "@msw/url": "^0.1.2", + "@mswjs/interceptors": "^0.45.6", + "cookie": "^2.0.1", "headers-polyfill": "^5.0.1", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", - "path-to-regexp": "^6.3.0", - "picocolors": "^1.1.1", - "rettime": "^0.11.11", - "statuses": "^2.0.2", - "strict-event-emitter": "^0.5.1", - "tough-cookie": "^6.0.1", + "rettime": "^0.11.12", + "tough-cookie": "^6.0.2", "type-fest": "^5.5.0", "until-async": "^3.0.2", - "yargs": "^17.7.2" + "yargs": "^18.2.0" }, "bin": { "msw": "cli/index.js" }, "engines": { - "node": ">=18" + "node": ">=22.12.0" }, "funding": { "url": "https://github.com/sponsors/mswjs" }, "peerDependencies": { - "typescript": ">= 4.8.x" + "graphql": ">=16", + "typescript": ">=5.9.x", + "vite": ">=6" }, "peerDependenciesMeta": { + "graphql": { + "optional": true + }, "typescript": { "optional": true + }, + "vite": { + "optional": true } } }, @@ -10859,35 +10875,69 @@ } } }, - "node_modules/msw/node_modules/@mswjs/interceptors": { - "version": "0.41.9", - "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz", - "integrity": "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==", + "node_modules/msw/node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", "dev": true, "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/msw/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/msw/node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", "dependencies": { - "@open-draft/deferred-promise": "^2.2.0", - "@open-draft/logger": "^0.3.0", - "@open-draft/until": "^2.0.0", - "is-node-process": "^1.2.0", - "outvariant": "^1.4.3", - "strict-event-emitter": "^0.5.1" + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">=18" + "node": ">=20" } }, - "node_modules/msw/node_modules/@mswjs/interceptors/node_modules/@open-draft/deferred-promise": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", - "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "node_modules/msw/node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "node_modules/msw/node_modules/@open-draft/until": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", - "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "node_modules/msw/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "dev": true, "license": "MIT" }, @@ -10914,6 +10964,39 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/msw/node_modules/string-width": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz", + "integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/msw/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/msw/node_modules/type-fest": { "version": "5.10.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.10.0.tgz", @@ -10930,6 +11013,70 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/msw/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/msw/node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/msw/node_modules/yargs": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz", + "integrity": "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/msw/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, "node_modules/muggle-string": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", @@ -12161,13 +12308,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/path-to-regexp": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", - "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", - "dev": true, - "license": "MIT" - }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -13629,16 +13769,6 @@ "dev": true, "license": "MIT" }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/std-env": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", @@ -13646,13 +13776,6 @@ "dev": true, "license": "MIT" }, - "node_modules/strict-event-emitter": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", - "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", - "dev": true, - "license": "MIT" - }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -15246,6 +15369,33 @@ } } }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, "node_modules/vitest/node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", diff --git a/package.json b/package.json index 3c46b39..7932da9 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "husky": "^9.0.11", "lerna": "^10.0.1", "lint-staged": "^15.2.2", - "msw": "2.15.0", + "msw": "3.0.2", "typescript": "^5.6.0", "vite": "^6.2.3", "vite-plugin-dts": "^4.5.0", @@ -43,5 +43,10 @@ "homepage": "https://github.com/uploadcare/ai-image-editor#readme", "bugs": { "url": "https://github.com/uploadcare/ai-image-editor/issues" + }, + "overrides": { + "@vitest/mocker": { + "msw": "$msw" + } } } From 7d3024acf5841f8e6292c0e50e0ec836ac63fbea Mon Sep 17 00:00:00 2001 From: nd0ut Date: Thu, 8 Oct 2026 03:15:36 +0300 Subject: [PATCH 031/104] test(ai-image-editor): serve msw 3's worker script for the e2e project Co-Authored-By: Claude Opus 5.5 --- packages/ai-image-editor/vitest.config.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/ai-image-editor/vitest.config.ts b/packages/ai-image-editor/vitest.config.ts index 471ad33..021fa66 100644 --- a/packages/ai-image-editor/vitest.config.ts +++ b/packages/ai-image-editor/vitest.config.ts @@ -1,4 +1,5 @@ import { playwright } from '@vitest/browser-playwright'; +import { msw } from 'msw/vite'; import { defineConfig } from 'vitest/config'; export default defineConfig({ @@ -15,6 +16,9 @@ export default defineConfig({ }, { extends: true, + // Serves msw 3's /mockServiceWorker.js, which the browser emulator registers, from this repo's msw + // rather than whichever copy @vitest/browser happens to resolve. + plugins: [msw({ mode: 'worker-only' })], test: { name: 'e2e', include: ['tests/**/*.test.{ts,tsx}'], From c26e05ad462865a551c49b127fd68765f842be3e Mon Sep 17 00:00:00 2001 From: nd0ut Date: Thu, 8 Oct 2026 04:16:24 +0300 Subject: [PATCH 032/104] build: move to vite 8 Bump vite from 6.4.2 to 8.3.3 and vitest/@vitest/browser-playwright to 4.1.11. Vitepress keeps its own vite 5. The library output stays what consumers got from vite 6: - build.target pins vite 6's default; vite 7/8 raised it to Chrome 111 / Safari 16.4, which left class static blocks, `?.` and `??=` unlowered. - output.strict and output.comments.legal: Rolldown drops "use strict" from the CJS files and the @lit/react license comment by default. - cssMinify 'esbuild' (esbuild is now a dev dependency): Lightning CSS drops the unprefixed backdrop-filter and rewrites light-dark() into vars a host page's color-scheme cannot flip. rollupOptions becomes rolldownOptions, __dirname becomes import.meta.dirname, and the root engines field follows vite 8's Node ^20.19.0 || >=22.12.0. Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 1119 +++++++++++++---- package.json | 9 +- packages/ai-image-editor/package.json | 2 +- packages/ai-image-editor/vite.config.ts | 36 +- packages/react-ai-image-editor/vite.config.ts | 13 +- 5 files changed, 905 insertions(+), 274 deletions(-) diff --git a/package-lock.json b/package-lock.json index 605152d..ad8ec9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,19 +15,20 @@ "devDependencies": { "@biomejs/biome": "2.2.6", "@mswjs/interceptors": "0.45.7", - "@vitest/browser-playwright": "^4.1.10", + "@vitest/browser-playwright": "^4.1.11", + "esbuild": "^0.28.2", "happy-dom": "^20.9.0", "husky": "^9.0.11", "lerna": "^10.0.1", "lint-staged": "^15.2.2", "msw": "3.0.2", "typescript": "^5.6.0", - "vite": "^6.2.3", + "vite": "^8.3.3", "vite-plugin-dts": "^4.5.0", - "vitest": "^4.1.10" + "vitest": "^4.1.11" }, "engines": { - "node": ">=20" + "node": "^20.19.0 || >=22.12.0" } }, "../uc-586-sync/packages/api-emulator": { @@ -753,9 +754,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", "cpu": [ "ppc64" ], @@ -770,9 +771,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", "cpu": [ "arm" ], @@ -787,9 +788,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", "cpu": [ "arm64" ], @@ -804,9 +805,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", "cpu": [ "x64" ], @@ -821,9 +822,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", "cpu": [ "arm64" ], @@ -838,9 +839,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", "cpu": [ "x64" ], @@ -855,9 +856,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", "cpu": [ "arm64" ], @@ -872,9 +873,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", "cpu": [ "x64" ], @@ -889,9 +890,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", "cpu": [ "arm" ], @@ -906,9 +907,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", "cpu": [ "arm64" ], @@ -923,9 +924,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", "cpu": [ "ia32" ], @@ -940,9 +941,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", "cpu": [ "loong64" ], @@ -957,9 +958,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", "cpu": [ "mips64el" ], @@ -974,9 +975,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", "cpu": [ "ppc64" ], @@ -991,9 +992,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", "cpu": [ "riscv64" ], @@ -1008,9 +1009,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", "cpu": [ "s390x" ], @@ -1025,9 +1026,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", "cpu": [ "x64" ], @@ -1042,9 +1043,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", "cpu": [ "arm64" ], @@ -1059,9 +1060,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", "cpu": [ "x64" ], @@ -1076,9 +1077,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", "cpu": [ "arm64" ], @@ -1093,9 +1094,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", "cpu": [ "x64" ], @@ -1110,9 +1111,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", "cpu": [ "arm64" ], @@ -1127,9 +1128,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", "cpu": [ "x64" ], @@ -1144,9 +1145,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", "cpu": [ "arm64" ], @@ -1161,9 +1162,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", "cpu": [ "ia32" ], @@ -1178,9 +1179,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", "cpu": [ "x64" ], @@ -2821,6 +2822,16 @@ "url": "https://github.com/sponsors/kettanaito" } }, + "node_modules/@oxc-project/types": { + "version": "0.153.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", + "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, "node_modules/@oxc-resolver/binding-android-arm-eabi": { "version": "11.21.3", "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.21.3.tgz", @@ -3163,6 +3174,286 @@ "dev": true, "license": "MIT" }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", + "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", + "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", + "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", + "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", + "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", + "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", + "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", + "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", + "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", + "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", + "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", + "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", + "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", + "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", + "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/pluginutils": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", @@ -4387,30 +4678,16 @@ "node": ">=16" } }, - "node_modules/@vitejs/plugin-vue": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", - "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0", - "vue": "^3.2.25" - } - }, "node_modules/@vitest/browser": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.10.tgz", - "integrity": "sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.11.tgz", + "integrity": "sha512-bwMovvAeuTFOK5kIFevw4VEf+1gVEICv4SYK4k3knJOxl6b1zEWud8mYKD73e1B0odAn174h1MofURy2TPWf3w==", "dev": true, "license": "MIT", "dependencies": { "@blazediff/core": "1.9.1", - "@vitest/mocker": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/mocker": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", @@ -4421,18 +4698,18 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "4.1.10" + "vitest": "4.1.11" } }, "node_modules/@vitest/browser-playwright": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.10.tgz", - "integrity": "sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.11.tgz", + "integrity": "sha512-riLBxPqwnJ0lWs2DN2WeUfYeKLoAjbP2Xx8cLQdSddzMi20sksIa6K2mPz79DyMZKKVKH2ksOC2yJvtNcZg8cg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/browser": "4.1.10", - "@vitest/mocker": "4.1.10", + "@vitest/browser": "4.1.11", + "@vitest/mocker": "4.1.11", "tinyrainbow": "^3.1.0" }, "funding": { @@ -4440,7 +4717,7 @@ }, "peerDependencies": { "playwright": "*", - "vitest": "4.1.10" + "vitest": "4.1.11" }, "peerDependenciesMeta": { "playwright": { @@ -4449,13 +4726,13 @@ } }, "node_modules/@vitest/browser-playwright/node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.10", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -4476,13 +4753,13 @@ } }, "node_modules/@vitest/browser/node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.10", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -4503,16 +4780,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -4521,9 +4798,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -4534,13 +4811,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.10", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -4548,14 +4825,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -4564,9 +4841,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -4574,13 +4851,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -5735,9 +6012,9 @@ } }, "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", "engines": { @@ -6558,6 +6835,16 @@ "node": ">=6" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -6823,9 +7110,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", - "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT" }, @@ -6857,9 +7144,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -6870,32 +7157,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, "node_modules/escalade": { @@ -6960,9 +7247,9 @@ } }, "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -8543,6 +8830,279 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -11095,9 +11655,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "dev": true, "funding": [ { @@ -11697,9 +12257,9 @@ } }, "node_modules/obug": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", - "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -12438,9 +12998,9 @@ } }, "node_modules/postcss": { - "version": "8.5.25", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", - "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", "dev": true, "funding": [ { @@ -12458,9 +13018,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.19", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -13222,6 +13782,40 @@ "dev": true, "license": "MIT" }, + "node_modules/rolldown": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", + "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.153.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.13", + "@rolldown/binding-android-arm64": "1.2.13", + "@rolldown/binding-darwin-arm64": "1.2.13", + "@rolldown/binding-darwin-x64": "1.2.13", + "@rolldown/binding-freebsd-x64": "1.2.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", + "@rolldown/binding-linux-arm64-gnu": "1.2.13", + "@rolldown/binding-linux-arm64-musl": "1.2.13", + "@rolldown/binding-linux-ppc64-gnu": "1.2.13", + "@rolldown/binding-linux-s390x-gnu": "1.2.13", + "@rolldown/binding-linux-x64-gnu": "1.2.13", + "@rolldown/binding-linux-x64-musl": "1.2.13", + "@rolldown/binding-openharmony-arm64": "1.2.13", + "@rolldown/binding-win32-arm64-msvc": "1.2.13", + "@rolldown/binding-win32-x64-msvc": "1.2.13" + } + }, "node_modules/rollup": { "version": "4.60.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", @@ -13676,9 +14270,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -13770,9 +14364,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", - "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", "dev": true, "license": "MIT" }, @@ -13960,9 +14554,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, "license": "MIT", "engines": { @@ -14018,9 +14612,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", "dev": true, "license": "MIT", "engines": { @@ -14587,24 +15181,23 @@ } }, "node_modules/vite": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", - "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.3.tgz", + "integrity": "sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.11", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -14613,14 +15206,15 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" @@ -14629,13 +15223,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { "optional": true }, - "lightningcss": { + "jiti": { + "optional": true + }, + "less": { "optional": true }, "sass": { @@ -14707,9 +15304,9 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -14720,9 +15317,9 @@ } }, "node_modules/vite/node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -15180,6 +15777,20 @@ "@types/hast": "^3.0.4" } }, + "node_modules/vitepress/node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, "node_modules/vitepress/node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -15280,19 +15891,19 @@ } }, "node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -15320,12 +15931,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -15370,13 +15981,13 @@ } }, "node_modules/vitest/node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.10", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -15415,9 +16026,9 @@ } }, "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -15428,9 +16039,9 @@ } }, "node_modules/vitest/node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -15826,7 +16437,7 @@ "@custom-elements-manifest/to-markdown": "^0.1.0", "@uploadcare/api-emulator": "file:/Users/nd0ut/workspace/uc-586-sync/packages/api-emulator", "@uploadcare/file-uploader": "1.34.0", - "@vitest/browser-playwright": "^4.1.10", + "@vitest/browser-playwright": "^4.1.11", "lit-analyzer": "^2.0.3", "playwright": "^1.49.0", "typedoc": "^0.28.19", diff --git a/package.json b/package.json index 7932da9..2353268 100644 --- a/package.json +++ b/package.json @@ -20,19 +20,20 @@ "devDependencies": { "@biomejs/biome": "2.2.6", "@mswjs/interceptors": "0.45.7", - "@vitest/browser-playwright": "^4.1.10", + "@vitest/browser-playwright": "^4.1.11", + "esbuild": "^0.28.2", "happy-dom": "^20.9.0", "husky": "^9.0.11", "lerna": "^10.0.1", "lint-staged": "^15.2.2", "msw": "3.0.2", "typescript": "^5.6.0", - "vite": "^6.2.3", + "vite": "^8.3.3", "vite-plugin-dts": "^4.5.0", - "vitest": "^4.1.10" + "vitest": "^4.1.11" }, "engines": { - "node": ">=20" + "node": "^20.19.0 || >=22.12.0" }, "license": "MIT", "author": "Uploadcare", diff --git a/packages/ai-image-editor/package.json b/packages/ai-image-editor/package.json index 8d8c1fd..338a104 100644 --- a/packages/ai-image-editor/package.json +++ b/packages/ai-image-editor/package.json @@ -62,7 +62,7 @@ "@custom-elements-manifest/to-markdown": "^0.1.0", "@uploadcare/api-emulator": "file:/Users/nd0ut/workspace/uc-586-sync/packages/api-emulator", "@uploadcare/file-uploader": "1.34.0", - "@vitest/browser-playwright": "^4.1.10", + "@vitest/browser-playwright": "^4.1.11", "lit-analyzer": "^2.0.3", "playwright": "^1.49.0", "typedoc": "^0.28.19", diff --git a/packages/ai-image-editor/vite.config.ts b/packages/ai-image-editor/vite.config.ts index a88bc7a..52bd135 100644 --- a/packages/ai-image-editor/vite.config.ts +++ b/packages/ai-image-editor/vite.config.ts @@ -2,10 +2,16 @@ import { resolve } from 'node:path'; import { defineConfig } from 'vite'; import dts from 'vite-plugin-dts'; +// Vite 8's default CSS minifier, Lightning CSS, drops the unprefixed `backdrop-filter` when +// `-webkit-backdrop-filter` follows it (no blur in Chrome/Firefox) and rewrites `light-dark()` +// into vars that only flip on a `color-scheme` it compiled itself, so a host page's +// `color-scheme` stops reaching the editor. esbuild keeps the CSS as Vite 6 shipped it. +const cssMinify = 'esbuild'; + export default defineConfig(({ command, mode }) => { if (command === 'serve') { return { - root: resolve(__dirname, 'demo'), + root: resolve(import.meta.dirname, 'demo'), server: { open: '/standalone.html' }, }; } @@ -13,18 +19,19 @@ export default defineConfig(({ command, mode }) => { // Static demo site build: vite build --mode demo if (mode === 'demo') { return { - root: resolve(__dirname, 'demo'), + root: resolve(import.meta.dirname, 'demo'), build: { - outDir: resolve(__dirname, 'dist-demo'), + outDir: resolve(import.meta.dirname, 'dist-demo'), emptyOutDir: true, - rollupOptions: { + cssMinify, + rolldownOptions: { // shimmer-lab.html is deliberately absent: it is a renderer-tuning // harness, so it stays a `vite dev` page (which serves any HTML under // the demo root) rather than shipping to the published playground. input: { - index: resolve(__dirname, 'demo/index.html'), - standalone: resolve(__dirname, 'demo/standalone.html'), - plugin: resolve(__dirname, 'demo/plugin.html'), + index: resolve(import.meta.dirname, 'demo/index.html'), + standalone: resolve(import.meta.dirname, 'demo/standalone.html'), + plugin: resolve(import.meta.dirname, 'demo/plugin.html'), }, }, }, @@ -33,12 +40,16 @@ export default defineConfig(({ command, mode }) => { return { build: { + // Vite 6's default ('modules'), pinned: Vite 7+ raised it to Safari 16.4 / Chrome 111, + // which would ship unlowered syntax (class static blocks, `??=`) to consumers. + target: ['es2020', 'edge88', 'firefox78', 'chrome87', 'safari14'], cssCodeSplit: false, + cssMinify, lib: { entry: { - 'ai-image-editor': resolve(__dirname, 'src/index.ts'), - plugin: resolve(__dirname, 'src/plugin.ts'), - errors: resolve(__dirname, 'src/errors.ts'), + 'ai-image-editor': resolve(import.meta.dirname, 'src/index.ts'), + plugin: resolve(import.meta.dirname, 'src/plugin.ts'), + errors: resolve(import.meta.dirname, 'src/errors.ts'), }, name: '@uploadcare/ai-image-editor', formats: ['es', 'cjs'], @@ -46,7 +57,7 @@ export default defineConfig(({ command, mode }) => { // (dist/.js for ESM, dist/.cjs for CommonJS). fileName: (format, entryName) => `${entryName}.${format === 'es' ? 'js' : 'cjs'}`, }, - rollupOptions: { + rolldownOptions: { // Not bundled, so the editor shares one copy with File Uploader: the // plugin gets the uploader's token function, and a second bundled copy // of `AuthTokenResolverError` fails `instanceof` and wraps it again. @@ -58,6 +69,9 @@ export default defineConfig(({ command, mode }) => { /^@uploadcare\/upload-client(\/|$)/, ], output: { + // Rolldown drops both by default; Rollup kept them + strict: true, + comments: { legal: true }, globals: { lit: 'lit', '@uploadcare/file-uploader': 'UC', diff --git a/packages/react-ai-image-editor/vite.config.ts b/packages/react-ai-image-editor/vite.config.ts index eb74ba5..308ba6f 100644 --- a/packages/react-ai-image-editor/vite.config.ts +++ b/packages/react-ai-image-editor/vite.config.ts @@ -5,23 +5,28 @@ import dts from 'vite-plugin-dts'; export default defineConfig(({ command }) => { if (command === 'serve') { return { - root: resolve(__dirname, 'src/demo'), + root: resolve(import.meta.dirname, 'src/demo'), }; } return { build: { + // Vite 6's default ('modules'), pinned: Vite 7+ raised it to Safari 16.4 / Chrome 111 + target: ['es2020', 'edge88', 'firefox78', 'chrome87', 'safari14'], lib: { - entry: resolve(__dirname, 'src/index.ts'), + entry: resolve(import.meta.dirname, 'src/index.ts'), name: '@uploadcare/react-ai-image-editor', formats: ['es', 'cjs'], fileName: 'react-ai-image-editor', }, - rollupOptions: { + rolldownOptions: { // @lit/react is ESM-only (no require condition) — bundle it so the CJS // build doesn't require() an ESM module (breaks on Node < 22.12) external: ['react', '@uploadcare/ai-image-editor', '@uploadcare/ai-image-editor/errors'], output: { - // Rollup strips module-level directives when bundling; Next.js needs + // Rolldown drops "use strict" and legal comments (the @lit/react license) by default; Rollup kept them + strict: true, + comments: { legal: true }, + // Rolldown strips module-level directives when bundling; Next.js needs // 'use client' at the top of the shipped files to mark the client // boundary (no-op elsewhere) banner: "'use client';", From f804313fd10f9aa8ee176408d9e5befededa2929 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Thu, 8 Oct 2026 04:18:14 +0300 Subject: [PATCH 033/104] build: move to vite-plugin-dts 5 v5 renames rollupTypes to bundleTypes and makes @microsoft/api-extractor an optional peer, so it is now a root dev dependency. The bundled .d.ts files are byte-identical to the v4 output. Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 443 +++++++++++------- package.json | 3 +- packages/ai-image-editor/vite.config.ts | 2 +- packages/react-ai-image-editor/vite.config.ts | 2 +- 4 files changed, 267 insertions(+), 183 deletions(-) diff --git a/package-lock.json b/package-lock.json index ad8ec9a..38daf3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ ], "devDependencies": { "@biomejs/biome": "2.2.6", + "@microsoft/api-extractor": "^7.59.4", "@mswjs/interceptors": "0.45.7", "@vitest/browser-playwright": "^4.1.11", "esbuild": "^0.28.2", @@ -24,7 +25,7 @@ "msw": "3.0.2", "typescript": "^5.6.0", "vite": "^8.3.3", - "vite-plugin-dts": "^4.5.0", + "vite-plugin-dts": "^5.1.2", "vitest": "^4.1.11" }, "engines": { @@ -1646,6 +1647,38 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -1653,6 +1686,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@lit-labs/motion": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@lit-labs/motion/-/motion-1.1.0.tgz", @@ -1698,19 +1742,19 @@ } }, "node_modules/@microsoft/api-extractor": { - "version": "7.58.7", - "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.58.7.tgz", - "integrity": "sha512-yK6OycD46gIzLRpj6ueVUWPk1ACSpkN1LBo05gY1qPTylbWyUCanXfH7+VgkI5LJrJoRSQR5F04XuCffCXLOBw==", + "version": "7.59.4", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.59.4.tgz", + "integrity": "sha512-XqseDKIw736qlhQwS9SOpbY4veOiP2Nc7YiWmgXXBmeA3kY9qGXw+Tu7DFpU9FvXywE1z8SvLJKUVxFpcX0kig==", "dev": true, "license": "MIT", "dependencies": { - "@microsoft/api-extractor-model": "7.33.8", - "@microsoft/tsdoc": "~0.16.0", - "@microsoft/tsdoc-config": "~0.18.1", - "@rushstack/node-core-library": "5.23.1", - "@rushstack/rig-package": "0.7.3", - "@rushstack/terminal": "0.24.0", - "@rushstack/ts-command-line": "5.3.9", + "@microsoft/api-extractor-model": "7.33.15", + "@microsoft/tsdoc": "~0.17.0", + "@microsoft/tsdoc-config": "~0.18.2", + "@rushstack/node-core-library": "5.25.1", + "@rushstack/rig-package": "0.7.4", + "@rushstack/terminal": "0.24.7", + "@rushstack/ts-command-line": "5.3.17", "diff": "~8.0.2", "minimatch": "10.2.3", "resolve": "~1.22.1", @@ -1720,18 +1764,24 @@ }, "bin": { "api-extractor": "bin/api-extractor" + }, + "engines": { + "node": ">=20.9.0" } }, "node_modules/@microsoft/api-extractor-model": { - "version": "7.33.8", - "resolved": "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.33.8.tgz", - "integrity": "sha512-aIcoQggPyer3B6Ze3usz0YWC/oBwUHfRH5ETUsr+oT2BRA6SfTJl7IKPcPZkX4UR+PohowzW4uMxsvjrn8vm+w==", + "version": "7.33.15", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.33.15.tgz", + "integrity": "sha512-UpCnItIJwu03iNTWtJVKv8Qbf0MXN7WLQZUail9cpAQmnZiIX36YPRRoRxz0pocwytzUpmJhstsLkyW5tsaHBA==", "dev": true, "license": "MIT", "dependencies": { - "@microsoft/tsdoc": "~0.16.0", - "@microsoft/tsdoc-config": "~0.18.1", - "@rushstack/node-core-library": "5.23.1" + "@microsoft/tsdoc": "~0.17.0", + "@microsoft/tsdoc-config": "~0.18.2", + "@rushstack/node-core-library": "5.25.1" + }, + "engines": { + "node": ">=20.9.0" } }, "node_modules/@microsoft/api-extractor/node_modules/balanced-match": { @@ -1787,20 +1837,20 @@ } }, "node_modules/@microsoft/tsdoc": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz", - "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==", + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.17.1.tgz", + "integrity": "sha512-VHyYyyoH63jFoO3shpw9Zfijk5v/9p/VrClW35z0/q6ooK7sSmxpzOkS4iZPxs3vO4XtBW4H38HRAKkAMIK7DA==", "dev": true, "license": "MIT" }, "node_modules/@microsoft/tsdoc-config": { - "version": "0.18.1", - "resolved": "https://registry.npmjs.org/@microsoft/tsdoc-config/-/tsdoc-config-0.18.1.tgz", - "integrity": "sha512-9brPoVdfN9k9g0dcWkFeA7IH9bbcttzDJlXvkf8b2OBzd5MueR1V2wkKBL0abn0otvmkHJC6aapBOTJDDeMCZg==", + "version": "0.18.3", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc-config/-/tsdoc-config-0.18.3.tgz", + "integrity": "sha512-0I0rGXo1eJziw3EEErkDS9p/0RzBcyOiV+zbSDqOZTeURmhtut+WIvpih0LM5g+ZpZ9SyFxDfkhAcl/r8e2zGw==", "dev": true, "license": "MIT", "dependencies": { - "@microsoft/tsdoc": "0.16.0", + "@microsoft/tsdoc": "0.17.1", "ajv": "~8.18.0", "jju": "~1.4.0", "resolve": "~1.22.2" @@ -3455,9 +3505,9 @@ "license": "MIT" }, "node_modules/@rollup/pluginutils": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", - "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", "dev": true, "license": "MIT", "dependencies": { @@ -3485,9 +3535,9 @@ "license": "MIT" }, "node_modules/@rollup/pluginutils/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -3848,13 +3898,13 @@ ] }, "node_modules/@rushstack/node-core-library": { - "version": "5.23.1", - "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-5.23.1.tgz", - "integrity": "sha512-wlKmIKIYCKuCASbITvOxLZXepPbwXvrv7S6ig6XNWFchSyhL/E2txmVXspHY49Wu2dzf7nI27a2k/yV5BA3EiA==", + "version": "5.25.1", + "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-5.25.1.tgz", + "integrity": "sha512-ozoRDwIx3jStOYpx86rFyXH4SaUR+DVT6HubQ59avLplJkWXSm5InztYH7RrOzzq9PJXTdq9/+lKX06SfkLVsA==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "~8.18.0", + "ajv": "~8.20.0", "ajv-draft-04": "~1.0.0", "ajv-formats": "~3.0.1", "fs-extra": "~11.3.0", @@ -3863,6 +3913,9 @@ "resolve": "~1.22.1", "semver": "~7.7.4" }, + "engines": { + "node": ">=20.9.0" + }, "peerDependencies": { "@types/node": "*" }, @@ -3872,6 +3925,23 @@ } } }, + "node_modules/@rushstack/node-core-library/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/@rushstack/node-core-library/node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -3901,27 +3971,33 @@ } }, "node_modules/@rushstack/rig-package": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@rushstack/rig-package/-/rig-package-0.7.3.tgz", - "integrity": "sha512-aAA518n6wxxjCfnTAOjQnm7ngNE0FVHxHAw2pxKlIhxrMn0XQjGcXKF0oKWpjBgJOmsaJpVob/v+zr3zxgPWuA==", + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@rushstack/rig-package/-/rig-package-0.7.4.tgz", + "integrity": "sha512-SHZdzc9b2ptUb5uvRWALtAceD/QKZ4UAnZjcaKviTMPmHBnW14gs7XDocn6sDv2EqgnxxFPFkpV7/I3cUijCbw==", "dev": true, "license": "MIT", "dependencies": { "jju": "~1.4.0", "resolve": "~1.22.1" + }, + "engines": { + "node": ">=20.9.0" } }, "node_modules/@rushstack/terminal": { - "version": "0.24.0", - "resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.24.0.tgz", - "integrity": "sha512-8ZQS4MMaGsv27EXCBiH7WMPkRZrffeDoIevs6z9TM5dzqiY6+Hn4evfK/G+gvgBTjfvfkHIZPQQmalmI2sM4TQ==", + "version": "0.24.7", + "resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.24.7.tgz", + "integrity": "sha512-o8J9nHOIwfYb0s0eG2WYhx7ed3YIvBXQHVFxYdXCmVdBOkTkdArJcgVzRS8ewUepmlic4nGgJPjKIVaNKSqEnA==", "dev": true, "license": "MIT", "dependencies": { - "@rushstack/node-core-library": "5.23.1", + "@rushstack/node-core-library": "5.25.1", "@rushstack/problem-matcher": "0.2.1", "supports-color": "~8.1.1" }, + "engines": { + "node": ">=20.9.0" + }, "peerDependencies": { "@types/node": "*" }, @@ -3948,16 +4024,19 @@ } }, "node_modules/@rushstack/ts-command-line": { - "version": "5.3.9", - "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-5.3.9.tgz", - "integrity": "sha512-GIHqU+sRGQ3LGWAZu1O+9Yh++qwtyNIIGuNbcWHJjBTm2qRez0cwINUHZ+pQLR8UuzZDcMajrDaNbUYoaL/XtQ==", + "version": "5.3.17", + "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-5.3.17.tgz", + "integrity": "sha512-pIwhWawZXAT0nTXwvcE/InQ7uaVAgSeIzILMb2mBrI01Hz1yEvxrhV2+DE2Fzux8aZY9OJTdLsI+sk9kZDuAJw==", "dev": true, "license": "MIT", "dependencies": { - "@rushstack/terminal": "0.24.0", + "@rushstack/terminal": "0.24.7", "@types/argparse": "1.0.38", "argparse": "~1.0.9", "string-argv": "~0.3.1" + }, + "engines": { + "node": ">=20.9.0" } }, "node_modules/@rushstack/ts-command-line/node_modules/argparse": { @@ -4969,17 +5048,6 @@ "@vue/shared": "3.5.38" } }, - "node_modules/@vue/compiler-vue2": { - "version": "2.7.16", - "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", - "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", - "dev": true, - "license": "MIT", - "dependencies": { - "de-indent": "^1.0.2", - "he": "^1.2.0" - } - }, "node_modules/@vue/devtools-api": { "version": "7.7.9", "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", @@ -5016,57 +5084,6 @@ "rfdc": "^1.4.1" } }, - "node_modules/@vue/language-core": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.0.tgz", - "integrity": "sha512-O1ZZFaaBGkKbsRfnVH1ifOK1/1BUkyK+3SQsfnh6PmMmD4qJcTU8godCeA96jjDRTL6zgnK7YzCHfaUlH2r0Mw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@volar/language-core": "~2.4.11", - "@vue/compiler-dom": "^3.5.0", - "@vue/compiler-vue2": "^2.7.16", - "@vue/shared": "^3.5.0", - "alien-signals": "^0.4.9", - "minimatch": "^9.0.3", - "muggle-string": "^0.4.1", - "path-browserify": "^1.0.1" - }, - "peerDependencies": { - "typescript": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@vue/language-core/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@vue/language-core/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@vue/reactivity": { "version": "3.5.38", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.38.tgz", @@ -5425,9 +5442,9 @@ } }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", "dev": true, "license": "MIT", "bin": { @@ -5537,13 +5554,6 @@ "node": ">= 14.0.0" } }, - "node_modules/alien-signals": { - "version": "0.4.14", - "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-0.4.14.tgz", - "integrity": "sha512-itUAVzhczTmP2U5yX67xVpsbbOiquusbWVyA9N+sy6+r6YVbFkahXvNCeEPWEOMhwDYwbVbGHFkVL03N9I5g+Q==", - "dev": true, - "license": "MIT" - }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -6445,9 +6455,9 @@ "license": "MIT" }, "node_modules/confbox": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", - "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.1.tgz", + "integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==", "dev": true, "license": "MIT" }, @@ -6692,13 +6702,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/de-indent": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", - "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", - "dev": true, - "license": "MIT" - }, "node_modules/debounce": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", @@ -7264,9 +7267,9 @@ "license": "Apache-2.0" }, "node_modules/exsolve": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", - "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.3.tgz", + "integrity": "sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA==", "dev": true, "license": "MIT" }, @@ -7332,9 +7335,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "dev": true, "funding": [ { @@ -7702,6 +7705,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/glob/node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -7924,16 +7934,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "dev": true, - "license": "MIT", - "bin": { - "he": "bin/he" - } - }, "node_modules/headers-polyfill": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz", @@ -8345,13 +8345,13 @@ } }, "node_modules/is-core-module": { - "version": "2.16.2", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", - "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", + "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", "dev": true, "license": "MIT", "dependencies": { - "hasown": "^2.0.3" + "hasown": "^2.0.4" }, "engines": { "node": ">= 0.4" @@ -11637,13 +11637,6 @@ "node": "^20.19.0 || ^22.12.0 || >=23" } }, - "node_modules/muggle-string": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", - "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", - "dev": true, - "license": "MIT" - }, "node_modules/mute-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", @@ -12929,14 +12922,14 @@ } }, "node_modules/pkg-types": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", - "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.3.tgz", + "integrity": "sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==", "dev": true, "license": "MIT", "dependencies": { - "confbox": "^0.2.4", - "exsolve": "^1.0.8", + "confbox": "^0.3.1", + "exsolve": "^1.1.1", "pathe": "^2.0.3" } }, @@ -13637,14 +13630,14 @@ } }, "node_modules/resolve": { - "version": "1.22.12", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", - "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "version": "1.22.13", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.13.tgz", + "integrity": "sha512-Dj/cW9zBV2aRj6XzCxuGIs3ESZKv02jixne3q6jFZYZ3/Tg09gHHviSMDL4kObh9pqSahdRHEnDoaP2gn8YLhA==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", + "is-core-module": "^2.17.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -15053,6 +15046,90 @@ "node": ">= 10.0.0" } }, + "node_modules/unplugin": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "acorn": "^8.15.0", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/unplugin-dts": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/unplugin-dts/-/unplugin-dts-1.1.2.tgz", + "integrity": "sha512-RDqy90XUmFj6rz4U5YoTMdYdaCJe2ZnQZaWIjkWS4952Gv0zBTQyqWtlGgVMPYHa982dURvyoc+sUOFIcDFv+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.4", + "@volar/typescript": "^2.4.26", + "compare-versions": "^6.1.1", + "debug": "^4.4.0", + "glob-to-regexp": "0.4.1", + "kolorist": "^1.8.0", + "local-pkg": "^1.1.1", + "magic-string": "^0.30.17", + "unplugin": "^2.3.2" + }, + "peerDependencies": { + "@microsoft/api-extractor": ">=7", + "@rspack/core": "^1", + "@vue/language-core": "^3.1.5", + "esbuild": "*", + "rolldown": "*", + "rollup": ">=3", + "typescript": ">=4", + "vite": ">=3", + "webpack": "^4 || ^5" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@rspack/core": { + "optional": true + }, + "@vue/language-core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "rolldown": { + "optional": true + }, + "rollup": { + "optional": true + }, + "vite": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/unplugin/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/until-async": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", @@ -15259,27 +15336,26 @@ } }, "node_modules/vite-plugin-dts": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/vite-plugin-dts/-/vite-plugin-dts-4.5.4.tgz", - "integrity": "sha512-d4sOM8M/8z7vRXHHq/ebbblfaxENjogAAekcfcDCCwAyvGqnPrc7f4NZbvItS+g4WTgerW0xDwSz5qz11JT3vg==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/vite-plugin-dts/-/vite-plugin-dts-5.1.2.tgz", + "integrity": "sha512-1bXUz836sWq6ewEMQre3ZtNFVfZk/92DppgCCKP8tWx/NWwV2wHuXlRHb2TztHjbikPxvyAGLGu7hVh94Plwww==", "dev": true, "license": "MIT", "dependencies": { - "@microsoft/api-extractor": "^7.50.1", - "@rollup/pluginutils": "^5.1.4", - "@volar/typescript": "^2.4.11", - "@vue/language-core": "2.2.0", - "compare-versions": "^6.1.1", - "debug": "^4.4.0", - "kolorist": "^1.8.0", - "local-pkg": "^1.0.0", - "magic-string": "^0.30.17" + "unplugin-dts": "1.1.2" }, "peerDependencies": { - "typescript": "*", - "vite": "*" + "@microsoft/api-extractor": ">=7", + "rollup": ">=3", + "vite": ">=3" }, "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "rollup": { + "optional": true + }, "vite": { "optional": true } @@ -16117,9 +16193,9 @@ "license": "MIT" }, "node_modules/vscode-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", - "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz", + "integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==", "dev": true, "license": "MIT" }, @@ -16196,6 +16272,13 @@ "node": ">=14.17" } }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + }, "node_modules/whatwg-mimetype": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", diff --git a/package.json b/package.json index 2353268..04f4c2f 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ ], "devDependencies": { "@biomejs/biome": "2.2.6", + "@microsoft/api-extractor": "^7.59.4", "@mswjs/interceptors": "0.45.7", "@vitest/browser-playwright": "^4.1.11", "esbuild": "^0.28.2", @@ -29,7 +30,7 @@ "msw": "3.0.2", "typescript": "^5.6.0", "vite": "^8.3.3", - "vite-plugin-dts": "^4.5.0", + "vite-plugin-dts": "^5.1.2", "vitest": "^4.1.11" }, "engines": { diff --git a/packages/ai-image-editor/vite.config.ts b/packages/ai-image-editor/vite.config.ts index 52bd135..041668a 100644 --- a/packages/ai-image-editor/vite.config.ts +++ b/packages/ai-image-editor/vite.config.ts @@ -79,6 +79,6 @@ export default defineConfig(({ command, mode }) => { }, }, }, - plugins: [dts({ rollupTypes: true, insertTypesEntry: true, exclude: ['**/*.dev.ts', '**/*.test.ts'] })], + plugins: [dts({ bundleTypes: true, insertTypesEntry: true, exclude: ['**/*.dev.ts', '**/*.test.ts'] })], }; }); diff --git a/packages/react-ai-image-editor/vite.config.ts b/packages/react-ai-image-editor/vite.config.ts index 308ba6f..77d22ae 100644 --- a/packages/react-ai-image-editor/vite.config.ts +++ b/packages/react-ai-image-editor/vite.config.ts @@ -36,6 +36,6 @@ export default defineConfig(({ command }) => { }, }, }, - plugins: [dts({ rollupTypes: true, insertTypesEntry: true })], + plugins: [dts({ bundleTypes: true, insertTypesEntry: true })], }; }); From 033b7b4e8dcd3b02e8b733fcc5157cdaf1bbf0b6 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Thu, 8 Oct 2026 20:03:16 +0300 Subject: [PATCH 034/104] test(ai-image-editor): prove authToken wiring and caching through a signed project The authToken e2e tests run against the emulator's signedUploads preset, so a run that finishes proves the editor signed every derivative request, and a run refused with SignatureRequiredError proves it signed none. The caching tests assert on resolver/provider call counts and on that success, no longer on the Authorization headers a replaced global fetch recorded. "sends a plain token on every request" is folded into "signs once authToken is set and stops once it is unset". The header's exact value is pinned by the specs: uploadcareApiClient.test.ts ("sends no Authorization header when unset", "sends a plain token as a bearer header") and uploadcareDerivativeApi.test.ts ("carries the token on both network paths"). Co-Authored-By: Claude Opus 5.5 --- .../tests/editor/auth-token.e2e.test.tsx | 104 ++++++++---------- 1 file changed, 47 insertions(+), 57 deletions(-) diff --git a/packages/ai-image-editor/tests/editor/auth-token.e2e.test.tsx b/packages/ai-image-editor/tests/editor/auth-token.e2e.test.tsx index 90d82e6..ab1e8a8 100644 --- a/packages/ai-image-editor/tests/editor/auth-token.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/auth-token.e2e.test.tsx @@ -1,95 +1,89 @@ import { mintAuthToken } from '@uploadcare/api-emulator'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { session } from '../emulator'; import type { UcAiImageEditorType } from './harness'; -import { clickSend, mount, recordRequests, STAGING, typePrompt } from './harness'; +import { clickSend, historyEl, mount, STAGING, typePrompt } from './harness'; /** - * The emulator checks every Bearer token it gets, so the tokens here are real - * ones it accepts, and a run that finishes is itself proof the token was good. + * The project here has Signed Uploads on, so the emulator refuses every + * derivative request (the POST and each status poll) that carries no valid + * Bearer token: a run that finishes is itself proof every request was signed. + * What the header looks like is pinned by the `uploadcareApiClient` and + * `uploadcareDerivativeApi` specs. */ describe(' authToken', () => { + beforeEach(() => { + // derivativesInstant again, so it wraps the signing gate like it did the plain routes. + session.use('signedUploads').use('derivativesInstant'); + }); + /** - * Runs one generation and waits for its finished result, counted rather than - * observed through the canvas: after the first run the canvas already holds - * the result URL, so waiting on it would return immediately and the second - * generation would never be awaited. + * Runs one generation and waits for it to land in the history, counted rather + * than observed through the canvas: after the first run the canvas already + * holds a result URL, so waiting on it would return immediately and the + * second generation would never be awaited. */ - const generate = async (el: UcAiImageEditorType, prompt: string, results: string[]) => { - const before = results.length; + const generate = async (el: UcAiImageEditorType, prompt: string) => { + const before = historyEl(el)?.entries.length ?? 0; typePrompt(el, prompt); await el.updateComplete; clickSend(el); - // A finished result, so a status poll the emulator refused fails the test. - await vi.waitFor(() => { - expect(results).toHaveLength(before + 1); - }); + await vi.waitFor(() => expect(historyEl(el)?.entries).toHaveLength(before + 1)); + await el.updateComplete; + }; + + /** Runs one generation and answers the `uc:error` the signing gate's refusal raises. */ + const generateRefused = async (el: UcAiImageEditorType, prompt: string) => { + const onError = vi.fn(); + el.addEventListener('uc:error', onError); + typePrompt(el, prompt); await el.updateComplete; + clickSend(el); + await vi.waitFor(() => expect(onError).toHaveBeenCalledTimes(1)); + el.removeEventListener('uc:error', onError); + return onError.mock.calls[0]![0].detail.error; }; - it.each([undefined, null, ''])('sends no Authorization header when authToken is %p', async (value) => { + it.each([undefined, null, ''])('signs nothing when authToken is %p', async (value) => { // The common case: a project without signing. The provider gets no token // function, so nothing has to stand in for a missing token. - const { auth, results } = recordRequests(); const el = mount(STAGING); el.authToken = value as unknown as undefined; await el.updateComplete; - await generate(el, 'a tiger', results); - - expect(auth.length).toBeGreaterThan(1); - expect(auth.every((value) => value === null)).toBe(true); + expect((await generateRefused(el, 'a tiger')).code).toBe('SignatureRequiredError'); }); it('signs once authToken is set and stops once it is unset', async () => { - const { auth, results } = recordRequests(); - const token = await mintAuthToken({ tokenId: 'plain' }); const el = mount(STAGING); - el.authToken = token; await el.updateComplete; - await generate(el, 'a tiger', results); - const signed = auth.length; + expect((await generateRefused(el, 'a tiger')).code).toBe('SignatureRequiredError'); - el.authToken = undefined; + el.authToken = await mintAuthToken({ tokenId: 'plain' }); await el.updateComplete; - await generate(el, 'a lion', results); - - expect(auth.slice(0, signed).every((value) => value === `Bearer ${token}`)).toBe(true); - expect(auth.slice(signed).every((value) => value === null)).toBe(true); - }); + await generate(el, 'a lion'); - it('sends a plain token on every request', async () => { - const { auth, results } = recordRequests(); - const token = await mintAuthToken({ tokenId: 'plain' }); - const el = mount(STAGING); - el.authToken = token; + el.authToken = undefined; await el.updateComplete; - - await generate(el, 'a tiger', results); - - expect(auth.length).toBeGreaterThan(1); - expect(auth.every((value) => value === `Bearer ${token}`)).toBe(true); + expect((await generateRefused(el, 'a bear')).code).toBe('SignatureRequiredError'); }); it('calls an authToken function once and reuses the token across requests', async () => { // Standalone, the editor owns the cache: a generate makes several // authenticated requests and must not ask the app for a token each time. - const { auth, results } = recordRequests(); const token = await mintAuthToken({ tokenId: 'fetched' }); const fetchToken = vi.fn(async () => token); const el = mount(STAGING); el.authToken = fetchToken; await el.updateComplete; - await generate(el, 'a tiger', results); + await generate(el, 'a tiger'); - expect(auth.length).toBeGreaterThan(1); - expect(auth.every((value) => value === `Bearer ${token}`)).toBe(true); expect(fetchToken).toHaveBeenCalledTimes(1); }); it('keeps the cached token when the function identity changes', async () => { // A React parent hands over a new closure on every render. - const { auth, results } = recordRequests(); const [firstToken, secondToken] = [ await mintAuthToken({ tokenId: 'first' }), await mintAuthToken({ tokenId: 'second' }), @@ -98,31 +92,30 @@ describe(' authToken', () => { const el = mount(STAGING); el.authToken = first; await el.updateComplete; - await generate(el, 'a tiger', results); + await generate(el, 'a tiger'); const second = vi.fn(async () => secondToken); el.authToken = second; await el.updateComplete; - await generate(el, 'a lion', results); + await generate(el, 'a lion'); + expect(first).toHaveBeenCalledTimes(1); expect(second).not.toHaveBeenCalled(); - expect(auth.every((value) => value === `Bearer ${firstToken}`)).toBe(true); }); it('refetches after invalidateAuthToken()', async () => { // The escape hatch for a sign-out, and the only recovery when a token's // `exp` cannot be read and so never goes stale on its own. - const { results } = recordRequests(); const token = await mintAuthToken({ tokenId: 'fetched' }); const fetchToken = vi.fn(async () => token); const el = mount(STAGING); el.authToken = fetchToken; await el.updateComplete; - await generate(el, 'a tiger', results); + await generate(el, 'a tiger'); expect(fetchToken).toHaveBeenCalledTimes(1); el.invalidateAuthToken(); - await generate(el, 'a lion', results); + await generate(el, 'a lion'); expect(fetchToken).toHaveBeenCalledTimes(2); }); @@ -130,7 +123,6 @@ describe(' authToken', () => { it('does not cache when cacheAuthToken is false', async () => { // What the file-uploader plugin sets: the uploader already caches, so the // editor must resolve through to it rather than hold its own copy. - const { results } = recordRequests(); const token = await mintAuthToken({ tokenId: 'fetched' }); const fetchToken = vi.fn(async () => token); const el = mount(STAGING); @@ -138,7 +130,7 @@ describe(' authToken', () => { el.authToken = fetchToken; await el.updateComplete; - await generate(el, 'a tiger', results); + await generate(el, 'a tiger'); expect(fetchToken.mock.calls.length).toBeGreaterThan(1); }); @@ -146,7 +138,6 @@ describe(' authToken', () => { it('accepts a provider and lets it own the caching', async () => { // What the File Uploader plugin hands over: the uploader's cache, which // the editor must use rather than wrap in a second one. - const { auth, results } = recordRequests(); const token = await mintAuthToken({ tokenId: 'provider' }); const getToken = vi.fn(async () => token); const invalidate = vi.fn(); @@ -154,9 +145,8 @@ describe(' authToken', () => { el.authToken = { getToken, invalidate }; await el.updateComplete; - await generate(el, 'a tiger', results); + await generate(el, 'a tiger'); - expect(auth.every((value) => value === `Bearer ${token}`)).toBe(true); // Called per request: the provider decides what to cache, not the editor. expect(getToken.mock.calls.length).toBeGreaterThan(1); }); From 7bc873cef621b06788b56d0a88d82d6cdd3168ac Mon Sep 17 00:00:00 2001 From: nd0ut Date: Thu, 8 Oct 2026 20:03:16 +0300 Subject: [PATCH 035/104] test(ai-image-editor): assert the produced file's name in the filename tests The emulator stores a derivative result under the requested filename, so the tests read the name off the file uc:done commits instead of the request body. Co-Authored-By: Claude Opus 5.5 --- .../tests/editor/filename.e2e.test.tsx | 44 ++++++++++--------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/packages/ai-image-editor/tests/editor/filename.e2e.test.tsx b/packages/ai-image-editor/tests/editor/filename.e2e.test.tsx index 3f49354..e982351 100644 --- a/packages/ai-image-editor/tests/editor/filename.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/filename.e2e.test.tsx @@ -1,12 +1,29 @@ import type { UploadcareFile } from '@uploadcare/upload-client'; import { DEMO_FILES } from '@uploadcare/api-emulator'; import { describe, expect, it, vi } from 'vitest'; -import { clickSend, editorMode, mount, recordRequests, SAMPLE_UUID, STAGING, typePrompt } from './harness'; +import type { UcAiImageEditorType } from './harness'; +import { clickPrimary, clickSend, editorMode, mount, primaryBtn, SAMPLE_UUID, STAGING, typePrompt } from './harness'; -/** What the result is named: the source's name, a resolver, or a fixed string. */ +/** + * What the result is named: the source's name, a resolver, or a fixed string. + * The emulator stores a result under the name the editor asked for, so the + * committed file's `originalFilename` is the name the run produced. + */ describe(' result filename', () => { + /** Runs the test's one generation, commits it, and answers the committed file's name. */ + const producedFilename = async (el: UcAiImageEditorType, prompt: string) => { + typePrompt(el, prompt); + await el.updateComplete; + clickSend(el); + // The primary commits a result, so it is enabled once the run has one. + await vi.waitFor(() => expect(primaryBtn(el).disabled).toBe(false)); + const onDone = vi.fn(); + el.addEventListener('uc:done', onDone); + clickPrimary(el); + return (onDone.mock.calls[0]![0].detail.file as UploadcareFile).originalFilename; + }; + it('names the result after the source file in edit mode (preserves the original name)', async () => { - const stub = recordRequests(); const el = mount(STAGING); // Inject the source's file info (as the plugin does) — it carries the uuid // (→ edit mode) and its originalFilename is the default output name. @@ -18,16 +35,11 @@ describe(' result filename', () => { await el.updateComplete; expect(editorMode(el)).toBe('edit'); - typePrompt(el, 'add a hat'); - await el.updateComplete; - clickSend(el); - await vi.waitFor(() => expect(stub.generateBodies.length).toBe(1)); // The edit result is named after the source, not the provider's default. - expect(stub.generateBodies[0]!.filename).toBe('holiday-photo.jpg'); + expect(await producedFilename(el, 'add a hat')).toBe('holiday-photo.jpg'); }); it('names the result via the outputFilename resolver (original + counter)', async () => { - const stub = recordRequests(); const el = mount(STAGING); // A seeded image no test has edited, so no persisted lineage → counter starts at 1. el.sourceFileInfo = { @@ -39,24 +51,14 @@ describe(' result filename', () => { el.outputFilename = (original, counter) => `${original ?? 'ai'}-edit-${counter}`; await el.updateComplete; - typePrompt(el, 'add a hat'); - await el.updateComplete; - clickSend(el); - // Waits for the result too, so the run behind the request succeeded. - await vi.waitFor(() => expect(stub.results.length).toBe(1)); - expect(stub.generateBodies[0]!.filename).toBe('cat.png-edit-1'); + expect(await producedFilename(el, 'add a hat')).toBe('cat.png-edit-1'); }); it('uses a static outputFilename string verbatim', async () => { - const stub = recordRequests(); const el = mount(STAGING); el.outputFilename = 'my-art.png'; await el.updateComplete; - typePrompt(el, 'a sunset'); - await el.updateComplete; - clickSend(el); - await vi.waitFor(() => expect(stub.generateBodies.length).toBe(1)); - expect(stub.generateBodies[0]!.filename).toBe('my-art.png'); + expect(await producedFilename(el, 'a sunset')).toBe('my-art.png'); }); }); From 23aa9febca50c3dc7ce2a0d10db53ce8ea21e37f Mon Sep 17 00:00:00 2001 From: nd0ut Date: Thu, 8 Oct 2026 20:03:16 +0300 Subject: [PATCH 036/104] test(ai-image-editor): read the sent aspect ratio from the emulator's request log The emulator ignores the ratio, so the derivative POST body stays the observable; it now comes from session.requests. The generate-mode test picks 3:2 rather than 1:1, which the provider falls back to when no ratio is passed and so hid a dropped ratio. Co-Authored-By: Claude Opus 5.5 --- .../tests/editor/aspect-ratio.e2e.test.tsx | 39 ++++++++++++------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/packages/ai-image-editor/tests/editor/aspect-ratio.e2e.test.tsx b/packages/ai-image-editor/tests/editor/aspect-ratio.e2e.test.tsx index a67d0ab..b2445b1 100644 --- a/packages/ai-image-editor/tests/editor/aspect-ratio.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/aspect-ratio.e2e.test.tsx @@ -1,11 +1,23 @@ import { describe, expect, it, vi } from 'vitest'; -import { clickSend, editorMode, mount, recordRequests, SAMPLE_UUID, STAGING, typePrompt } from './harness'; +import { session } from '../emulator'; +import { clickSend, editorMode, mount, SAMPLE_UUID, STAGING, typePrompt } from './harness'; + +/** + * The body of every derivative POST (generate or edit) the emulator received. The + * emulator ignores the ratio, so the request is the only place it shows. + */ +const derivativeBodies = (): Promise>> => + Promise.all( + session.requests + .filter((request) => request.method === 'POST' && new URL(request.url).pathname.startsWith('/derivative/')) + .map((request) => request.clone().json()), + ); /** The ratio picker: what it offers per mode, what it sends, what it restores. */ describe(' aspect ratio', () => { it('renders the aspect-ratio picker in generate mode (no Auto) and sends the selected ratio', async () => { - const stub = recordRequests(); - const el = mount({ ...STAGING, 'aspect-ratios': '16:9 1:1' }); + // 3:2: neither the picker's default (the first ratio) nor the provider's fallback (1:1). + const el = mount({ ...STAGING, 'aspect-ratios': '16:9 3:2' }); await el.updateComplete; const ratio = el.shadowRoot!.querySelector('uc-ai-aspect-ratio')!; @@ -17,18 +29,18 @@ describe(' aspect ratio', () => { const options = Array.from(ratio.shadowRoot!.querySelectorAll('.option')) as HTMLButtonElement[]; expect(options.length).toBe(2); - // Pick the second option (1:1) before the only generate — sending flips to edit. + // Pick the second option (3:2) before the only generate — sending flips to edit. options[1]!.click(); await el.updateComplete; typePrompt(el, 'mountain'); await el.updateComplete; clickSend(el); - await vi.waitFor(() => expect(stub.generateBodies.length).toBe(1)); - expect(stub.generateBodies[0]!.aspect_ratio).toEqual([1, 1]); + await vi.waitFor(async () => expect(await derivativeBodies()).toHaveLength(1)); + const [body] = await derivativeBodies(); + expect(body!.aspect_ratio).toEqual([3, 2]); }); it('defaults edit mode to "Auto" and omits aspect_ratio (preserving the source AR)', async () => { - const stub = recordRequests(); const el = mount(STAGING); el.sourceUuid = SAMPLE_UUID; await el.updateComplete; @@ -46,10 +58,11 @@ describe(' aspect ratio', () => { typePrompt(el, 'add a hat'); await el.updateComplete; clickSend(el); - await vi.waitFor(() => expect(stub.generateBodies.length).toBe(1)); + await vi.waitFor(async () => expect(await derivativeBodies()).toHaveLength(1)); + const [body] = await derivativeBodies(); // Auto is the default → no aspect_ratio on the wire; backend preserves it. - expect(stub.generateBodies[0]!.aspect_ratio).toBeUndefined(); - expect(stub.generateBodies[0]!.source).toBe(SAMPLE_UUID); + expect(body!.aspect_ratio).toBeUndefined(); + expect(body!.source).toBe(SAMPLE_UUID); }); it('renders the aspect-ratio picker in edit mode too', async () => { @@ -61,7 +74,6 @@ describe(' aspect ratio', () => { }); it('sends an explicit ratio when the user reshapes in edit mode', async () => { - const stub = recordRequests(); const el = mount({ ...STAGING, 'aspect-ratios': '1:1' }); el.sourceUuid = SAMPLE_UUID; await el.updateComplete; @@ -78,8 +90,9 @@ describe(' aspect ratio', () => { typePrompt(el, 'make it square'); await el.updateComplete; clickSend(el); - await vi.waitFor(() => expect(stub.generateBodies.length).toBe(1)); - expect(stub.generateBodies[0]!.aspect_ratio).toEqual([1, 1]); + await vi.waitFor(async () => expect(await derivativeBodies()).toHaveLength(1)); + const [body] = await derivativeBodies(); + expect(body!.aspect_ratio).toEqual([1, 1]); }); it('records the aspect ratio on a history entry and restores it when re-selected', async () => { From f8a3fd4b4f6ff17a376c178c2e105246694f141b Mon Sep 17 00:00:00 2001 From: nd0ut Date: Thu, 8 Oct 2026 20:03:16 +0300 Subject: [PATCH 037/104] test(ai-image-editor): take a run's result uuid from the session's files A generation's result is the one file in the session that isn't a seeded DEMO_FILES image; resultUuid() answers it, in place of the uuids scraped from status frames. Co-Authored-By: Claude Opus 5.5 --- .../tests/editor/generation.e2e.test.tsx | 32 ++++++++----------- .../ai-image-editor/tests/editor/harness.ts | 4 +++ 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx b/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx index 6adfdf1..50869d1 100644 --- a/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx @@ -9,7 +9,7 @@ import { editorMode, mount, primaryBtn, - recordRequests, + resultUuid, SAMPLE_UUID, STAGING, typePrompt, @@ -21,33 +21,30 @@ import { */ describe(' generation', () => { it('auto-enters edit mode after the first successful generation', async () => { - const { results } = recordRequests(); const el = mount(STAGING); await el.updateComplete; expect(editorMode(el)).toBe('generate'); typePrompt(el, 'a tiger'); await el.updateComplete; clickSend(el); - await vi.waitFor(() => expect(canvasUrl(el)).toContain(`https://cdn.example.com/${results[0]}/`)); + await vi.waitFor(() => expect(canvasUrl(el)).toContain(`https://cdn.example.com/${resultUuid()}/`)); await el.updateComplete; expect(editorMode(el)).toBe('edit'); }); it('clears the prompt after a successful generation', async () => { - const { results } = recordRequests(); const el = mount(STAGING); await el.updateComplete; const input = el.shadowRoot!.querySelector('uc-ai-prompt-row')!.shadowRoot!.querySelector('textarea')!; typePrompt(el, 'a tiger'); await el.updateComplete; clickSend(el); - await vi.waitFor(() => expect(canvasUrl(el)).toContain(`https://cdn.example.com/${results[0]}/`)); + await vi.waitFor(() => expect(canvasUrl(el)).toContain(`https://cdn.example.com/${resultUuid()}/`)); await el.updateComplete; expect(input.value).toBe(''); }); it('generates via the send button, then the primary commits the result with uc:done', async () => { - const { results } = recordRequests(); const el = mount(STAGING); await el.updateComplete; @@ -58,7 +55,7 @@ describe(' generation', () => { await el.updateComplete; clickSend(el); await vi.waitFor(() => { - expect(canvasUrl(el)).toContain(`https://cdn.example.com/${results[0]}/`); + expect(canvasUrl(el)).toContain(`https://cdn.example.com/${resultUuid()}/`); }); await el.updateComplete; @@ -69,13 +66,12 @@ describe(' generation', () => { clickPrimary(el); expect(onDone).toHaveBeenCalledTimes(1); const detail = onDone.mock.calls[0]![0].detail; - expect(detail.url).toBe(`https://cdn.example.com/${results[0]}/`); - expect(detail.file.uuid).toBe(results[0]); - expect(detail.file.cdnUrl).toBe(`https://cdn.example.com/${results[0]}/`); + expect(detail.url).toBe(`https://cdn.example.com/${resultUuid()}/`); + expect(detail.file.uuid).toBe(resultUuid()); + expect(detail.file.cdnUrl).toBe(`https://cdn.example.com/${resultUuid()}/`); }); it('includes the UploadcareFile and its uuid in uc:done after a generation', async () => { - const { results } = recordRequests(); const el = mount(STAGING); await el.updateComplete; const onDone = vi.fn(); @@ -84,13 +80,13 @@ describe(' generation', () => { typePrompt(el, 'make it pop'); await el.updateComplete; clickSend(el); - await vi.waitFor(() => expect(canvasUrl(el)).toContain(`https://cdn.example.com/${results[0]}/`)); + await vi.waitFor(() => expect(canvasUrl(el)).toContain(`https://cdn.example.com/${resultUuid()}/`)); await el.updateComplete; clickPrimary(el); expect(onDone).toHaveBeenCalledTimes(1); - expect(onDone.mock.calls[0]![0].detail.file.uuid).toBe(results[0]); - expect(onDone.mock.calls[0]![0].detail.uuid).toBe(results[0]); + expect(onDone.mock.calls[0]![0].detail.file.uuid).toBe(resultUuid()); + expect(onDone.mock.calls[0]![0].detail.uuid).toBe(resultUuid()); }); it('reports a refused run with uc:error and its message, and keeps the prompt', async () => { @@ -118,7 +114,6 @@ describe(' generation', () => { }); it.skip('fires uc:change as the current result appears and clears', async () => { - const { results } = recordRequests(); const el = mount(STAGING); await el.updateComplete; const onChange = vi.fn(); @@ -131,8 +126,8 @@ describe(' generation', () => { expect(onChange).toHaveBeenCalledTimes(1); }); const first = onChange.mock.calls[0]![0].detail; - expect(first.result.url).toBe(`https://cdn.example.com/${results[0]}/`); - expect(first.result.file.uuid).toBe(results[0]); + expect(first.result.url).toBe(`https://cdn.example.com/${resultUuid()}/`); + expect(first.result.file.uuid).toBe(resultUuid()); // Start over clears the current result -> uc:change with null. await el.updateComplete; @@ -179,7 +174,6 @@ describe(' generation', () => { }); it('applies an async secure-delivery resolver to the canvas preview', async () => { - const { results } = recordRequests(); const el = mount(STAGING); el.secureDeliveryProxyUrlResolver = async (url: string) => `https://signed.example/${encodeURIComponent(url)}`; await el.updateComplete; @@ -188,7 +182,7 @@ describe(' generation', () => { clickSend(el); await vi.waitFor(() => expect(canvasUrl(el)).toContain( - `https://signed.example/${encodeURIComponent(`https://cdn.example.com/${results[0]}/`)}`, + `https://signed.example/${encodeURIComponent(`https://cdn.example.com/${resultUuid()}/`)}`, ), ); }); diff --git a/packages/ai-image-editor/tests/editor/harness.ts b/packages/ai-image-editor/tests/editor/harness.ts index 0e8eb04..42de053 100644 --- a/packages/ai-image-editor/tests/editor/harness.ts +++ b/packages/ai-image-editor/tests/editor/harness.ts @@ -95,6 +95,10 @@ export const STAGING = { pubkey: 'demopublickey', 'cdn-cname': 'https://cdn.exam /** An image every fresh emulator session already holds. */ export const SAMPLE_UUID = DEMO_FILES[0]; +/** The file the test's run produced: the one in the session that isn't a seeded `DEMO_FILES` image. */ +export const resultUuid = (): string | undefined => + [...session.files.keys()].find((uuid) => !DEMO_FILES.includes(uuid)); + export function typePrompt(el: UcAiImageEditorType, value: string): void { const input = el.shadowRoot!.querySelector('uc-ai-prompt-row')!.shadowRoot!.querySelector('textarea')!; input.value = value; From 6b7d4f8c8f992cfe9acf78ed1569abace109eef0 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Thu, 8 Oct 2026 20:05:56 +0300 Subject: [PATCH 038/104] test(ai-image-editor): drop the fetch-replacing recorder from the e2e harness recordRequests, installFetch and restoreFetch have no users left: the editor tests read what was sent from session.requests and what a run produced from session.files. AGENTS.md says so, and where a test spies on fetch instead. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 6 ++- .../ai-image-editor/tests/editor/harness.ts | 49 ------------------- .../tests/e2e/client.e2e.test.tsx | 2 +- 3 files changed, 6 insertions(+), 51 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a8f89c8..a1f1ae6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,7 +98,11 @@ responses. `tests/emulator.ts` emulates Uploadcare's hosts and `cdn.example.com` (the tests' CDN cname), fails any other Uploadcare host, passes every non-Uploadcare origin through, and resets the session before every test; - import `session` from there to register a scenario or preset. The editor + import `session` from there to register a scenario or preset, or to read + what the editor sent (`requests`) and what a run produced (`files`; the + harness's `resultUuid()` answers the one that isn't a `DEMO_FILES` image). + A test that checks what the editor passed `fetch` itself uses + `vi.spyOn(globalThis, 'fetch')` and restores it after the test. The editor tests apply the `derivativesInstant` preset (`tests/editor/harness.ts`) so a generation finishes on its first poll instead of after the editor's 1.5s interval several times over. diff --git a/packages/ai-image-editor/tests/editor/harness.ts b/packages/ai-image-editor/tests/editor/harness.ts index 42de053..f2445cc 100644 --- a/packages/ai-image-editor/tests/editor/harness.ts +++ b/packages/ai-image-editor/tests/editor/harness.ts @@ -16,67 +16,18 @@ import { cleanup } from '../test-renderer'; export { UcAiImageEditor }; export type { UcAiImageEditorType }; -let restoreFetch: (() => void) | null = null; - /** Generations finish on their first status poll, so a test doesn't sit through the editor's 1.5s interval. */ beforeEach(() => { session.use('derivativesInstant'); }); afterEach(() => { - restoreFetch?.(); - restoreFetch = null; // Persisted history is namespaced by pubkey in localStorage; clear it so a // seeded/recorded lineage in one test can't leak into the next. localStorage.clear(); cleanup(); }); -/** The page's own fetch, which `tests/emulator.ts` answers from the emulator. */ -const realFetch = globalThis.fetch.bind(globalThis); - -/** - * Swap `globalThis.fetch` for the rest of the test. The provider binds - * `globalThis.fetch` at construction, so install it BEFORE setting `pubkey`. - */ -function installFetch(handler: typeof fetch): void { - restoreFetch?.(); - globalThis.fetch = handler; - restoreFetch = () => { - globalThis.fetch = realFetch; - }; -} - -/** - * Record what the editor sends to (and gets back from) the derivative API, which - * the emulator answers. Install BEFORE setting `pubkey`, like `installFetch`. - */ -export function recordRequests(): { - /** Every derivative POST body, in order. */ - generateBodies: Array>; - /** The Authorization header of every request, in order. */ - auth: Array; - /** The uuid of every finished result, in order. */ - results: string[]; -} { - const generateBodies: Array> = []; - const auth: Array = []; - const results: string[] = []; - installFetch(async (input, init) => { - const response = await realFetch(input, init); - // Recorded once answered, so a test waiting on a count waits for the answer too. - auth.push(new Headers(init?.headers).get('Authorization')); - if (init?.method === 'POST') generateBodies.push(JSON.parse(init.body as string)); - const frame = await response - .clone() - .json() - .catch(() => null); - if (frame?.status === 'success' && frame.is_ready) results.push(frame.uuid); - return response; - }); - return { generateBodies, auth, results }; -} - export function mount(attrs: Record = {}): UcAiImageEditorType { const el = document.createElement('uc-ai-image-editor') as UcAiImageEditorType; for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v); diff --git a/packages/react-ai-image-editor/tests/e2e/client.e2e.test.tsx b/packages/react-ai-image-editor/tests/e2e/client.e2e.test.tsx index 9b50abf..1a0b064 100644 --- a/packages/react-ai-image-editor/tests/e2e/client.e2e.test.tsx +++ b/packages/react-ai-image-editor/tests/e2e/client.e2e.test.tsx @@ -8,7 +8,7 @@ import { AiImageEditor, preloadAiImageEditor } from '../../src'; import { setupContainers } from '../support/containers'; // The real element talks to Uploadcare APIs once connected; stub the network -// so tests are hermetic (same approach as packages/ai-image-editor e2e tests). +// so tests are hermetic. const realFetch = globalThis.fetch; beforeAll(() => { globalThis.fetch = (async () => From 31feac6cda2b0910f5d24f94eb24258e355ddef3 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Thu, 8 Oct 2026 20:12:05 +0300 Subject: [PATCH 039/104] test(ai-image-editor): drop the redundant derivativesInstant from the signed auth setup signedUploads is a session setting the routes check, not a scenario, so the harness's derivativesInstant already covers the signed routes. Co-Authored-By: Claude Opus 5.5 --- packages/ai-image-editor/tests/editor/auth-token.e2e.test.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/ai-image-editor/tests/editor/auth-token.e2e.test.tsx b/packages/ai-image-editor/tests/editor/auth-token.e2e.test.tsx index ab1e8a8..fb74466 100644 --- a/packages/ai-image-editor/tests/editor/auth-token.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/auth-token.e2e.test.tsx @@ -13,8 +13,7 @@ import { clickSend, historyEl, mount, STAGING, typePrompt } from './harness'; */ describe(' authToken', () => { beforeEach(() => { - // derivativesInstant again, so it wraps the signing gate like it did the plain routes. - session.use('signedUploads').use('derivativesInstant'); + session.use('signedUploads'); }); /** From 95f6d34f01d78f46855b007ebc39b629662a019e Mon Sep 17 00:00:00 2001 From: nd0ut Date: Thu, 8 Oct 2026 20:12:05 +0300 Subject: [PATCH 040/104] test(ai-image-editor): say why the unset-authToken test expects SignatureRequiredError Co-Authored-By: Claude Opus 5.5 --- .../ai-image-editor/tests/editor/auth-token.e2e.test.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/ai-image-editor/tests/editor/auth-token.e2e.test.tsx b/packages/ai-image-editor/tests/editor/auth-token.e2e.test.tsx index fb74466..0b27a1f 100644 --- a/packages/ai-image-editor/tests/editor/auth-token.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/auth-token.e2e.test.tsx @@ -44,8 +44,9 @@ describe(' authToken', () => { }; it.each([undefined, null, ''])('signs nothing when authToken is %p', async (value) => { - // The common case: a project without signing. The provider gets no token - // function, so nothing has to stand in for a missing token. + // No token function reaches the provider, so no header is sent at all: a + // missing header is SignatureRequiredError, while an empty 'Bearer ' would + // be AccessTokenInvalidError. const el = mount(STAGING); el.authToken = value as unknown as undefined; await el.updateComplete; From 630c5bcffb621680a0f34882f28bfb632973c4b1 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Thu, 8 Oct 2026 20:16:56 +0300 Subject: [PATCH 041/104] chore: keep Next build output out of Biome Biome 2.2.6 overflows its stack on the fixture's generated tests/next-fixture/.next/ after a 'test:next' run, and `npm run lint` then exits 0 having linted nothing past the crash. linter.includes only filters rules; files.includes is what keeps the folder from being scanned at all. Co-Authored-By: Claude Opus 5.5 --- biome.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/biome.json b/biome.json index 54ea4d6..0a151aa 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,8 @@ { "$schema": "https://biomejs.dev/schemas/2.2.6/schema.json", + "files": { + "includes": ["**", "!**/.next"] + }, "assist": { "actions": { "source": { From 6f00f0b3c9a022fdc5cffdf969298dd583e10bde Mon Sep 17 00:00:00 2001 From: nd0ut Date: Thu, 8 Oct 2026 20:19:09 +0300 Subject: [PATCH 042/104] test(react-ai-image-editor): drop the unused fetch stub from the e2e tests The wrapper's e2e tests replaced globalThis.fetch with one answering {} "so tests are hermetic", but nothing in them reaches the network: with fetch made to throw and log, all 8 pass and no call is made. Mounting with a pubkey and dispatching the events by hand never fetches, so the stub only hid what the element would do if that ever changed. Co-Authored-By: Claude Opus 5.5 --- .../tests/e2e/client.e2e.test.tsx | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/packages/react-ai-image-editor/tests/e2e/client.e2e.test.tsx b/packages/react-ai-image-editor/tests/e2e/client.e2e.test.tsx index 1a0b064..eb91029 100644 --- a/packages/react-ai-image-editor/tests/e2e/client.e2e.test.tsx +++ b/packages/react-ai-image-editor/tests/e2e/client.e2e.test.tsx @@ -1,26 +1,12 @@ import type { DoneDetail, UcAiImageEditor } from '@uploadcare/ai-image-editor'; import React from 'react'; import { createRoot, type Root } from 'react-dom/client'; -import { afterEach, beforeAll, expect, it, vi } from 'vitest'; +import { afterEach, expect, it, vi } from 'vitest'; import { AiImageEditorError } from '@uploadcare/ai-image-editor/errors'; import { AiImageEditor, preloadAiImageEditor } from '../../src'; import { setupContainers } from '../support/containers'; -// The real element talks to Uploadcare APIs once connected; stub the network -// so tests are hermetic. -const realFetch = globalThis.fetch; -beforeAll(() => { - globalThis.fetch = (async () => - new Response(JSON.stringify({}), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - })) as typeof fetch; - return () => { - globalThis.fetch = realFetch; - }; -}); - const makeContainer = setupContainers(); const roots: Root[] = []; From 41e61be92bea8c41a56bc4fd20138f579afddb47 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Thu, 8 Oct 2026 20:22:27 +0300 Subject: [PATCH 043/104] test(react-ai-image-editor): run the e2e tests against the in-page emulator The wrapper's e2e tests make no requests today, which is why the fetch stub could go. Nothing would notice if the element started to, though: those requests would reach the real network. Start the emulator from @uploadcare/api-emulator/browser in the e2e project, as the editor package does, with the default unhandled: 'error', so an Uploadcare request is answered here and any other origin fails the request loudly. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 10 +++++++--- package-lock.json | 1 + packages/react-ai-image-editor/package.json | 1 + packages/react-ai-image-editor/tests/e2e/setup.ts | 13 +++++++++++++ packages/react-ai-image-editor/vitest.config.ts | 4 ++++ 5 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 packages/react-ai-image-editor/tests/e2e/setup.ts diff --git a/AGENTS.md b/AGENTS.md index a1f1ae6..9c62a22 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,9 +117,13 @@ responses. a job stuck in `processing` or a poll that hangs is a scenario, not a stub. Stub `fetch` only where it is simpler, and say why next to it. -The dependency is a TEMPORARY `file:` link to an unreleased checkout, so -`npm ci` only resolves it on that machine; swap it for a version once the -package ships. +The React wrapper's e2e project runs against the same in-page emulator +(`packages/react-ai-image-editor/tests/e2e/setup.ts`), so a request the +element starts making there fails loudly instead of reaching the network. + +In both packages the dependency is a TEMPORARY `file:` link to an unreleased +checkout, so `npm ci` only resolves it on that machine; swap it for a version +once the package ships. ## Docs layout diff --git a/package-lock.json b/package-lock.json index 38daf3e..5f6b2b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16548,6 +16548,7 @@ "devDependencies": { "@types/react": "^19", "@types/react-dom": "^19", + "@uploadcare/api-emulator": "file:/Users/nd0ut/workspace/uc-586-sync/packages/api-emulator", "react": "^19", "react-dom": "^19" }, diff --git a/packages/react-ai-image-editor/package.json b/packages/react-ai-image-editor/package.json index 799560a..7f43144 100644 --- a/packages/react-ai-image-editor/package.json +++ b/packages/react-ai-image-editor/package.json @@ -35,6 +35,7 @@ "react": ">= 17.0.0" }, "devDependencies": { + "@uploadcare/api-emulator": "file:/Users/nd0ut/workspace/uc-586-sync/packages/api-emulator", "@types/react": "^19", "@types/react-dom": "^19", "react": "^19", diff --git a/packages/react-ai-image-editor/tests/e2e/setup.ts b/packages/react-ai-image-editor/tests/e2e/setup.ts new file mode 100644 index 0000000..25c5644 --- /dev/null +++ b/packages/react-ai-image-editor/tests/e2e/setup.ts @@ -0,0 +1,13 @@ +import { setupEmulator } from '@uploadcare/api-emulator/browser'; +import { beforeEach } from 'vitest'; + +/** + * The Uploadcare emulator, running in the page. These tests mount the real element and only drive the React + * wrapper, so nothing should reach the network today; if the element starts to, Uploadcare requests are answered + * here and any other origin fails the request loudly instead of going out. + */ +const emulator = setupEmulator(); + +beforeEach(async () => { + await emulator.reset(); +}); diff --git a/packages/react-ai-image-editor/vitest.config.ts b/packages/react-ai-image-editor/vitest.config.ts index fee5959..8bd7de8 100644 --- a/packages/react-ai-image-editor/vitest.config.ts +++ b/packages/react-ai-image-editor/vitest.config.ts @@ -1,4 +1,5 @@ import { playwright } from '@vitest/browser-playwright'; +import { msw } from 'msw/vite'; import { defineConfig } from 'vitest/config'; export default defineConfig({ @@ -40,9 +41,12 @@ export default defineConfig({ }, { extends: true, + // Serves msw 3's /mockServiceWorker.js for the in-page emulator (tests/e2e/setup.ts). + plugins: [msw({ mode: 'worker-only' })], test: { name: 'e2e', include: ['tests/e2e/**/*.test.{ts,tsx}'], + setupFiles: ['tests/e2e/setup.ts'], browser: { provider: playwright(), instances: [{ browser: 'chromium' }], From b6a17cb4f6934766c76f3e3832e0fd554d94796c Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 19:19:35 +0300 Subject: [PATCH 044/104] test: restore spies and stubbed globals before every test Checklist item 23. Both packages' vitest configs now set restoreMocks and unstubGlobals at the root, so every project (specs, e2e, ssr, hydration) undoes vi.spyOn and vi.stubGlobal before the next test instead of relying on each file to remember. The per-file afterEach(vi.restoreAllMocks), try/finally unstubAllGlobals and the plugin test's warn.mockRestore() are dropped as redundant. hydration.test.tsx keeps its mockRestore(): it has to run before root.unmount() so unmount errors are not swallowed. Co-Authored-By: Claude Opus 5.5 --- .../uploadcareApiClient.schemas.dev.test.ts | 6 +---- .../provider/api/uploadcareApiClient.test.ts | 6 +---- .../api/uploadcareDerivativeApi.test.ts | 6 +---- .../src/shared/lib/cdn.test.ts | 24 +++++-------------- .../src/shared/ui/canvas/UcAiCanvas.test.ts | 24 ++++++++----------- .../ui/AiImageEditorPlugin.test.ts | 1 - packages/ai-image-editor/vitest.config.ts | 3 +++ .../react-ai-image-editor/vitest.config.ts | 3 +++ 8 files changed, 25 insertions(+), 48 deletions(-) diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.schemas.dev.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.schemas.dev.test.ts index cbab2ca..1435fd0 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.schemas.dev.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.schemas.dev.test.ts @@ -1,10 +1,6 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { validate } from './uploadcareApiClient.schemas.dev'; -afterEach(() => { - vi.restoreAllMocks(); -}); - describe('uploadcare derivative API dev schema validation', () => { it('stays silent for a well-formed generate request body', () => { const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts index 7e460d7..3336dfa 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts @@ -1,15 +1,11 @@ import { DEMO_FILES, mintAuthToken } from '@uploadcare/api-emulator'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { session } from '../../../../tests/specs/setup'; import { AiProviderError } from '../model/types'; import { UploadcareApiClient } from './uploadcareApiClient'; const PUBLIC_KEY = 'demopublickey'; -afterEach(() => { - vi.restoreAllMocks(); -}); - describe('UploadcareApiClient', () => { it('throws when publicKey is missing', () => { expect(() => new UploadcareApiClient({ publicKey: '' })).toThrow(/publicKey/); diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts index 00c8fcd..53cd7d6 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts @@ -1,6 +1,6 @@ import { DEMO_FILES, mintAuthToken } from '@uploadcare/api-emulator'; import { getPrefixedCdnBaseAsync } from '@uploadcare/cname-prefix/async'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { session } from '../../../../tests/specs/setup'; import { AiProviderError } from '../model/types'; import { UploadcareDerivativeApi } from './uploadcareDerivativeApi'; @@ -13,10 +13,6 @@ const NO_DELAY = { pollIntervalMs: 0 } as const; const neverFinish = () => session.on('GET /derivative/status/', () => Response.json({ type: 'job', status: 'processing' })); -afterEach(() => { - vi.restoreAllMocks(); -}); - describe('UploadcareDerivativeApi', () => { it('throws when publicKey is missing', () => { expect(() => new UploadcareDerivativeApi({ publicKey: '' })).toThrow(/publicKey/); diff --git a/packages/ai-image-editor/src/shared/lib/cdn.test.ts b/packages/ai-image-editor/src/shared/lib/cdn.test.ts index 2a3a1b0..6b477df 100644 --- a/packages/ai-image-editor/src/shared/lib/cdn.test.ts +++ b/packages/ai-image-editor/src/shared/lib/cdn.test.ts @@ -13,31 +13,19 @@ describe('cdn helpers', () => { it('builds a dpr-scaled preview url capped at the CDN limit (resize directive only)', () => { vi.stubGlobal('devicePixelRatio', 2); - try { - // Only the resize directive — the CDN applies format/auto + quality itself. - expect(cdnPreviewUrl(UC_URL, 800)).toBe(`${UC_URL}-/preview/1600x1600/`); - expect(cdnPreviewUrl(UC_URL, 2000)).toContain(`preview/${CDN_MAX_OUTPUT_DIMENSION}x${CDN_MAX_OUTPUT_DIMENSION}`); - } finally { - vi.unstubAllGlobals(); - } + // Only the resize directive — the CDN applies format/auto + quality itself. + expect(cdnPreviewUrl(UC_URL, 800)).toBe(`${UC_URL}-/preview/1600x1600/`); + expect(cdnPreviewUrl(UC_URL, 2000)).toContain(`preview/${CDN_MAX_OUTPUT_DIMENSION}x${CDN_MAX_OUTPUT_DIMENSION}`); }); it('scales the preview by devicePixelRatio', () => { vi.stubGlobal('devicePixelRatio', 1); - try { - expect(cdnPreviewUrl(UC_URL, 800)).toBe(`${UC_URL}-/preview/800x800/`); - } finally { - vi.unstubAllGlobals(); - } + expect(cdnPreviewUrl(UC_URL, 800)).toBe(`${UC_URL}-/preview/800x800/`); }); it('builds a dpr-scaled centered square crop-to-fill thumbnail', () => { vi.stubGlobal('devicePixelRatio', 2); - try { - // scale_crop (not preview) so a non-square source fills the square tile sharply. - expect(cdnSquareThumbUrl(UC_URL, 48)).toBe(`${UC_URL}-/scale_crop/96x96/center/`); - } finally { - vi.unstubAllGlobals(); - } + // scale_crop (not preview) so a non-square source fills the square tile sharply. + expect(cdnSquareThumbUrl(UC_URL, 48)).toBe(`${UC_URL}-/scale_crop/96x96/center/`); }); }); diff --git a/packages/ai-image-editor/src/shared/ui/canvas/UcAiCanvas.test.ts b/packages/ai-image-editor/src/shared/ui/canvas/UcAiCanvas.test.ts index 2cad2ca..e5df919 100644 --- a/packages/ai-image-editor/src/shared/ui/canvas/UcAiCanvas.test.ts +++ b/packages/ai-image-editor/src/shared/ui/canvas/UcAiCanvas.test.ts @@ -273,20 +273,16 @@ describe('UcAiCanvas', () => { } }, ); - try { - const el = await mountWithImage(); - el.fullsizeUrl = 'https://example.com/full.png'; - await el.updateComplete; - - fullscreenBtn(el)!.dispatchEvent(new Event('mouseenter')); - expect(created).toEqual(['https://example.com/full.png']); - - // Hovering again must not refetch the same url. - fullscreenBtn(el)!.dispatchEvent(new Event('mouseenter')); - expect(created).toHaveLength(1); - } finally { - vi.unstubAllGlobals(); - } + const el = await mountWithImage(); + el.fullsizeUrl = 'https://example.com/full.png'; + await el.updateComplete; + + fullscreenBtn(el)!.dispatchEvent(new Event('mouseenter')); + expect(created).toEqual(['https://example.com/full.png']); + + // Hovering again must not refetch the same url. + fullscreenBtn(el)!.dispatchEvent(new Event('mouseenter')); + expect(created).toHaveLength(1); }); it('overlays the fullsize rendition only while fullscreen', async () => { diff --git a/packages/ai-image-editor/src/widgets/ai-image-editor-plugin/ui/AiImageEditorPlugin.test.ts b/packages/ai-image-editor/src/widgets/ai-image-editor-plugin/ui/AiImageEditorPlugin.test.ts index 140722f..7b648ee 100644 --- a/packages/ai-image-editor/src/widgets/ai-image-editor-plugin/ui/AiImageEditorPlugin.test.ts +++ b/packages/ai-image-editor/src/widgets/ai-image-editor-plugin/ui/AiImageEditorPlugin.test.ts @@ -21,7 +21,6 @@ describe('aspectRatiosFromCropPreset', () => { [1, 1], ]); expect(warn).toHaveBeenCalled(); - warn.mockRestore(); }); it('falls back to the popular set when only "free" is configured', () => { diff --git a/packages/ai-image-editor/vitest.config.ts b/packages/ai-image-editor/vitest.config.ts index 021fa66..262551b 100644 --- a/packages/ai-image-editor/vitest.config.ts +++ b/packages/ai-image-editor/vitest.config.ts @@ -4,6 +4,9 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { + // Every spy and stubbed global is undone before the next test, so none leaks into a test that runs after it. + restoreMocks: true, + unstubGlobals: true, projects: [ { extends: true, diff --git a/packages/react-ai-image-editor/vitest.config.ts b/packages/react-ai-image-editor/vitest.config.ts index 8bd7de8..0d89a87 100644 --- a/packages/react-ai-image-editor/vitest.config.ts +++ b/packages/react-ai-image-editor/vitest.config.ts @@ -4,6 +4,9 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { + // Every spy and stubbed global is undone before the next test, so none leaks into a test that runs after it. + restoreMocks: true, + unstubGlobals: true, projects: [ { extends: true, From 014afa62a0a15cebfbabc477e0af692560984b9c Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 19:19:55 +0300 Subject: [PATCH 045/104] test(ai-image-editor): remove the canvas tests' Fullscreen API stub after each test Checklist item 3. The fullscreen-button tests defined document.fullscreenEnabled, document.fullscreenElement and HTMLElement.prototype.requestFullscreen and never removed them, so "is hidden when the Fullscreen API is unsupported" failed whenever it ran after a test that stubbed them (e.g. --sequence.shuffle --sequence.seed=12345). An afterEach now deletes all three; happy-dom has none of them, so deleting restores it. Co-Authored-By: Claude Opus 5.5 --- .../src/shared/ui/canvas/UcAiCanvas.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/ai-image-editor/src/shared/ui/canvas/UcAiCanvas.test.ts b/packages/ai-image-editor/src/shared/ui/canvas/UcAiCanvas.test.ts index e5df919..be08ab6 100644 --- a/packages/ai-image-editor/src/shared/ui/canvas/UcAiCanvas.test.ts +++ b/packages/ai-image-editor/src/shared/ui/canvas/UcAiCanvas.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import './UcAiCanvas'; import type { UcAiCanvas } from './UcAiCanvas'; @@ -228,6 +228,13 @@ describe('UcAiCanvas', () => { return requestFullscreen; } + // happy-dom has none of these, so removing them restores it; a stub left behind would show the button in a later test. + afterEach(() => { + Reflect.deleteProperty(document, 'fullscreenEnabled'); + Reflect.deleteProperty(document, 'fullscreenElement'); + Reflect.deleteProperty(HTMLElement.prototype, 'requestFullscreen'); + }); + async function mountWithImage(): Promise { const el = await mount('https://example.com/a.png'); el.fullscreenLabel = 'View fullscreen'; From 4ef7825a2e53d6b79dc12e0d2921faf1c1d5a18f Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 19:21:03 +0300 Subject: [PATCH 046/104] test(ai-image-editor): keep the secure-delivery e2e preview on the emulated CDN Checklist items 3 and 33. The resolver in "applies an async secure-delivery resolver to the canvas preview" pointed the preview at https://signed.example/..., a proxy that doesn't exist, so every run sent the image requests out to the real network to fail there. It now signs the URL in place (`?token=signed`), which the emulated CDN answers. The assertion checks both the result's CDN URL and the token, and fails when SecureUrlController ignores the resolver. Co-Authored-By: Claude Opus 5.5 --- .../tests/editor/generation.e2e.test.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx b/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx index 50869d1..9cdd154 100644 --- a/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx @@ -175,16 +175,17 @@ describe(' generation', () => { it('applies an async secure-delivery resolver to the canvas preview', async () => { const el = mount(STAGING); - el.secureDeliveryProxyUrlResolver = async (url: string) => `https://signed.example/${encodeURIComponent(url)}`; + // The signed URL stays on the emulated CDN, so the preview loads instead of reaching for a proxy that doesn't exist. + el.secureDeliveryProxyUrlResolver = async (url: string) => `${url}?token=signed`; await el.updateComplete; typePrompt(el, 'a tiger'); await el.updateComplete; clickSend(el); - await vi.waitFor(() => - expect(canvasUrl(el)).toContain( - `https://signed.example/${encodeURIComponent(`https://cdn.example.com/${resultUuid()}/`)}`, - ), - ); + await vi.waitFor(() => { + const url = canvasUrl(el); + expect(url).toContain(`https://cdn.example.com/${resultUuid()}/`); + expect(url).toMatch(/\?token=signed$/); + }); }); it('aborts in-flight generation and shows the new source when source changes', async () => { From 0d9fb56543032e28552d6bcbb7e57b667cdeb2a2 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 19:21:38 +0300 Subject: [PATCH 047/104] test(ai-image-editor): refuse non-Uploadcare origins in the e2e emulator Checklist items 3 and 33. The browser emulator ran with unhandled: 'passthrough', so any request to a third-party origin left the machine. It now uses unhandled: 'error' (the page's own origin is still served). With the previous commit's fake proxy origin still in place, this setting refused its two image requests (logged by the emulator) instead of letting them out; after that commit the full e2e run makes no refused, unemulated or unimplemented request. The uploader's Unsplash from_url import needs no external answer: the emulator serves it without fetching. Co-Authored-By: Claude Opus 5.5 --- packages/ai-image-editor/tests/emulator.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ai-image-editor/tests/emulator.ts b/packages/ai-image-editor/tests/emulator.ts index 3e2d6ce..d91fa58 100644 --- a/packages/ai-image-editor/tests/emulator.ts +++ b/packages/ai-image-editor/tests/emulator.ts @@ -4,10 +4,10 @@ import { beforeEach } from 'vitest'; /** * The Uploadcare emulator, running in the page, for Uploadcare's hosts and `cdn.example.com` (the CDN cname the - * editor tests configure). Any other Uploadcare host fails; every other origin passes through: the uploader tests import an Unsplash image from a URL, - * and the emulator's `from_url` answers for that source without fetching it. + * editor tests configure). Every other origin except the page's own fails, so nothing reaches the real network. The + * uploader tests import an Unsplash image from a URL; the emulator's `from_url` answers for it without fetching it. */ -const emulator = setupEmulator({ cdnHosts: ['cdn.example.com'], unhandled: 'passthrough' }); +const emulator = setupEmulator({ cdnHosts: ['cdn.example.com'], unhandled: 'error' }); /** The current test's emulator session, for a test that registers a scenario or preset on it. */ export let session: EmulatorSession; From d454c804ba8945537c5f2c1694eef72e7e896291 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 19:22:54 +0300 Subject: [PATCH 048/104] test(react-ai-image-editor): give the gated fallback test a file of its own Checklist items 3 and 19. hydration.test.tsx gated the mocked engine import so its first test could see the fallback, but a module resolves once per file: whenever another test in the file ran first, the gate was already open and "shows the fallback until the engine loads" failed (--sequence.shuffle --sequence.seed=12345). That test moves, unchanged, to fallback.test.tsx with the gate; hydration.test.tsx mocks the engine without one and loses its releaseImport() workarounds. Both files share the fake element from tests/support/fake-editor.ts. Co-Authored-By: Claude Opus 5.5 --- .../tests/hydration/fallback.test.tsx | 60 ++++++++++++++++ .../tests/hydration/hydration.test.tsx | 70 +------------------ .../tests/support/fake-editor.ts | 17 +++++ 3 files changed, 79 insertions(+), 68 deletions(-) create mode 100644 packages/react-ai-image-editor/tests/hydration/fallback.test.tsx create mode 100644 packages/react-ai-image-editor/tests/support/fake-editor.ts diff --git a/packages/react-ai-image-editor/tests/hydration/fallback.test.tsx b/packages/react-ai-image-editor/tests/hydration/fallback.test.tsx new file mode 100644 index 0000000..dc1653b --- /dev/null +++ b/packages/react-ai-image-editor/tests/hydration/fallback.test.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { expect, it, vi } from 'vitest'; + +import { AiImageEditor } from '../../src'; +import { setupContainers } from '../support/containers'; + +// The engine import is gated so the test can observe the fallback before it resolves. A module resolves once per +// file, so this test has a file to itself: sharing one, it would pass only when it happened to run first. +let releaseImport: () => void; +const importGate = new Promise((resolve) => { + releaseImport = resolve; +}); + +vi.mock('@uploadcare/ai-image-editor', async () => { + await importGate; + return (await import('../support/fake-editor')).defineFakeEditor(); +}); + +const makeContainer = setupContainers(); + +it('shows the fallback until the engine loads, then swaps in the element with wired props and events', async () => { + const container = makeContainer(); + const onDone = vi.fn(); + const apiRef = React.createRef(); + + const root = createRoot(container); + root.render( + } + />, + ); + + // engine import is still gated: fallback must be visible + await vi.waitFor(() => { + expect(container.querySelector('[data-testid="skeleton"]')).not.toBeNull(); + }); + expect(container.querySelector('uc-ai-image-editor')).toBeNull(); + + releaseImport(); + + await vi.waitFor(() => { + expect(container.querySelector('uc-ai-image-editor')).not.toBeNull(); + }); + const el = container.querySelector('uc-ai-image-editor') as HTMLElement & { pubkey: string }; + expect(container.querySelector('[data-testid="skeleton"]')).toBeNull(); + expect(el.pubkey).toBe('test-pubkey'); + expect(el.getAttribute('class')).toBe('my-class'); + expect(apiRef.current).toBe(el); + + el.dispatchEvent(new CustomEvent('uc:done', { detail: { some: 'detail' } })); + expect(onDone).toHaveBeenCalledWith({ some: 'detail' }); + + root.unmount(); + expect(container.querySelector('uc-ai-image-editor')).toBeNull(); +}); diff --git a/packages/react-ai-image-editor/tests/hydration/hydration.test.tsx b/packages/react-ai-image-editor/tests/hydration/hydration.test.tsx index ccbde20..24243fb 100644 --- a/packages/react-ai-image-editor/tests/hydration/hydration.test.tsx +++ b/packages/react-ai-image-editor/tests/hydration/hydration.test.tsx @@ -6,77 +6,12 @@ import { expect, it, vi } from 'vitest'; import { AiImageEditor } from '../../src'; import { setupContainers } from '../support/containers'; -// Deterministic stand-in for the real Lit element: accessors live on the -// prototype (like Lit's @property) so the adapter's prop-splitting treats them -// as element properties. The import is gated so the first test can observe the -// pre-resolution fallback state. -let releaseImport: () => void; -const importGate = new Promise((resolve) => { - releaseImport = resolve; -}); - -vi.mock('@uploadcare/ai-image-editor', async () => { - await importGate; - class UcAiImageEditor extends HTMLElement { - #pubkey = ''; - get pubkey() { - return this.#pubkey; - } - set pubkey(value: string) { - this.#pubkey = value; - } - } - customElements.define('uc-ai-image-editor', UcAiImageEditor); - return { UcAiImageEditor }; -}); +vi.mock('@uploadcare/ai-image-editor', async () => (await import('../support/fake-editor')).defineFakeEditor()); const makeContainer = setupContainers(); -it('shows the fallback until the engine loads, then swaps in the element with wired props and events', async () => { - const container = makeContainer(); - const onDone = vi.fn(); - const apiRef = React.createRef(); - - const root = createRoot(container); - root.render( - } - />, - ); - - // engine import is still gated: fallback must be visible - await vi.waitFor(() => { - expect(container.querySelector('[data-testid="skeleton"]')).not.toBeNull(); - }); - expect(container.querySelector('uc-ai-image-editor')).toBeNull(); - - releaseImport(); - - await vi.waitFor(() => { - expect(container.querySelector('uc-ai-image-editor')).not.toBeNull(); - }); - const el = container.querySelector('uc-ai-image-editor') as HTMLElement & { pubkey: string }; - expect(container.querySelector('[data-testid="skeleton"]')).toBeNull(); - expect(el.pubkey).toBe('test-pubkey'); - expect(el.getAttribute('class')).toBe('my-class'); - expect(apiRef.current).toBe(el); - - el.dispatchEvent(new CustomEvent('uc:done', { detail: { some: 'detail' } })); - expect(onDone).toHaveBeenCalledWith({ some: 'detail' }); - - root.unmount(); - expect(container.querySelector('uc-ai-image-editor')).toBeNull(); -}); - it('hydrates server HTML without hydration mismatches', async () => { - releaseImport(); // in case the first test didn't run (order independence) - const ui = ( - loading} /> - ); + const ui = loading} />; const serverHtml = renderToString(ui); expect(serverHtml).toContain('skeleton'); @@ -97,7 +32,6 @@ it('hydrates server HTML without hydration mismatches', async () => { }); it('uc:cancel and uc:error events reach their callbacks', async () => { - releaseImport(); // in case the first test didn't run (order independence) const container = makeContainer(); const onCancel = vi.fn(); const onError = vi.fn(); diff --git a/packages/react-ai-image-editor/tests/support/fake-editor.ts b/packages/react-ai-image-editor/tests/support/fake-editor.ts new file mode 100644 index 0000000..e8802ae --- /dev/null +++ b/packages/react-ai-image-editor/tests/support/fake-editor.ts @@ -0,0 +1,17 @@ +/** + * Deterministic stand-in for the real Lit element, for a `vi.mock('@uploadcare/ai-image-editor')` factory: accessors + * live on the prototype (like Lit's `@property`) so the adapter's prop-splitting treats them as element properties. + */ +export function defineFakeEditor() { + class UcAiImageEditor extends HTMLElement { + #pubkey = ''; + get pubkey() { + return this.#pubkey; + } + set pubkey(value: string) { + this.#pubkey = value; + } + } + customElements.define('uc-ai-image-editor', UcAiImageEditor); + return { UcAiImageEditor }; +} From 483d6206c5a01e70035c7bf0e6db4cd8ea32a615 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 19:23:17 +0300 Subject: [PATCH 049/104] ci: run the tests in a shuffled order Checklist items 3 and 19. The test job passes --sequence.shuffle with the run id as the seed, so a test that depends on another running first fails in CI, and the logged command reproduces the order locally. Both packages pass with --sequence.seed=12345 after the previous commits, which fixed the two order-dependent tests that seed exposed. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/checks.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 0928de2..5b7ff33 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -91,7 +91,9 @@ jobs: - run: npm run build --workspaces --if-present - - run: npm run test --workspaces --if-present + # shuffled so a test that only passes after another one fails here; the seed is the run id, so a failure + # reproduces locally with `npm test -- --sequence.shuffle --sequence.seed=` + - run: npm run test --workspaces --if-present -- --sequence.shuffle --sequence.seed=${{ github.run_id }} next-fixture: if: startsWith(github.head_ref, 'release-please--') From 3c077fa08feffcb52f2cb38d834d8429ec1b7e0d Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 19:27:23 +0300 Subject: [PATCH 050/104] test(ai-image-editor): look up a well-formed absent uuid in the not-found test Checklist items 12 and 35. The emulator now refuses a /info/ file_id that is not a uuid with 400 'file_id is invalid.' (api-emulator 92f34d12), as the real Upload API does, so getFileInfo('missing') never reached the 404 'File is not found.' path this test guards. Look up a fixed, well-formed uuid the session does not hold (fixed, not random, per item 22). Proved able to fail: wrapping isReadyPoll's rejection in a generic error makes the test fail with "got 'lookup broke'". Co-Authored-By: Claude Opus 5.5 --- .../src/entities/provider/api/uploadcareDerivativeApi.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts index 53cd7d6..c70a2db 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts @@ -266,7 +266,9 @@ describe('UploadcareDerivativeApi', () => { it('propagates a lookup failure', async () => { const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY }); - await expect(provider.getFileInfo('missing')).rejects.toThrow(/not found/i); + // A well-formed uuid the session does not hold: a malformed id would be + // refused earlier, as invalid, and never reach the not-found lookup. + await expect(provider.getFileInfo('00000000-0000-4000-8000-000000000000')).rejects.toThrow(/not found/i); }); }); From dcf6d5d4aa78feda50c3272b9c27bbbc2906ae3d Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 19:32:09 +0300 Subject: [PATCH 051/104] test(ai-image-editor): abort a hanging client request instead of spying on fetch Checklist items 6, 7 and 14. The three "forwards the abort signal" tests spied on fetch and compared the signal it got by identity, which a correct AbortSignal.any refactor would break and which says nothing about what the caller sees. Each now hangs its route with a session.on scenario that never answers, aborts once the request is in the emulator's log, and expects the call to reject with an AbortError, named for that outcome. Proved able to fail: not passing the signal to fetch in startJob and getJobStatus leaves all three calls hanging until the test times out. Co-Authored-By: Claude Opus 5.5 --- .../provider/api/uploadcareApiClient.test.ts | 44 ++++++++++++++----- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts index 3336dfa..430d8ba 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts @@ -6,6 +6,9 @@ import { UploadcareApiClient } from './uploadcareApiClient'; const PUBLIC_KEY = 'demopublickey'; +/** Answers nothing, ever: a request to `route` stays in flight until its caller aborts it. */ +const hang = (route: string) => session.on(route, () => new Promise(() => {})); + describe('UploadcareApiClient', () => { it('throws when publicKey is missing', () => { expect(() => new UploadcareApiClient({ publicKey: '' })).toThrow(/publicKey/); @@ -102,12 +105,21 @@ describe('UploadcareApiClient', () => { await expect(client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' })).rejects.toThrow(/400/); }); - it('forwards the abort signal', async () => { - const fetchSpy = vi.spyOn(globalThis, 'fetch'); + it('gives up on a hanging request when the caller aborts', async () => { + hang('POST /derivative/image/generate/'); const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); const controller = new AbortController(); - await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png', signal: controller.signal }); - expect(fetchSpy.mock.calls[0]![1]?.signal).toBe(controller.signal); + + const pending = client.generate({ + prompt: 'x', + aspectRatio: [1, 1], + filename: 'f.png', + signal: controller.signal, + }); + await vi.waitFor(() => expect(session.requests).toHaveLength(1)); + controller.abort(); + + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); }); it('sends Accept: application/json', async () => { @@ -186,12 +198,16 @@ describe('UploadcareApiClient', () => { await expect(client.edit({ prompt: 'x', source: 'u', filename: 'f.png' })).rejects.toThrow(/400/); }); - it('forwards the abort signal', async () => { - const fetchSpy = vi.spyOn(globalThis, 'fetch'); + it('gives up on a hanging request when the caller aborts', async () => { + hang('POST /derivative/image/edit/'); const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); const controller = new AbortController(); - await client.edit({ prompt: 'x', source: DEMO_FILES[0], filename: 'f.png', signal: controller.signal }); - expect(fetchSpy.mock.calls[0]![1]?.signal).toBe(controller.signal); + + const pending = client.edit({ prompt: 'x', source: DEMO_FILES[0], filename: 'f.png', signal: controller.signal }); + await vi.waitFor(() => expect(session.requests).toHaveLength(1)); + controller.abort(); + + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); }); }); @@ -228,13 +244,17 @@ describe('UploadcareApiClient', () => { expect(err.errorCode).toBe('job_not_found'); }); - it('forwards the abort signal', async () => { - const fetchSpy = vi.spyOn(globalThis, 'fetch'); + it('gives up on a hanging request when the caller aborts', async () => { + hang('GET /derivative/status/'); const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); const jobId = await startJob(client); const controller = new AbortController(); - await client.getJobStatus(jobId, controller.signal); - expect(fetchSpy.mock.calls[1]![1]?.signal).toBe(controller.signal); + + const pending = client.getJobStatus(jobId, controller.signal); + await vi.waitFor(() => expect(session.requests).toHaveLength(2)); + controller.abort(); + + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); }); }); }); From 6a4af7f559e067bf7e29deae2e8b357a2d500c80 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 19:33:22 +0300 Subject: [PATCH 052/104] test(ai-image-editor): abort a hanging derivative job instead of spying on fetch Checklist items 6, 7 and 14. "passes the abort signal..." compared every fetch's signal to the caller's by identity, and "stops polling..." wrapped fetch to abort from inside it. Both pinned how the signal travels, not what the caller sees. They become two scenarios: the start request hangs, or the first status poll hangs; the test aborts once that request is in the emulator's log and expects an AbortError, or the poll's CancelError, not a generation_timeout. The pre-aborted case reads the emulator's request log (nothing sent) instead of counting fetch calls. The old "no further polling" count is dropped: with a hanging poll, the next poll can only be sent on the aborted signal, and fetch refuses that. No break could make the count fail. Proved able to fail: not passing the signal to fetch fails all three (the first two hang until the timeout, and the third sends its POST). Not passing it to poll hangs the status case. Turning every CancelError into generation_timeout fails the status case. Co-Authored-By: Claude Opus 5.5 --- .../api/uploadcareDerivativeApi.test.ts | 49 ++++++++++--------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts index c70a2db..22d8971 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts @@ -9,6 +9,9 @@ const PUBLIC_KEY = 'demopublickey'; const CDN = 'https://cdn.example.com'; const NO_DELAY = { pollIntervalMs: 0 } as const; +/** Answers nothing, ever: a request to `route` stays in flight until its caller aborts it. */ +const hang = (route: string) => session.on(route, () => new Promise(() => {})); + /** A job that never finishes: every status poll answers `processing`. */ const neverFinish = () => session.on('GET /derivative/status/', () => Response.json({ type: 'job', status: 'processing' })); @@ -164,41 +167,39 @@ describe('UploadcareDerivativeApi', () => { await expect(provider.generate({ prompt: 'x', mode: 'generate' })).rejects.toThrow(/400/); }); - it('passes the abort signal to generate and status fetches', async () => { - const fetchSpy = vi.spyOn(globalThis, 'fetch'); + it('cancels a job that is still starting when the caller aborts', async () => { + hang('POST /derivative/image/generate/'); const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); const controller = new AbortController(); - await provider.generate({ prompt: 'x', mode: 'generate', signal: controller.signal }); - const signals = fetchSpy.mock.calls.map(([, init]) => init?.signal); - expect(signals.length).toBeGreaterThanOrEqual(2); - expect(signals.every((s) => s === controller.signal)).toBe(true); + + const pending = provider.generate({ prompt: 'x', mode: 'generate', signal: controller.signal }); + await vi.waitFor(() => expect(session.requests).toHaveLength(1)); + controller.abort(); + + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); }); - it('stops polling when the signal is aborted mid-flight', async () => { - const controller = new AbortController(); - const emulated = globalThis.fetch; - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => { - const response = await emulated(url, init); - // Abort while "processing": the next poll must never happen. - if (init?.method === 'GET') controller.abort(); - return response; - }); + it('cancels a status poll in flight when the caller aborts', async () => { + hang('GET /derivative/status/'); const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); + const controller = new AbortController(); - await expect(provider.generate({ prompt: 'x', mode: 'generate', signal: controller.signal })).rejects.toThrow( - /cancel/i, - ); - // 1 POST + exactly 1 status poll, then it bails — no further polling. - expect(fetchSpy).toHaveBeenCalledTimes(2); + const pending = provider.generate({ prompt: 'x', mode: 'generate', signal: controller.signal }); + // The start, then the first status poll. + await vi.waitFor(() => expect(session.requests).toHaveLength(2)); + controller.abort(); + + await expect(pending).rejects.toThrow(/cancel/i); }); - it('does not start polling when the signal is already aborted', async () => { - const fetchSpy = vi.spyOn(globalThis, 'fetch'); + it('sends nothing when the signal is already aborted', async () => { const controller = new AbortController(); controller.abort(); const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); - await expect(provider.generate({ prompt: 'x', mode: 'generate', signal: controller.signal })).rejects.toThrow(); - expect(fetchSpy).toHaveBeenCalledTimes(1); + await expect(provider.generate({ prompt: 'x', mode: 'generate', signal: controller.signal })).rejects.toMatchObject( + { name: 'AbortError' }, + ); + expect(session.requests).toHaveLength(0); }); describe('edit mode', () => { From ca17661cf1bcf1396d5540e325ea676878438f8c Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 19:35:09 +0300 Subject: [PATCH 053/104] test(ai-image-editor): script the status frames the polling test walks Checklist items 8, 25 and 2. "polls the status endpoint ... until is_ready" expected exactly 5 requests, which is the emulator's own walk (processing, uploading, unready success, ready success), set in emulator source the reader can't see. The test now scripts those frames with session.on, one per poll, on top of derivativesInstant for the real ready frame. It expects the job to resolve after the fourth status poll and no later. The status URL check is dropped: it is wire format, and uploadcareApiClient.test.ts "GETs the status endpoint with pub_key + job_id" already asserts it. Proved able to fail: resolving on any success frame ignores is_ready and stops after 3 polls. Resolving on anything but processing stops after 2. Co-Authored-By: Claude Opus 5.5 --- .../api/uploadcareDerivativeApi.test.ts | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts index 22d8971..eafcdad 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts @@ -8,13 +8,14 @@ import { UploadcareDerivativeApi } from './uploadcareDerivativeApi'; const PUBLIC_KEY = 'demopublickey'; const CDN = 'https://cdn.example.com'; const NO_DELAY = { pollIntervalMs: 0 } as const; +const STATUS_PATH = '/derivative/status/'; +const STATUS = `GET ${STATUS_PATH}`; /** Answers nothing, ever: a request to `route` stays in flight until its caller aborts it. */ const hang = (route: string) => session.on(route, () => new Promise(() => {})); /** A job that never finishes: every status poll answers `processing`. */ -const neverFinish = () => - session.on('GET /derivative/status/', () => Response.json({ type: 'job', status: 'processing' })); +const neverFinish = () => session.on(STATUS, () => Response.json({ type: 'job', status: 'processing' })); describe('UploadcareDerivativeApi', () => { it('throws when publicKey is missing', () => { @@ -60,22 +61,21 @@ describe('UploadcareDerivativeApi', () => { expect((await session.requests[0].json()).aspect_ratio).toEqual([1, 1]); }); - it('polls the status endpoint with pub_key + job_id until the success frame reports is_ready', async () => { - const provider = new UploadcareDerivativeApi({ - publicKey: PUBLIC_KEY, - cdnBaseUrl: CDN, - ...NO_DELAY, + it('keeps polling through processing, uploading and an unready success until is_ready', async () => { + // Oldest first: the emulator's own frames, walked straight to ready. + session.use('derivativesInstant'); + session.on(STATUS, async ({ next }) => Response.json({ ...(await (await next())?.json()), is_ready: false }), { + times: 1, }); + session.on(STATUS, () => Response.json({ type: 'job', status: 'uploading' }), { times: 1 }); + session.on(STATUS, () => Response.json({ type: 'job', status: 'processing' }), { times: 1 }); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, cdnBaseUrl: CDN, ...NO_DELAY }); const result = await provider.generate({ prompt: 'x', mode: 'generate' }); expect(result.url).toBe(`${CDN}/${result.uuid}/`); - // 1 POST + 4 status polls: processing, uploading, success not yet ready, success ready. - expect(session.requests).toHaveLength(5); - expect(session.requests[1].url).toMatch( - new RegExp(`^https://upload\\.uploadcare\\.com/derivative/status/\\?pub_key=${PUBLIC_KEY}&job_id=[\\w-]+$`), - ); - expect(session.requests[1].method).toBe('GET'); + // processing, uploading, unready success, ready success: one poll each, none after. + expect(session.requests.filter((request) => new URL(request.url).pathname === STATUS_PATH)).toHaveLength(4); }); it('throws when the job ends in an error status', async () => { @@ -180,7 +180,7 @@ describe('UploadcareDerivativeApi', () => { }); it('cancels a status poll in flight when the caller aborts', async () => { - hang('GET /derivative/status/'); + hang(STATUS); const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); const controller = new AbortController(); From 967e7d2247660e0a03cf0a4775208a4e32d31efa Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 19:36:01 +0300 Subject: [PATCH 054/104] test(ai-image-editor): check the derivative provider's results, not its request bodies Checklist item 2. The derivative spec re-asserted the wire body (pub_key, prompt, URL, method, Content-Type) that uploadcareApiClient.test.ts already covers ("POSTs pub_key + prompt + aspect_ratio + filename and returns the job", "POSTs pub_key + prompt + source uuid + filename to the edit endpoint", "includes aspect_ratio only when provided"). It now tests only the provider's own logic, through what it returns. The emulator now sizes a result by aspect_ratio, so a requested 16:9 comes back 16:9, a missing ratio comes back square, and an edit with no ratio keeps the source's dimensions. The default filename is the result's originalFilename. The edit endpoint URL stays: it shows that mode: 'edit' goes to the edit route. The metadata forwarding tests stay too, since no other test covers the provider passing metadata on to the client. Proved able to fail: a 16:9 DEFAULT_RATIO fails the square case. Dropping the requested ratio fails the 16:9 generate and edit cases. Defaulting an edit to 1:1 fails the source-dimensions case. Changing the default filename fails its case. Co-Authored-By: Claude Opus 5.5 --- .../api/uploadcareDerivativeApi.test.ts | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts index eafcdad..2183cf1 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts @@ -1,5 +1,6 @@ import { DEMO_FILES, mintAuthToken } from '@uploadcare/api-emulator'; import { getPrefixedCdnBaseAsync } from '@uploadcare/cname-prefix/async'; +import type { UploadcareFile } from '@uploadcare/upload-client'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { session } from '../../../../tests/specs/setup'; import { AiProviderError } from '../model/types'; @@ -11,6 +12,10 @@ const NO_DELAY = { pollIntervalMs: 0 } as const; const STATUS_PATH = '/derivative/status/'; const STATUS = `GET ${STATUS_PATH}`; +/** A result's width over its height. */ +const ratioOf = (file: UploadcareFile) => + (file.imageInfo?.width ?? Number.NaN) / (file.imageInfo?.height ?? Number.NaN); + /** Answers nothing, ever: a request to `route` stays in flight until its caller aborts it. */ const hang = (route: string) => session.on(route, () => new Promise(() => {})); @@ -22,19 +27,16 @@ describe('UploadcareDerivativeApi', () => { expect(() => new UploadcareDerivativeApi({ publicKey: '' })).toThrow(/publicKey/); }); - it('POSTs the prompt + aspect ratio + pub_key to the derivative endpoint', async () => { + it('generates an image at the requested aspect ratio', async () => { const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); - await provider.generate({ prompt: 'a hat', mode: 'generate', aspectRatio: [16, 9] }); - - expect(session.requests[0].url).toBe('https://upload.uploadcare.com/derivative/image/generate/'); - expect(session.requests[0].method).toBe('POST'); - expect(session.requests[0].headers.get('Content-Type')).toBe('application/json'); - expect(await session.requests[0].json()).toMatchObject({ - pub_key: PUBLIC_KEY, - prompt: 'a hat', - aspect_ratio: [16, 9], - filename: 'generated.png', - }); + const { file } = await provider.generate({ prompt: 'a hat', mode: 'generate', aspectRatio: [16, 9] }); + expect(ratioOf(file)).toBe(16 / 9); + }); + + it('names the result generated.png when no filename is given', async () => { + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); + const { file } = await provider.generate({ prompt: 'x', mode: 'generate' }); + expect(file.originalFilename).toBe('generated.png'); }); it('forwards request metadata to the generate endpoint', async () => { @@ -55,10 +57,10 @@ describe('UploadcareDerivativeApi', () => { expect((await session.requests[0].json()).metadata).toEqual({ source: 'ai-image-editor' }); }); - it('uses 1:1 when aspectRatio is missing or invalid', async () => { + it('generates a square image when aspectRatio is missing', async () => { const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); - await provider.generate({ prompt: 'x', mode: 'generate' }); - expect((await session.requests[0].json()).aspect_ratio).toEqual([1, 1]); + const { file } = await provider.generate({ prompt: 'x', mode: 'generate' }); + expect(ratioOf(file)).toBe(1); }); it('keeps polling through processing, uploading and an unready success until is_ready', async () => { @@ -203,7 +205,7 @@ describe('UploadcareDerivativeApi', () => { }); describe('edit mode', () => { - it('POSTs prompt + source uuid to the edit endpoint and resolves the result', async () => { + it('edits the source into a new file at the requested aspect ratio', async () => { const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, cdnBaseUrl: CDN, @@ -218,21 +220,19 @@ describe('UploadcareDerivativeApi', () => { }); expect(session.requests[0].url).toBe('https://upload.uploadcare.com/derivative/image/edit/'); - expect(await session.requests[0].json()).toMatchObject({ - pub_key: PUBLIC_KEY, - prompt: 'remove the cat', - source: DEMO_FILES[0], - aspect_ratio: [16, 9], - }); expect(result.uuid).not.toBe(DEMO_FILES[0]); + expect(ratioOf(result.file)).toBe(16 / 9); expect(result.url).toBe(`${CDN}/${result.uuid}/`); expect(result.mode).toBe('edit'); }); - it('omits aspect_ratio when none is provided', async () => { + it("keeps the source's dimensions when no aspectRatio is given", async () => { const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); - await provider.generate({ prompt: 'x', mode: 'edit', source: DEMO_FILES[0] }); - expect((await session.requests[0].json()).aspect_ratio).toBeUndefined(); + const source = await provider.getFileInfo(DEMO_FILES[0]); + const { file } = await provider.generate({ prompt: 'x', mode: 'edit', source: DEMO_FILES[0] }); + expect(file.imageInfo).toMatchObject({ width: source.imageInfo?.width, height: source.imageInfo?.height }); + // The seeded demo image is not square, so a 1:1 default would show here. + expect(ratioOf(file)).not.toBe(1); }); it('throws (without any request) when an edit has no source uuid', async () => { From 1e179bf73e147da19452a837e913c963f8b4137f Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 19:36:19 +0300 Subject: [PATCH 055/104] test(ai-image-editor): cover the invalid aspect ratios the 1:1 fallback promises Checklist item 9. "uses 1:1 when aspectRatio is missing or invalid" only tried a missing ratio. It is now a table: missing, not positive ([0, 1]) and wider than the 10:1 limit ([20, 1]). Each must still produce a square result. Proved able to fail: skipping isValidAspectRatio sends [0, 1] (the emulator refuses it with invalid_aspect_ratio) and [20, 1] (the result comes back 20:1). Co-Authored-By: Claude Opus 5.5 --- .../provider/api/uploadcareDerivativeApi.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts index 2183cf1..a54a8cc 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts @@ -57,9 +57,17 @@ describe('UploadcareDerivativeApi', () => { expect((await session.requests[0].json()).metadata).toEqual({ source: 'ai-image-editor' }); }); - it('generates a square image when aspectRatio is missing', async () => { + it.each([ + ['missing', undefined], + ['not positive', [0, 1]], + ['wider than 10:1', [20, 1]], + ] as const)('generates a square image when aspectRatio is %s', async (_, aspectRatio) => { const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); - const { file } = await provider.generate({ prompt: 'x', mode: 'generate' }); + const { file } = await provider.generate({ + prompt: 'x', + mode: 'generate', + aspectRatio: aspectRatio && [...aspectRatio], + }); expect(ratioOf(file)).toBe(1); }); From 5397b4c6cc45a9fd7a3f9481f3d2082852389deb Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 19:36:49 +0300 Subject: [PATCH 056/104] test(ai-image-editor): rotate the token between requests in the token-follow test Checklist item 9. "follows a token that changes, on both network paths" changed the token before the provider sent anything, so a provider that resolved the token once and kept it would still pass. It now runs generate and getFileInfo with one token, rotates it, and runs both again. Both rounds must succeed against the signedUploads project, every request before the rotation must carry the first token, and every request after it the second. Proved able to fail: memoizing the resolver handed to upload-client (getFileInfo's path) sends the first token after the rotation. The old test passed with that change. Co-Authored-By: Claude Opus 5.5 --- .../provider/api/uploadcareDerivativeApi.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts index a54a8cc..ddc58dc 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts @@ -318,18 +318,26 @@ describe('UploadcareDerivativeApi', () => { it('follows a token that changes, on both network paths', async () => { // The provider holds one resolver for its lifetime; whoever owns the // token changes what that resolver returns, and nothing is pushed in. - let token = 'not-a-token'; + const [first, second] = [await mintAuthToken({ tokenId: 'first' }), await mintAuthToken({ tokenId: 'second' })]; + let token = first; const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, authToken: () => token, ...NO_DELAY, }); + const tokensSent = (requests: Request[]) => + new Set(requests.map((request) => request.headers.get('Authorization'))); - token = await mintAuthToken({ tokenId: 'second' }); + await provider.generate({ prompt: 'x', mode: 'generate' }); + await expect(provider.getFileInfo(DEMO_FILES[0])).resolves.toMatchObject({ uuid: DEMO_FILES[0] }); + const beforeRotation = session.requests.length; + token = second; await provider.generate({ prompt: 'x', mode: 'generate' }); - expect(session.requests[0].headers.get('Authorization')).toBe(`Bearer ${token}`); await expect(provider.getFileInfo(DEMO_FILES[0])).resolves.toMatchObject({ uuid: DEMO_FILES[0] }); + + expect(tokensSent(session.requests.slice(0, beforeRotation))).toEqual(new Set([`Bearer ${first}`])); + expect(tokensSent(session.requests.slice(beforeRotation))).toEqual(new Set([`Bearer ${second}`])); }); }); }); From 2dbad8f747202265d4c20e52afc8c3c8bb4681fd Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 19:37:35 +0300 Subject: [PATCH 057/104] test(ai-image-editor): drop the requestUpdate count from the transition test Checklist items 8 and 6. "requests an update on each significant transition" expected exactly two requestUpdate calls, which pins how many times the controller asks, not whether the host re-renders when it should. A harmless extra call would fail it. The test now holds the run open with the deferred provider and expects at least one re-render request when the run starts and another when it settles, and its name says so. Proved able to fail: removing the requestUpdate on entering busy, or the one in run's finally, fails it. Co-Authored-By: Claude Opus 5.5 --- .../model/GenerationController.test.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/ai-image-editor/src/features/generation/model/GenerationController.test.ts b/packages/ai-image-editor/src/features/generation/model/GenerationController.test.ts index 44a12f6..3f14df0 100644 --- a/packages/ai-image-editor/src/features/generation/model/GenerationController.test.ts +++ b/packages/ai-image-editor/src/features/generation/model/GenerationController.test.ts @@ -184,15 +184,16 @@ describe('GenerationController', () => { expect(observeSignal()?.aborted).toBe(true); }); - it('requests an update on each significant transition', async () => { + it('asks the host to re-render when a run starts and again when it settles', async () => { const ctrl = new GenerationController(host); - const provider: AiProvider = { - id: 'fake', - generate: async ({ prompt, mode }) => okResult('https://example.com/x.jpg', prompt, mode), - }; + const { provider, setNext } = createDeferredProvider(); + + const run = ctrl.run({ provider, prompt: 'x', mode: 'generate' }); + expect(host.requestUpdate).toHaveBeenCalled(); + host.requestUpdate.mockClear(); - await ctrl.run({ provider, prompt: 'x', mode: 'generate' }); - // One on enter-busy + one on finally. - expect(host.requestUpdate).toHaveBeenCalledTimes(2); + setNext(okResult('https://example.com/x.jpg', 'x')); + await run; + expect(host.requestUpdate).toHaveBeenCalled(); }); }); From 644fecd4d38fa46daf40b515d23fa63870bf9dd7 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 19:37:55 +0300 Subject: [PATCH 058/104] test(ai-image-editor): test the controller's host registration by what it does Checklist item 6. "registers itself with the host on construction" asserted addController was called, which names a mechanism, not an outcome. The fake host now keeps its controllers and can disconnect them the way a Lit element leaving the DOM does. "hostDisconnected aborts the in-flight request" becomes "aborts the in-flight request when its host disconnects": the abort goes through the host, so it covers registration as well. The registration test is deleted, since that test now covers it. Proved able to fail: not calling addController, or emptying hostDisconnected, leaves the run's signal unaborted. Co-Authored-By: Claude Opus 5.5 --- .../model/GenerationController.test.ts | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/ai-image-editor/src/features/generation/model/GenerationController.test.ts b/packages/ai-image-editor/src/features/generation/model/GenerationController.test.ts index 3f14df0..aaa8af7 100644 --- a/packages/ai-image-editor/src/features/generation/model/GenerationController.test.ts +++ b/packages/ai-image-editor/src/features/generation/model/GenerationController.test.ts @@ -16,8 +16,17 @@ const okResult = (url: string, prompt: string, mode: AiEditorMode = 'generate'): class FakeHost implements ReactiveControllerHost { public requestUpdate = vi.fn(); public updateComplete = Promise.resolve(true); - public addController = vi.fn<(controller: ReactiveController) => void>(); public removeController = vi.fn(); + private readonly controllers: ReactiveController[] = []; + + public addController(controller: ReactiveController): void { + this.controllers.push(controller); + } + + /** What a Lit element does when it leaves the DOM. */ + public disconnect(): void { + for (const controller of this.controllers) controller.hostDisconnected?.(); + } } /** Provider whose generate() resolves with the next value supplied via setNext(). */ @@ -58,11 +67,6 @@ describe('GenerationController', () => { host = new FakeHost(); }); - it('registers itself with the host on construction', () => { - const ctrl = new GenerationController(host); - expect(host.addController).toHaveBeenCalledWith(ctrl); - }); - it('starts with busy=false, no result, no error, empty history', () => { const ctrl = new GenerationController(host); expect(ctrl.busy).toBe(false); @@ -176,11 +180,11 @@ describe('GenerationController', () => { expect(host.requestUpdate).toHaveBeenCalled(); }); - it('hostDisconnected aborts the in-flight request', () => { + it('aborts the in-flight request when its host disconnects', () => { const ctrl = new GenerationController(host); const { provider, observeSignal } = createDeferredProvider(); void ctrl.run({ provider, prompt: 'x', mode: 'generate' }); - ctrl.hostDisconnected(); + host.disconnect(); expect(observeSignal()?.aborted).toBe(true); }); From 79e100e6760133f648f93f3dd07dfebf940df568 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 19:38:51 +0300 Subject: [PATCH 059/104] test(ai-image-editor): delete the prompt test that asserts its own input Checklist items 9 and 6. "updates internal prompt state when the user types in the prompt input" set the textarea's value and then expected that same value back. It passes with the prompt row's @input handler removed, so it tests nothing about the editor's state. generation.e2e.test.tsx "clears the prompt after a successful generation" already covers typing reaching the run: it types, sends and gets a result, and it fails with the handler removed. Co-Authored-By: Claude Opus 5.5 --- .../ai-image-editor/tests/editor/mounting.e2e.test.tsx | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/packages/ai-image-editor/tests/editor/mounting.e2e.test.tsx b/packages/ai-image-editor/tests/editor/mounting.e2e.test.tsx index fb1d4bd..90568e5 100644 --- a/packages/ai-image-editor/tests/editor/mounting.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/mounting.e2e.test.tsx @@ -33,16 +33,6 @@ describe(' mounting and configuration', () => { expect(editorMode(el)).toBe('edit'); }); - it('updates internal prompt state when the user types in the prompt input', async () => { - const el = mount(); - await el.updateComplete; - const input = el.shadowRoot!.querySelector('uc-ai-prompt-row')!.shadowRoot!.querySelector('textarea')!; - input.value = 'a tiger'; - input.dispatchEvent(new Event('input', { bubbles: true })); - await el.updateComplete; - expect(input.value).toBe('a tiger'); - }); - it('renders configurable prompt presets per mode', async () => { const el = mount(); el.presets = { From 083698148afd3211df4dc0138b09789a5025038a Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 19:39:58 +0300 Subject: [PATCH 060/104] test(react-ai-image-editor): check that preloadAiImageEditor loads the engine before a render Checklist item 9. "preloadAiImageEditor warms the engine cache" called preload, rendered, and waited for the element. Rendering alone loads the engine, so the test passed with preload turned into a no-op. Earlier tests in the file had already registered the element anyway. It is replaced by tests/hydration/preload.test.tsx, a file of its own over the fake engine: the element is not registered at first, and preloadAiImageEditor() registers it with nothing rendered. Proved able to fail: with preloadAiImageEditor a no-op, the new test fails and the deleted one still passed. Co-Authored-By: Claude Opus 5.5 --- .../tests/e2e/client.e2e.test.tsx | 13 +------------ .../tests/hydration/preload.test.tsx | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 12 deletions(-) create mode 100644 packages/react-ai-image-editor/tests/hydration/preload.test.tsx diff --git a/packages/react-ai-image-editor/tests/e2e/client.e2e.test.tsx b/packages/react-ai-image-editor/tests/e2e/client.e2e.test.tsx index eb91029..018f6d9 100644 --- a/packages/react-ai-image-editor/tests/e2e/client.e2e.test.tsx +++ b/packages/react-ai-image-editor/tests/e2e/client.e2e.test.tsx @@ -4,7 +4,7 @@ import { createRoot, type Root } from 'react-dom/client'; import { afterEach, expect, it, vi } from 'vitest'; import { AiImageEditorError } from '@uploadcare/ai-image-editor/errors'; -import { AiImageEditor, preloadAiImageEditor } from '../../src'; +import { AiImageEditor } from '../../src'; import { setupContainers } from '../support/containers'; const makeContainer = setupContainers(); @@ -142,17 +142,6 @@ it('updated callback props receive events (no stale handlers)', async () => { expect(second).toHaveBeenCalledTimes(1); }); -it('preloadAiImageEditor warms the engine cache', async () => { - preloadAiImageEditor(); - const container = render(); - await vi.waitFor( - () => { - expect(container.querySelector('uc-ai-image-editor')).not.toBeNull(); - }, - { timeout: 10_000 }, - ); -}); - it('shares one AiImageEditorError class identity across package entries', async () => { // both imports resolve to built dist files here, so this guards the // bundling contract: instanceof must work across the main and /errors entries diff --git a/packages/react-ai-image-editor/tests/hydration/preload.test.tsx b/packages/react-ai-image-editor/tests/hydration/preload.test.tsx new file mode 100644 index 0000000..72a8526 --- /dev/null +++ b/packages/react-ai-image-editor/tests/hydration/preload.test.tsx @@ -0,0 +1,15 @@ +import { expect, it, vi } from 'vitest'; + +import { preloadAiImageEditor } from '../../src'; + +// A file of its own: a module resolves once per file, so an earlier render here would have loaded the engine already. +vi.mock('@uploadcare/ai-image-editor', async () => (await import('../support/fake-editor')).defineFakeEditor()); + +it('preloadAiImageEditor loads the engine before anything renders', async () => { + expect(customElements.get('uc-ai-image-editor')).toBeUndefined(); + + preloadAiImageEditor(); + + // Loading the engine registers the element; nothing has been rendered. + await vi.waitFor(() => expect(customElements.get('uc-ai-image-editor')).toBeDefined()); +}); From ebd4e9375b489fc026c7c7302fff681bcbd09a7a Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 19:48:10 +0300 Subject: [PATCH 061/104] test(ai-image-editor): pin the default poll interval and timeout with fake timers Checklist item 22. Two specs run the derivative provider with its default options on a fake clock: status polls land exactly 1500ms apart, and the job gives up as generation_timeout exactly 1,000,000ms after it started. Only setTimeout, clearTimeout and Date are faked, so the emulator's requests still complete over real I/O between clock steps. The wall-clock "times out when the job never reaches a terminal state" spec (pollTimeoutMs: 5) is deleted: "gives up at the default timeout" covers it on a clock that cannot race. That spec was also the source of the TimeoutNegativeWarning in the run output, not the pollTimeoutMs: 0 spec: a status check that finished past its 5ms deadline made upload-client's poll call setTimeout with a negative delay (Math.min(interval, timeLeft())). With pollTimeoutMs: 0, poll gives up before its first check and never sleeps. Co-Authored-By: Claude Opus 5.5 --- .../api/uploadcareDerivativeApi.test.ts | 67 +++++++++++++++++-- 1 file changed, 60 insertions(+), 7 deletions(-) diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts index ddc58dc..65f7b03 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts @@ -118,14 +118,67 @@ describe('UploadcareDerivativeApi', () => { expect(err.message).toContain(jobId); }); - it('times out when the job never reaches a terminal state', async () => { - neverFinish(); - const provider = new UploadcareDerivativeApi({ - publicKey: PUBLIC_KEY, - pollIntervalMs: 0, - pollTimeoutMs: 5, + describe('with the default poll options', () => { + // The documented defaults: a poll every 1.5s, giving up after 1,000,000ms. + const INTERVAL = 1500; + const TIMEOUT = 1_000_000; + + // Only the clock is fake: the emulator's requests still need real I/O turns to complete. + beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); + return () => vi.useRealTimers(); + }); + + /** Starts a generation and tracks whether it has settled. */ + const start = () => { + const run = { settled: false, pending: Promise.resolve() as Promise }; + run.pending = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY }) + .generate({ prompt: 'x', mode: 'generate' }) + .finally(() => { + run.settled = true; + }); + run.pending.catch(() => {}); + return run; + }; + + /** Lets the in-flight request finish, without moving the clock, until the poll sleeps or `run` settles. */ + const untilIdle = async (run: { settled: boolean }) => { + while (vi.getTimerCount() === 0 && !run.settled) { + await new Promise((resolve) => setImmediate(resolve)); + } + }; + + it('waits the default interval between status polls', async () => { + const polledAt: number[] = []; + session.on(STATUS, () => { + polledAt.push(Date.now()); + return Response.json({ type: 'job', status: 'processing' }); + }); + const run = start(); + + for (let sleeps = 0; sleeps < 3; sleeps++) { + await untilIdle(run); + await vi.advanceTimersToNextTimerAsync(); + } + await untilIdle(run); + + expect(polledAt.slice(1).map((at, i) => at - polledAt[i])).toEqual([INTERVAL, INTERVAL, INTERVAL]); + }); + + it('gives up at the default timeout', async () => { + neverFinish(); + const startedAt = Date.now(); + const run = start(); + + for (;;) { + await untilIdle(run); + if (run.settled) break; + await vi.advanceTimersToNextTimerAsync(); + } + + expect(Date.now() - startedAt).toBe(TIMEOUT); + await expect(run.pending).rejects.toMatchObject({ name: 'AiProviderError', errorCode: 'generation_timeout' }); }); - await expect(provider.generate({ prompt: 'x', mode: 'generate' })).rejects.toThrow(/time/i); }); it('honours baseUrl + cdnBaseUrl overrides for both generate and status', async () => { From 586daba417014570e68d394aa609ffb1834fd0d5 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 20:15:41 +0300 Subject: [PATCH 062/104] test(react-ai-image-editor): commit the re-render with flushSync instead of sleeping 50ms Checklist item 21. The stale-handler test waited 50ms after root.render and hoped React had committed. flushSync commits synchronously, layout effects included, so @lit/react has swapped the uc:cancel listener before the event is dispatched. flushSync over act() because act needs IS_REACT_ACT_ENVIRONMENT set for the whole e2e project. Fail-first: rendering without flushSync, or freezing the wrapper's handler memo with [] deps, both fail with the stale onCancel called once. Co-Authored-By: Claude Opus 5.5 --- packages/react-ai-image-editor/tests/e2e/client.e2e.test.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/react-ai-image-editor/tests/e2e/client.e2e.test.tsx b/packages/react-ai-image-editor/tests/e2e/client.e2e.test.tsx index 018f6d9..257cb78 100644 --- a/packages/react-ai-image-editor/tests/e2e/client.e2e.test.tsx +++ b/packages/react-ai-image-editor/tests/e2e/client.e2e.test.tsx @@ -1,5 +1,6 @@ import type { DoneDetail, UcAiImageEditor } from '@uploadcare/ai-image-editor'; import React from 'react'; +import { flushSync } from 'react-dom'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, expect, it, vi } from 'vitest'; @@ -133,8 +134,8 @@ it('updated callback props receive events (no stale handlers)', async () => { ); const root = roots[roots.length - 1]; - root.render(); - await new Promise((r) => setTimeout(r, 50)); + // Commits synchronously, layout effects included, so @lit/react has swapped the listener before the event. + flushSync(() => root.render()); const el = container.querySelector('uc-ai-image-editor') as UcAiImageEditor; el.dispatchEvent(new CustomEvent('uc:cancel')); From f4cf14532dd6de91d43545ebc919c47f859ded2e Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 20:16:55 +0300 Subject: [PATCH 063/104] test(ai-image-editor): wait for uc-config's definition, not a 0ms timer, in renderUploader Checklist item 21. renderUploader slept one macrotask after rendering the uploader before touching uc-config. The elements are defined in beforeAll, so inserting the markup upgrades them synchronously; the sleep waited for nothing (all 8 tests pass with it removed outright). It now awaits customElements.whenDefined('uc-config'), the one thing setting config.plugins depends on, which resolves at once when the definition is already in place. No new assertion, so there is nothing to break first. Co-Authored-By: Claude Opus 5.5 --- packages/ai-image-editor/tests/plugin.e2e.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ai-image-editor/tests/plugin.e2e.test.ts b/packages/ai-image-editor/tests/plugin.e2e.test.ts index 99cdfff..742d7c8 100644 --- a/packages/ai-image-editor/tests/plugin.e2e.test.ts +++ b/packages/ai-image-editor/tests/plugin.e2e.test.ts @@ -1,6 +1,6 @@ import { beforeAll, describe, expect, it, vi } from 'vitest'; import { page } from 'vitest/browser'; -import { cleanup, delay, getCtxName } from './test-renderer'; +import { cleanup, getCtxName } from './test-renderer'; const TEST_IMAGE_URL = 'https://images.unsplash.com/photo-1699102241946-45c5e1937d69?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&w=640'; @@ -24,7 +24,7 @@ async function renderUploader(plugins: unknown[] = []) { `, ); - await delay(0); + await customElements.whenDefined('uc-config'); const config = document.querySelector(`uc-config[ctx-name="${ctxName}"]`) as Config; config.plugins = plugins; return { ctxName, config }; From 8bfd388ba5dc26ce77d8e2b92345c51650aba1c2 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 20:17:50 +0300 Subject: [PATCH 064/104] test(ai-image-editor): wait for the host's resize to land before asserting the kept rendition Checklist item 21. The shrink half of the rendition test slept 120ms and then asserted the URL had not changed, a negative assertion that passes just as well if the resize was never processed. It now waits on a positive signal: a ResizeObserver created after the width change has reported the host, and observers are notified in creation order, so the host's own observer has already handled the 300px layout; then the host's update settles. The test-renderer delay() helper has no callers left and is removed. Fail-first: making the host replace its preview bucket on every resize (dropping the "only grow" guard) fails the test: the URL no longer contains /preview/1000x1000/. Co-Authored-By: Claude Opus 5.5 --- .../tests/editor/sizing.e2e.test.tsx | 17 +++++++++++++++-- packages/ai-image-editor/tests/test-renderer.ts | 4 ---- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/ai-image-editor/tests/editor/sizing.e2e.test.tsx b/packages/ai-image-editor/tests/editor/sizing.e2e.test.tsx index a2281ef..0e2c5bb 100644 --- a/packages/ai-image-editor/tests/editor/sizing.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/sizing.e2e.test.tsx @@ -1,8 +1,20 @@ import { describe, expect, it, vi } from 'vitest'; -import { delay } from '../test-renderer'; import type { UcAiImageEditorType } from './harness'; import { canvasUrl, clickSend, mount, STAGING, typePrompt } from './harness'; +/** + * Resolves once a ResizeObserver created now has reported `target`'s current size. Observers are notified in creation + * order, so every older one (the host's own) has already handled that layout. + */ +const afterResizeObservers = (target: Element) => + new Promise((resolve) => { + const observer = new ResizeObserver(() => { + observer.disconnect(); + resolve(); + }); + observer.observe(target); + }); + /** How the host sizes itself: fill vs content, the clamp, and the CDN rendition it picks. */ describe(' sizing', () => { const stageEl = (el: UcAiImageEditorType) => el.shadowRoot!.querySelector('.stage') as HTMLElement; @@ -107,7 +119,8 @@ describe(' sizing', () => { // …while shrinking keeps the sharper, already-cached one. el.style.width = '300px'; - await delay(120); + await afterResizeObservers(el); + await el.updateComplete; expect(canvasUrl(el)).toContain(`/preview/${size(1000)}x${size(1000)}/`); }); }); diff --git a/packages/ai-image-editor/tests/test-renderer.ts b/packages/ai-image-editor/tests/test-renderer.ts index 891ff26..82ffe1b 100644 --- a/packages/ai-image-editor/tests/test-renderer.ts +++ b/packages/ai-image-editor/tests/test-renderer.ts @@ -40,7 +40,3 @@ declare module 'vitest/browser' { export function getCtxName(): string { return `test-${Math.random().toString(36).slice(2)}`; } - -export function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} From 9cfe32aa50331d4579b939f32e65626e265903d4 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 20:25:45 +0300 Subject: [PATCH 065/104] test(ai-image-editor): fail a test whose derivative traffic breaks the dev schemas Checklist item 13. The dev-only Zod validation of every derivative request and response only console.errors on a mismatch, so drift between the emulator and the client's schema failed no test. Both setup files (the specs and the browser e2e) now spy on console.error and fail, after the test, any test in which the validation reported a mismatch. A test that spies on console.error itself (the schema's own spec) keeps its spy. Fail-first: typing is_ready as a string in the success schema fails 15 tests in uploadcareDerivativeApi.test.ts and 5 in generation.e2e.test.tsx with "The derivative API schema validation failed"; before this change the same edit only logged. Co-Authored-By: Claude Opus 5.5 --- packages/ai-image-editor/tests/emulator.ts | 3 +++ .../ai-image-editor/tests/schema-drift.ts | 26 +++++++++++++++++++ packages/ai-image-editor/tests/specs/setup.ts | 3 +++ 3 files changed, 32 insertions(+) create mode 100644 packages/ai-image-editor/tests/schema-drift.ts diff --git a/packages/ai-image-editor/tests/emulator.ts b/packages/ai-image-editor/tests/emulator.ts index d91fa58..62ea52c 100644 --- a/packages/ai-image-editor/tests/emulator.ts +++ b/packages/ai-image-editor/tests/emulator.ts @@ -1,6 +1,7 @@ import type { EmulatorSession } from '@uploadcare/api-emulator'; import { setupEmulator } from '@uploadcare/api-emulator/browser'; import { beforeEach } from 'vitest'; +import { failOnSchemaDrift } from './schema-drift'; /** * The Uploadcare emulator, running in the page, for Uploadcare's hosts and `cdn.example.com` (the CDN cname the @@ -16,3 +17,5 @@ export let session: EmulatorSession; beforeEach(async () => { session = await emulator.reset(); }); + +failOnSchemaDrift(); diff --git a/packages/ai-image-editor/tests/schema-drift.ts b/packages/ai-image-editor/tests/schema-drift.ts new file mode 100644 index 0000000..1fe9dcb --- /dev/null +++ b/packages/ai-image-editor/tests/schema-drift.ts @@ -0,0 +1,26 @@ +import { afterEach, beforeEach, vi } from 'vitest'; + +/** The prefix `uploadcareApiClient.schemas.dev.ts` puts on every contract-drift report. */ +const DRIFT = '[uploadcare-derivative-api]'; + +/** + * Fails the test in which the dev-only schema validation reports a request or response that breaks the derivative + * API contract. In the app that report is only a `console.error`; here it means the emulator (or the client) drifted + * from the schema, which no assertion would otherwise notice. A test that spies on `console.error` itself replaces + * this check for its own duration. + */ +export function failOnSchemaDrift(): void { + let reports: unknown[][] = []; + beforeEach(() => { + reports = []; + const log = console.error; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + if (typeof args[0] === 'string' && args[0].startsWith(DRIFT)) reports.push(args); + log(...args); + }); + }); + afterEach(() => { + if (reports.length) + throw new Error(`The derivative API schema validation failed:\n${reports.map(String).join('\n')}`); + }); +} diff --git a/packages/ai-image-editor/tests/specs/setup.ts b/packages/ai-image-editor/tests/specs/setup.ts index 6492949..b0b7612 100644 --- a/packages/ai-image-editor/tests/specs/setup.ts +++ b/packages/ai-image-editor/tests/specs/setup.ts @@ -1,6 +1,7 @@ import type { EmulatorSession } from '@uploadcare/api-emulator'; import { setupEmulator } from '@uploadcare/api-emulator/node'; import { afterAll, beforeEach } from 'vitest'; +import { failOnSchemaDrift } from '../schema-drift'; /** * The Uploadcare emulator behind this process's `fetch` and `node:http(s)`, for Uploadcare's hosts and @@ -17,5 +18,7 @@ beforeEach(async () => { session = await emulator.reset(); }); +failOnSchemaDrift(); + /** Removes the emulator's global patches, so a worker shared with a later file starts clean. */ afterAll(() => emulator.stop()); From 59cf6b7e93a5c550250f5c489826e9e460925eff Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 20:27:50 +0300 Subject: [PATCH 066/104] test(ai-image-editor): hold the emulator's success frame to the one production sent Checklist item 13. The verbatim derivative/status/ success frame from production was only checked against the client's Zod schema, never against what the emulator answers. It moves to a fixture, tests/fixtures/production-status-success.ts, which the schema spec now reads, and a new spec polls an emulated generate job to its ready success frame and compares the two by shape: the same keys, each value the same JSON type. Fail-first: the new spec failed on dpi, "array" from the emulator where production sent null. That is fixed in the emulator (uc-586-sync cc250fcc, "fix(api-emulator): report dpi: null for a PNG, as production did"), and the rebuilt dist passes. Setting is_image to 'yes' in the fixture fails both the new spec and the schema spec. Co-Authored-By: Claude Opus 5.5 --- .../api/uploadcareApiClient.contract.test.ts | 24 +++++++++ .../uploadcareApiClient.schemas.dev.test.ts | 45 +--------------- .../fixtures/production-status-success.ts | 54 +++++++++++++++++++ 3 files changed, 80 insertions(+), 43 deletions(-) create mode 100644 packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.contract.test.ts create mode 100644 packages/ai-image-editor/tests/fixtures/production-status-success.ts diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.contract.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.contract.test.ts new file mode 100644 index 0000000..8697b9e --- /dev/null +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.contract.test.ts @@ -0,0 +1,24 @@ +import { expect, it } from 'vitest'; +import { PRODUCTION_STATUS_SUCCESS, shapeOf } from '../../../../tests/fixtures/production-status-success'; +import { session } from '../../../../tests/specs/setup'; +import { UploadcareApiClient, type UploadcareJobStatus } from './uploadcareApiClient'; + +const PUBLIC_KEY = 'demopublickey'; + +/** Polls `jobId` until its result is ready, at most `polls` times. */ +async function pollToReady(client: UploadcareApiClient, jobId: string, polls = 10): Promise { + for (let poll = 1; ; poll += 1) { + const status = await client.getJobStatus(jobId); + if ((status.status === 'success' && status.is_ready) || status.status === 'error' || poll === polls) return status; + } +} + +it("polls a generate job to a success frame with production's keys and value types", async () => { + session.use('derivativesInstant'); + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); + + const { job_id } = await client.generate({ prompt: 'a hat', aspectRatio: [3, 2], filename: 'generated.png' }); + const done = await pollToReady(client, job_id!); + + expect(shapeOf(done)).toEqual(shapeOf(PRODUCTION_STATUS_SUCCESS)); +}); diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.schemas.dev.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.schemas.dev.test.ts index 1435fd0..ea24fa9 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.schemas.dev.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.schemas.dev.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; +import { PRODUCTION_STATUS_SUCCESS } from '../../../../tests/fixtures/production-status-success'; import { validate } from './uploadcareApiClient.schemas.dev'; describe('uploadcare derivative API dev schema validation', () => { @@ -109,51 +110,9 @@ describe('uploadcare derivative API dev schema validation', () => { expect(spy).not.toHaveBeenCalled(); }); - // A verbatim `derivative/status/` success frame from production (no `type` on - // success; image file → image_info + content_info.image present, video_info null). it('stays silent for a real derivative status success frame', () => { const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); - validate('status', { - size: 1620930, - total: 1620930, - done: 1620930, - uuid: '2e0c4294-32e0-4999-aed1-e78221224339', - file_id: '2e0c4294-32e0-4999-aed1-e78221224339', - original_filename: 'generated.png', - is_image: true, - is_stored: false, - image_info: { - dpi: null, - width: 1248, - format: 'PNG', - height: 832, - sequence: false, - color_mode: 'RGB', - orientation: null, - geo_location: null, - datetime_original: null, - }, - video_info: null, - content_info: { - mime: { mime: 'image/png', type: 'image', subtype: 'png' }, - image: { - dpi: null, - width: 1248, - format: 'PNG', - height: 832, - sequence: false, - color_mode: 'RGB', - orientation: null, - geo_location: null, - datetime_original: null, - }, - }, - is_ready: true, - filename: 'generated.png', - mime_type: 'image/png', - metadata: {}, - status: 'success', - }); + validate('status', PRODUCTION_STATUS_SUCCESS); expect(spy).not.toHaveBeenCalled(); }); diff --git a/packages/ai-image-editor/tests/fixtures/production-status-success.ts b/packages/ai-image-editor/tests/fixtures/production-status-success.ts new file mode 100644 index 0000000..c75a695 --- /dev/null +++ b/packages/ai-image-editor/tests/fixtures/production-status-success.ts @@ -0,0 +1,54 @@ +/** + * A `derivative/status/` success frame production sent, verbatim: no `type` on success, and for an image file + * `image_info` and `content_info.image` present, `video_info` null. The only frame of the derivative API recorded + * from the real API, so the one fixture the emulator's derivative routes can be held to. + */ +export const PRODUCTION_STATUS_SUCCESS = { + size: 1620930, + total: 1620930, + done: 1620930, + uuid: '2e0c4294-32e0-4999-aed1-e78221224339', + file_id: '2e0c4294-32e0-4999-aed1-e78221224339', + original_filename: 'generated.png', + is_image: true, + is_stored: false, + image_info: { + dpi: null, + width: 1248, + format: 'PNG', + height: 832, + sequence: false, + color_mode: 'RGB', + orientation: null, + geo_location: null, + datetime_original: null, + }, + video_info: null, + content_info: { + mime: { mime: 'image/png', type: 'image', subtype: 'png' }, + image: { + dpi: null, + width: 1248, + format: 'PNG', + height: 832, + sequence: false, + color_mode: 'RGB', + orientation: null, + geo_location: null, + datetime_original: null, + }, + }, + is_ready: true, + filename: 'generated.png', + mime_type: 'image/png', + metadata: {}, + status: 'success', +}; + +/** `value` with every leaf replaced by its JSON type (`'null'` and `'array'` kept apart), so two frames compare by shape. */ +export const shapeOf = (value: unknown): unknown => { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + if (typeof value !== 'object') return typeof value; + return Object.fromEntries(Object.entries(value).map(([key, inner]) => [key, shapeOf(inner)])); +}; From ec37d982e494e7cf04f5c826bd426eef85cf2609 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 20:29:08 +0300 Subject: [PATCH 067/104] test(ai-image-editor): check the ratio by the image it produces, not the request Checklist items 17 and 12 (review Top change 6). The aspect-ratio tests read the derivative POST bodies out of session.requests because the emulator ignored aspect_ratio. It now draws a result at the requested ratio and keeps the source's size when none is sent, so the three tests commit the result and check the uc:done file's imageInfo instead: 3:2 in generate mode, the source's own dimensions for Auto in edit mode, and a square result for an explicit 1:1 (the demo source is 136x150, so square proves the ratio was applied). The derivativeBodies helper and its requests read are gone, and the names state the outcome. Fail-first: making the client always send [1, 1] on generate and swap the edit condition (1:1 when Auto, nothing when a ratio is picked) fails all three. Co-Authored-By: Claude Opus 5.5 --- .../tests/editor/aspect-ratio.e2e.test.tsx | 68 ++++++++++++------- 1 file changed, 43 insertions(+), 25 deletions(-) diff --git a/packages/ai-image-editor/tests/editor/aspect-ratio.e2e.test.tsx b/packages/ai-image-editor/tests/editor/aspect-ratio.e2e.test.tsx index b2445b1..d06e9ff 100644 --- a/packages/ai-image-editor/tests/editor/aspect-ratio.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/aspect-ratio.e2e.test.tsx @@ -1,21 +1,44 @@ +import type { UploadcareFile } from '@uploadcare/upload-client'; import { describe, expect, it, vi } from 'vitest'; import { session } from '../emulator'; -import { clickSend, editorMode, mount, SAMPLE_UUID, STAGING, typePrompt } from './harness'; +import { + canvasUrl, + clickPrimary, + clickSend, + editorMode, + mount, + resultUuid, + SAMPLE_UUID, + STAGING, + typePrompt, + type UcAiImageEditorType, +} from './harness'; /** - * The body of every derivative POST (generate or edit) the emulator received. The - * emulator ignores the ratio, so the request is the only place it shows. + * Waits for the run's result on the canvas, commits it, and returns the image info of the file `uc:done` hands the + * host. The emulator draws a result at the requested ratio (or keeps the source's size when none is sent), so the + * dimensions show which ratio the editor asked for. */ -const derivativeBodies = (): Promise>> => - Promise.all( - session.requests - .filter((request) => request.method === 'POST' && new URL(request.url).pathname.startsWith('/derivative/')) - .map((request) => request.clone().json()), - ); - -/** The ratio picker: what it offers per mode, what it sends, what it restores. */ +async function committedImage(el: UcAiImageEditorType): Promise { + await vi.waitFor(() => { + const uuid = resultUuid(); + expect(uuid).toBeDefined(); + expect(canvasUrl(el)).toContain(`/${uuid}/`); + }); + await el.updateComplete; + const onDone = vi.fn(); + el.addEventListener('uc:done', onDone); + clickPrimary(el); + expect(onDone).toHaveBeenCalledOnce(); + return (onDone.mock.calls[0]![0] as CustomEvent<{ file: UploadcareFile }>).detail.file.imageInfo; +} + +/** A result's width over its height. */ +const ratioOf = (image: UploadcareFile['imageInfo']) => (image?.width ?? Number.NaN) / (image?.height ?? Number.NaN); + +/** The ratio picker: what it offers per mode, the shape of the image it produces, what it restores. */ describe(' aspect ratio', () => { - it('renders the aspect-ratio picker in generate mode (no Auto) and sends the selected ratio', async () => { + it('renders the aspect-ratio picker in generate mode (no Auto) and generates at the selected ratio', async () => { // 3:2: neither the picker's default (the first ratio) nor the provider's fallback (1:1). const el = mount({ ...STAGING, 'aspect-ratios': '16:9 3:2' }); await el.updateComplete; @@ -35,12 +58,10 @@ describe(' aspect ratio', () => { typePrompt(el, 'mountain'); await el.updateComplete; clickSend(el); - await vi.waitFor(async () => expect(await derivativeBodies()).toHaveLength(1)); - const [body] = await derivativeBodies(); - expect(body!.aspect_ratio).toEqual([3, 2]); + expect(ratioOf(await committedImage(el))).toBe(3 / 2); }); - it('defaults edit mode to "Auto" and omits aspect_ratio (preserving the source AR)', async () => { + it('defaults edit mode to "Auto", which keeps the dimensions of the source image', async () => { const el = mount(STAGING); el.sourceUuid = SAMPLE_UUID; await el.updateComplete; @@ -58,11 +79,9 @@ describe(' aspect ratio', () => { typePrompt(el, 'add a hat'); await el.updateComplete; clickSend(el); - await vi.waitFor(async () => expect(await derivativeBodies()).toHaveLength(1)); - const [body] = await derivativeBodies(); - // Auto is the default → no aspect_ratio on the wire; backend preserves it. - expect(body!.aspect_ratio).toBeUndefined(); - expect(body!.source).toBe(SAMPLE_UUID); + // Auto is the default → no aspect_ratio is sent, so the result keeps the source's size. + const source = session.files.get(SAMPLE_UUID)!.image!; + expect(await committedImage(el)).toMatchObject({ width: source.width, height: source.height }); }); it('renders the aspect-ratio picker in edit mode too', async () => { @@ -73,7 +92,7 @@ describe(' aspect ratio', () => { expect(el.shadowRoot!.querySelector('uc-ai-aspect-ratio')).toBeTruthy(); }); - it('sends an explicit ratio when the user reshapes in edit mode', async () => { + it('reshapes the result to a ratio the user picks in edit mode', async () => { const el = mount({ ...STAGING, 'aspect-ratios': '1:1' }); el.sourceUuid = SAMPLE_UUID; await el.updateComplete; @@ -90,9 +109,8 @@ describe(' aspect ratio', () => { typePrompt(el, 'make it square'); await el.updateComplete; clickSend(el); - await vi.waitFor(async () => expect(await derivativeBodies()).toHaveLength(1)); - const [body] = await derivativeBodies(); - expect(body!.aspect_ratio).toEqual([1, 1]); + // The source is not square, so a square result means the 1:1 was sent rather than Auto. + expect(ratioOf(await committedImage(el))).toBe(1); }); it('records the aspect ratio on a history entry and restores it when re-selected', async () => { From 23a093802a92c806091feacd1c489d0af8f7cfe2 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 20:31:06 +0300 Subject: [PATCH 068/104] test(ai-image-editor): run the derivative contract against the emulator and, on demand, the real API Checklist items 4 and 13. Nothing ran against the real API. A new contract project (tests/contract/) runs one set of assertions on two targets: generate at 3:2, poll to a ready success frame shaped like the recorded production one, read the file's info back, and the job_not_found and source_not_found refusals (both [inferred] in the emulator). By default it targets the emulator and is part of npm test; UC_CONTRACT_TARGET=live (npm run test:contract:live) points it at upload.uploadcare.com, where any difference fails. The shape spec from the previous commit moves here instead of living twice. The review proposed a nightly or workflow_dispatch job. Per the no-cron decision there is no schedule: the new contract workflow is workflow_dispatch only. As in #1081, missing credentials fail instead of skipping: a live run without UC_CONTRACT_PUBLIC_KEY throws in the setup file and exits 1 with no tests run. AGENTS.md documents both. Fail-first: with the client always sending [1, 1] and dropping the error_code from envelopes, all three tests fail; with getFileInfo reading a seeded file instead of the result, the info check fails (136 where 255 was produced). The live target itself was not run here: no project key is available on this machine. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/contract.yml | 31 ++++++++++ AGENTS.md | 9 +++ packages/ai-image-editor/package.json | 1 + .../api/uploadcareApiClient.contract.test.ts | 24 -------- .../contract/derivative.contract.test.ts | 58 +++++++++++++++++++ .../ai-image-editor/tests/contract/setup.ts | 32 ++++++++++ packages/ai-image-editor/vitest.config.ts | 12 ++++ 7 files changed, 143 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/contract.yml delete mode 100644 packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.contract.test.ts create mode 100644 packages/ai-image-editor/tests/contract/derivative.contract.test.ts create mode 100644 packages/ai-image-editor/tests/contract/setup.ts diff --git a/.github/workflows/contract.yml b/.github/workflows/contract.yml new file mode 100644 index 0000000..d10c85c --- /dev/null +++ b/.github/workflows/contract.yml @@ -0,0 +1,31 @@ +name: contract + +# The derivative API contract tests against the real upload.uploadcare.com. +# Run by hand only (each run spends one AI generation); the same tests run +# against the emulator in every `npm test`. Needs the UC_CONTRACT_PUBLIC_KEY +# secret: a project with AI derivatives enabled and signed uploads off. When +# it is missing the run fails instead of skipping. +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + contract: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + cache: npm + + - run: npm ci + + - name: Run the contract tests against the real API + env: + UC_CONTRACT_PUBLIC_KEY: ${{ secrets.UC_CONTRACT_PUBLIC_KEY }} + run: npm run test:contract:live -w @uploadcare/ai-image-editor diff --git a/AGENTS.md b/AGENTS.md index 9c62a22..5b41e4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,6 +106,15 @@ responses. tests apply the `derivativesInstant` preset (`tests/editor/harness.ts`) so a generation finishes on its first poll instead of after the editor's 1.5s interval several times over. +- **Contract** tests (`tests/contract/`, the `contract` project) hold the + derivative API to one set of assertions on two targets: the emulator in + every `npm test`, and the real `upload.uploadcare.com` with + `npm run test:contract:live -w @uploadcare/ai-image-editor` + (`UC_CONTRACT_PUBLIC_KEY` required; the run fails without it). CI runs the + live target only by hand (the `contract` workflow). +- In specs, e2e and contract tests, a request or response that fails the + client's dev schemas (`uploadcareApiClient.schemas.dev.ts`) fails the test + (`tests/schema-drift.ts`). - Steer the emulator per test with `session.on()` and `session.use(preset)` (`session` from the project's setup file); there are no magic keys, prompts or uuids. diff --git a/packages/ai-image-editor/package.json b/packages/ai-image-editor/package.json index 338a104..4b8b1f9 100644 --- a/packages/ai-image-editor/package.json +++ b/packages/ai-image-editor/package.json @@ -32,6 +32,7 @@ "preview": "vite preview", "test": "vitest run", "test:watch": "vitest", + "test:contract:live": "UC_CONTRACT_TARGET=live vitest run --project contract", "docs:api": "cem analyze && node scripts/build-cem-docs.mjs && typedoc && node scripts/clean-typedoc-class.mjs", "docs:dev": "npm run docs:api && vitepress dev docs", "docs:build": "npm run docs:api && vitepress build docs", diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.contract.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.contract.test.ts deleted file mode 100644 index 8697b9e..0000000 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.contract.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { expect, it } from 'vitest'; -import { PRODUCTION_STATUS_SUCCESS, shapeOf } from '../../../../tests/fixtures/production-status-success'; -import { session } from '../../../../tests/specs/setup'; -import { UploadcareApiClient, type UploadcareJobStatus } from './uploadcareApiClient'; - -const PUBLIC_KEY = 'demopublickey'; - -/** Polls `jobId` until its result is ready, at most `polls` times. */ -async function pollToReady(client: UploadcareApiClient, jobId: string, polls = 10): Promise { - for (let poll = 1; ; poll += 1) { - const status = await client.getJobStatus(jobId); - if ((status.status === 'success' && status.is_ready) || status.status === 'error' || poll === polls) return status; - } -} - -it("polls a generate job to a success frame with production's keys and value types", async () => { - session.use('derivativesInstant'); - const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); - - const { job_id } = await client.generate({ prompt: 'a hat', aspectRatio: [3, 2], filename: 'generated.png' }); - const done = await pollToReady(client, job_id!); - - expect(shapeOf(done)).toEqual(shapeOf(PRODUCTION_STATUS_SUCCESS)); -}); diff --git a/packages/ai-image-editor/tests/contract/derivative.contract.test.ts b/packages/ai-image-editor/tests/contract/derivative.contract.test.ts new file mode 100644 index 0000000..1286014 --- /dev/null +++ b/packages/ai-image-editor/tests/contract/derivative.contract.test.ts @@ -0,0 +1,58 @@ +import { assert, expect, it } from 'vitest'; +import { UploadcareApiClient, type UploadcareJobStatus } from '../../src/entities/provider/api/uploadcareApiClient'; +import { UploadcareDerivativeApi } from '../../src/entities/provider/api/uploadcareDerivativeApi'; +import { PRODUCTION_STATUS_SUCCESS, shapeOf } from '../fixtures/production-status-success'; +import { POLL_INTERVAL_MS, PUBLIC_KEY } from './setup'; + +/** A well-formed uuid no project holds. */ +const ABSENT_UUID = '00000000-0000-4000-8000-000000000000'; + +/** Polls `jobId` until it errors or its result is ready, for at most two minutes. */ +async function pollToEnd(client: UploadcareApiClient, jobId: string): Promise { + const deadline = Date.now() + 120_000; + for (;;) { + const status = await client.getJobStatus(jobId); + if ((status.status === 'success' && status.is_ready) || status.status === 'error' || Date.now() > deadline) + return status; + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } +} + +/** + * The one scenario that spends a generation: start it, poll it to a ready result, then read the file's info. Every + * request and response also passes the client's dev schemas (see `tests/schema-drift.ts`). + */ +it("generates an image at the requested ratio, ready in a success frame shaped like production's", async () => { + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); + + const { job_id } = await client.generate({ + prompt: 'a red apple on a white table', + aspectRatio: [3, 2], + filename: 'generated.png', + store: false, + }); + const done = await pollToEnd(client, job_id!); + + expect(shapeOf(done)).toEqual(shapeOf(PRODUCTION_STATUS_SUCCESS)); + assert(done.status === 'success'); + // Live sizes are the model's own (production drew 1248x832 for one request), so allow a pixel of rounding. + expect(done.image_info!.width / done.image_info!.height).toBeCloseTo(3 / 2, 2); + + const info = await new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY }).getFileInfo(done.uuid); + expect(info.imageInfo).toMatchObject({ width: done.image_info!.width, height: done.image_info!.height }); +}); + +it('refuses the status of a job nobody started with job_not_found', async () => { + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); + await expect(client.getJobStatus(ABSENT_UUID)).rejects.toMatchObject({ + name: 'AiProviderError', + errorCode: 'job_not_found', + }); +}); + +it('refuses an edit of a file the project does not hold with source_not_found', async () => { + const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); + await expect( + client.edit({ prompt: 'add a hat', source: ABSENT_UUID, filename: 'edited.png', store: false }), + ).rejects.toMatchObject({ name: 'AiProviderError', errorCode: 'source_not_found' }); +}); diff --git a/packages/ai-image-editor/tests/contract/setup.ts b/packages/ai-image-editor/tests/contract/setup.ts new file mode 100644 index 0000000..14e7138 --- /dev/null +++ b/packages/ai-image-editor/tests/contract/setup.ts @@ -0,0 +1,32 @@ +import { setupEmulator } from '@uploadcare/api-emulator/node'; +import { afterAll, beforeEach } from 'vitest'; +import { failOnSchemaDrift } from '../schema-drift'; + +/** + * The contract tests run the same assertions against one of two targets: by default the emulator (part of + * `npm test`), or with `UC_CONTRACT_TARGET=live` the real `upload.uploadcare.com`, so a difference between the two + * fails one of them. A live run needs `UC_CONTRACT_PUBLIC_KEY`, a project with AI derivatives enabled and signed + * uploads off; without it the run fails rather than skipping, so missing credentials never look like a pass. + */ +export const LIVE = process.env.UC_CONTRACT_TARGET === 'live'; + +function livePublicKey(): string { + const key = process.env.UC_CONTRACT_PUBLIC_KEY; + if (!key) throw new Error('UC_CONTRACT_TARGET=live needs UC_CONTRACT_PUBLIC_KEY'); + return key; +} + +export const PUBLIC_KEY = LIVE ? livePublicKey() : 'demopublickey'; + +/** Live, a job takes seconds to finish; the emulator walks it in four instant polls. */ +export const POLL_INTERVAL_MS = LIVE ? 1500 : 0; + +if (!LIVE) { + const emulator = setupEmulator(); + beforeEach(async () => { + await emulator.reset(); + }); + afterAll(() => emulator.stop()); +} + +failOnSchemaDrift(); diff --git a/packages/ai-image-editor/vitest.config.ts b/packages/ai-image-editor/vitest.config.ts index 262551b..8750877 100644 --- a/packages/ai-image-editor/vitest.config.ts +++ b/packages/ai-image-editor/vitest.config.ts @@ -17,6 +17,18 @@ export default defineConfig({ setupFiles: ['tests/specs/setup.ts'], }, }, + { + extends: true, + test: { + // The derivative API contract: against the emulator by default, against the real API with + // `UC_CONTRACT_TARGET=live` (`npm run test:contract:live`), where a generation takes a while. + name: 'contract', + include: ['tests/contract/**/*.test.ts'], + environment: 'node', + setupFiles: ['tests/contract/setup.ts'], + testTimeout: process.env.UC_CONTRACT_TARGET === 'live' ? 180_000 : undefined, + }, + }, { extends: true, // Serves msw 3's /mockServiceWorker.js, which the browser emulator registers, from this repo's msw From 68df9df2dbbfa966f782ef63146a5ea2cdfe67ce Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 20:32:51 +0300 Subject: [PATCH 069/104] test(ai-image-editor): keep the contract tests out of the browser e2e project Checklist item 4. The e2e project's tests/**/*.test.{ts,tsx} glob also picked up tests/contract/, so npm test ran the node-only contract file in Chromium too, where importing the node emulator failed the whole e2e run (9 files). The e2e project now excludes tests/contract/ on top of Vitest's default excludes. Fail-first: the full npm test failed 9 e2e files with the old glob; the e2e project passes (62 passed, 2 skipped) with the exclude. Co-Authored-By: Claude Opus 5.5 --- packages/ai-image-editor/vitest.config.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/ai-image-editor/vitest.config.ts b/packages/ai-image-editor/vitest.config.ts index 8750877..6f1c440 100644 --- a/packages/ai-image-editor/vitest.config.ts +++ b/packages/ai-image-editor/vitest.config.ts @@ -1,6 +1,6 @@ import { playwright } from '@vitest/browser-playwright'; import { msw } from 'msw/vite'; -import { defineConfig } from 'vitest/config'; +import { configDefaults, defineConfig } from 'vitest/config'; export default defineConfig({ test: { @@ -37,6 +37,8 @@ export default defineConfig({ test: { name: 'e2e', include: ['tests/**/*.test.{ts,tsx}'], + // The contract tests run in node, in their own project. + exclude: [...configDefaults.exclude, 'tests/contract/**'], setupFiles: ['tests/setup.ts'], browser: { provider: playwright(), From 2680a270bbeaaa79a8b5ba21f606c4d62aa962c9 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 20:40:18 +0300 Subject: [PATCH 070/104] fix(a11y): name the aspect-ratio listbox Checklist item 31. The picker's popover had role="listbox" and no accessible name, which ARIA requires for a listbox, so a screen reader announced an unnamed list and a test couldn't find it with getByRole('listbox', { name }). It now carries the same label as its trigger ("Pick aspect ratio" in the editor). Fail-first: the new UcAiAspectRatio spec failed with "expected null to be 'Pick aspect ratio'" before the attribute was added. Co-Authored-By: Claude Opus 5.5 --- .../features/aspect-ratio-select/ui/UcAiAspectRatio.test.ts | 5 +++++ .../src/features/aspect-ratio-select/ui/UcAiAspectRatio.ts | 5 +++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/ai-image-editor/src/features/aspect-ratio-select/ui/UcAiAspectRatio.test.ts b/packages/ai-image-editor/src/features/aspect-ratio-select/ui/UcAiAspectRatio.test.ts index 75cbf8f..b4b5753 100644 --- a/packages/ai-image-editor/src/features/aspect-ratio-select/ui/UcAiAspectRatio.test.ts +++ b/packages/ai-image-editor/src/features/aspect-ratio-select/ui/UcAiAspectRatio.test.ts @@ -34,6 +34,11 @@ describe('UcAiAspectRatio', () => { expect(selected[0]!.querySelector('.option-ratio')?.textContent).toBe('3:2'); }); + it('names the listbox after the picker', async () => { + const el = await mount({ labelText: 'Pick aspect ratio' }); + expect(el.shadowRoot!.querySelector('[role="listbox"]')?.getAttribute('aria-label')).toBe('Pick aspect ratio'); + }); + it('disables the trigger while busy', async () => { const el = await mount({ busy: true }); expect(trigger(el).disabled).toBe(true); diff --git a/packages/ai-image-editor/src/features/aspect-ratio-select/ui/UcAiAspectRatio.ts b/packages/ai-image-editor/src/features/aspect-ratio-select/ui/UcAiAspectRatio.ts index 23521c8..1db7666 100644 --- a/packages/ai-image-editor/src/features/aspect-ratio-select/ui/UcAiAspectRatio.ts +++ b/packages/ai-image-editor/src/features/aspect-ratio-select/ui/UcAiAspectRatio.ts @@ -93,6 +93,7 @@ export class UcAiAspectRatio extends LitElement { public override render(): TemplateResult { const triggerLabel = this._triggerLabel(); + const label = this.labelText ?? 'Aspect ratio'; return html` -
+
${this.options.map((option) => { const isSelected = this.selected ? aspectRatioValueEquals(option.value, this.selected) : false; From 9c6421a5b0c114f25a56d5373f615fb6de8d5a37 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 20:40:38 +0300 Subject: [PATCH 071/104] fix(a11y): announce the selected aspect ratio in the picker's name Checklist item 31. The trigger's aria-label ("Pick aspect ratio") replaced its visible content, so a screen reader never heard which ratio was selected, only that there was a picker. The name now ends with the selection the trigger shows: "Pick aspect ratio: 3:2", or "Pick aspect ratio: Auto" in edit mode. It also lets a test read the selection off the button the user sees. Fail-first: the new UcAiAspectRatio spec failed with "expected 'Pick aspect ratio' to be 'Pick aspect ratio: 3:2'" before the change. Co-Authored-By: Claude Opus 5.5 --- .../features/aspect-ratio-select/ui/UcAiAspectRatio.test.ts | 5 +++++ .../src/features/aspect-ratio-select/ui/UcAiAspectRatio.ts | 5 +++-- .../src/features/aspect-ratio-select/ui/aspect-ratio.css | 4 ++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/ai-image-editor/src/features/aspect-ratio-select/ui/UcAiAspectRatio.test.ts b/packages/ai-image-editor/src/features/aspect-ratio-select/ui/UcAiAspectRatio.test.ts index b4b5753..d94acd7 100644 --- a/packages/ai-image-editor/src/features/aspect-ratio-select/ui/UcAiAspectRatio.test.ts +++ b/packages/ai-image-editor/src/features/aspect-ratio-select/ui/UcAiAspectRatio.test.ts @@ -39,6 +39,11 @@ describe('UcAiAspectRatio', () => { expect(el.shadowRoot!.querySelector('[role="listbox"]')?.getAttribute('aria-label')).toBe('Pick aspect ratio'); }); + it('announces the selected ratio in the trigger name', async () => { + const el = await mount({ labelText: 'Pick aspect ratio' }); + expect(trigger(el).getAttribute('aria-label')).toBe('Pick aspect ratio: 3:2'); + }); + it('disables the trigger while busy', async () => { const el = await mount({ busy: true }); expect(trigger(el).disabled).toBe(true); diff --git a/packages/ai-image-editor/src/features/aspect-ratio-select/ui/UcAiAspectRatio.ts b/packages/ai-image-editor/src/features/aspect-ratio-select/ui/UcAiAspectRatio.ts index 1db7666..db77719 100644 --- a/packages/ai-image-editor/src/features/aspect-ratio-select/ui/UcAiAspectRatio.ts +++ b/packages/ai-image-editor/src/features/aspect-ratio-select/ui/UcAiAspectRatio.ts @@ -31,7 +31,8 @@ export class UcAiAspectRatio extends LitElement { public busy = false; /** - * Accessible label for the trigger. A plain property (not the inherited + * Accessible label for the trigger (which appends the current selection) and + * the listbox. A plain property (not the inherited * `ariaLabel`) so it doesn't also stamp `aria-label` on the host element. */ @property({ attribute: 'aria-label-text' }) @@ -101,7 +102,7 @@ export class UcAiAspectRatio extends LitElement { class="trigger" aria-haspopup="listbox" aria-expanded="${this.open ? 'true' : 'false'}" - aria-label="${label}" + aria-label="${triggerLabel ? `${label}: ${triggerLabel}` : label}" ?disabled=${this.busy} @click=${this._toggle} > diff --git a/packages/ai-image-editor/src/features/aspect-ratio-select/ui/aspect-ratio.css b/packages/ai-image-editor/src/features/aspect-ratio-select/ui/aspect-ratio.css index c8f89eb..3fe7303 100644 --- a/packages/ai-image-editor/src/features/aspect-ratio-select/ui/aspect-ratio.css +++ b/packages/ai-image-editor/src/features/aspect-ratio-select/ui/aspect-ratio.css @@ -22,7 +22,7 @@ } .trigger:hover, -.trigger[aria-expanded='true'] { +.trigger[aria-expanded="true"] { background: var(--uc-ai-secondary-hover); } @@ -123,7 +123,7 @@ background: var(--uc-ai-secondary); } -.option[aria-selected='true'] { +.option[aria-selected="true"] { background: var(--uc-ai-secondary-hover); } From 3f9fd5ce427bf985cf1629ff1ee7f0334265a02d Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 20:43:28 +0300 Subject: [PATCH 072/104] test(ai-image-editor): find the editor by role in the harness and the mounting tests Checklist items 31, 32, 30 and 5. The harness reached every control through querySelector and shadowRoot chains, set the textarea's value directly, and read the mode off the prompt row's `mode` prop. It now offers locators by role and accessible name (the prompt box, Generate, Done, the editor's region, the canvas image, the history chips) plus userEvent-driven fillPrompt / sendPrompt / clickDone, and a web-first expectCanvasToShow that reads the rendered . The old helpers stay until the last test file stops using them. The mounting tests use them: the mode is the region's name ("Generate image" / "Edit image") and the prompt box's ("Create image..." / "Edit image..."), and every check is an `await expect.element`, so none depends on a child having rendered by the parent's updateComplete. The import of MODES from src/internal is gone: the built-in generate presets are written out by label, and the empty-presets test first sees the toolbar so its negative can't pass on a slow render. Fail-first, each against this file: naming the region "Generate image" in both modes, or always using the generate placeholder, failed the edit-mode test; rendering the chips for an empty preset set failed the hide test; renaming the "3D character" preset failed the fallback test. Co-Authored-By: Claude Opus 5.5 --- .../ai-image-editor/tests/editor/harness.ts | 47 ++++++++++++- .../tests/editor/mounting.e2e.test.tsx | 69 ++++++++----------- 2 files changed, 75 insertions(+), 41 deletions(-) diff --git a/packages/ai-image-editor/tests/editor/harness.ts b/packages/ai-image-editor/tests/editor/harness.ts index f2445cc..eaa47c6 100644 --- a/packages/ai-image-editor/tests/editor/harness.ts +++ b/packages/ai-image-editor/tests/editor/harness.ts @@ -1,6 +1,6 @@ import { DEMO_FILES } from '@uploadcare/api-emulator'; -import { afterEach, beforeEach } from 'vitest'; -import { page } from 'vitest/browser'; +import { afterEach, beforeEach, expect } from 'vitest'; +import { page, userEvent } from 'vitest/browser'; import type { UcAiImageEditor as UcAiImageEditorType } from '../../src/index'; import { UcAiImageEditor } from '../../src/index'; import { session } from '../emulator'; @@ -50,6 +50,49 @@ export const SAMPLE_UUID = DEMO_FILES[0]; export const resultUuid = (): string | undefined => [...session.files.keys()].find((uuid) => !DEMO_FILES.includes(uuid)); +/* + * The editor as a user finds it: by role and accessible name, through its shadow roots (locators pierce open ones). + * The names are the English strings, written out so a changed label fails here rather than following the source. + */ + +/** The prompt box, named after the mode's placeholder. */ +export const promptBox = () => page.getByRole('textbox'); + +/** The prompt row's send button. It is hidden until the prompt holds text. */ +export const sendButton = () => page.getByRole('button', { name: 'Generate', exact: true }); + +/** The footer's primary, which commits the result (fires `uc:done`). */ +export const doneButton = () => page.getByRole('button', { name: 'Done', exact: true }); + +/** The editor's region, named after its mode. */ +export const editorRegion = (mode: 'generate' | 'edit') => + page.getByRole('region', { name: mode === 'edit' ? 'Edit image' : 'Generate image', exact: true }); + +/** The canvas's picture. Thumbnails and preloads are hidden from the accessibility tree, so it is the only image. */ +export const canvasImage = () => page.getByRole('img'); + +/** The result chips in the history strip, each named after the prompt that made it. */ +export const historyChips = () => page.getByRole('toolbar', { name: 'Recent prompts' }).getByRole('button'); + +export const fillPrompt = (value: string) => userEvent.fill(promptBox(), value); + +export const clickDone = () => userEvent.click(doneButton()); + +/** Types the prompt and sends it. */ +export async function sendPrompt(value: string): Promise { + await fillPrompt(value); + await userEvent.click(sendButton()); +} + +/** Waits for the run's result to land in the session and answers its uuid. */ +export async function generatedUuid(): Promise { + await expect.poll(resultUuid).toBeDefined(); + return resultUuid()!; +} + +/** Waits for the canvas to show the CDN rendition of `uuid`. */ +export const expectCanvasToShow = (uuid: string) => + expect.element(canvasImage()).toHaveAttribute('src', expect.stringContaining(`https://cdn.example.com/${uuid}/`)); export function typePrompt(el: UcAiImageEditorType, value: string): void { const input = el.shadowRoot!.querySelector('uc-ai-prompt-row')!.shadowRoot!.querySelector('textarea')!; input.value = value; diff --git a/packages/ai-image-editor/tests/editor/mounting.e2e.test.tsx b/packages/ai-image-editor/tests/editor/mounting.e2e.test.tsx index 90568e5..f99c88e 100644 --- a/packages/ai-image-editor/tests/editor/mounting.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/mounting.e2e.test.tsx @@ -1,6 +1,13 @@ -import { describe, expect, it, vi } from 'vitest'; -import { MODES } from '../../src/internal'; -import { editorMode, mount, SAMPLE_UUID, STAGING, UcAiImageEditor } from './harness'; +import { describe, expect, it } from 'vitest'; +import { page } from 'vitest/browser'; +import { doneButton, editorRegion, mount, promptBox, SAMPLE_UUID, STAGING, UcAiImageEditor } from './harness'; + +/** The quick-prompt chips, by their labels. */ +const quickPrompts = () => page.getByRole('toolbar', { name: 'Quick prompts' }).getByRole('button'); +const quickPromptLabels = () => + quickPrompts() + .elements() + .map((chip) => chip.textContent!.trim()); /** * What the element renders when it mounts, and the configuration that decides @@ -11,26 +18,23 @@ describe(' mounting and configuration', () => { expect(customElements.get('uc-ai-image-editor')).toBe(UcAiImageEditor); }); - it('mounts in generate mode and renders the canvas + prompt + chips + footer (no history strip yet)', async () => { - const el = mount(); - await el.updateComplete; - const root = el.shadowRoot; - expect(root?.querySelector('uc-ai-canvas')).toBeTruthy(); - expect(root?.querySelector('uc-ai-prompt-row')).toBeTruthy(); - expect(root?.querySelector('uc-ai-chips')).toBeTruthy(); - expect(root?.querySelector('uc-ai-footer')).toBeTruthy(); + it('mounts in generate mode with the prompt, quick prompts and footer (no history strip yet)', async () => { + mount(); + await expect.element(editorRegion('generate')).toBeVisible(); + await expect.element(promptBox()).toHaveAccessibleName('Create image...'); + await expect.element(page.getByRole('toolbar', { name: 'Quick prompts' })).toBeVisible(); + await expect.element(page.getByRole('button', { name: 'Cancel', exact: true })).toBeVisible(); + await expect.element(doneButton()).toBeVisible(); // The history strip only mounts once there are results (or in edit mode). - expect(root?.querySelector('uc-ai-history')).toBeNull(); - expect(editorMode(el)).toBe('generate'); + await expect.element(page.getByRole('toolbar', { name: 'Recent prompts' })).not.toBeInTheDocument(); }); it('derives edit mode from a source uuid, generate mode without one', async () => { const el = mount(); - await el.updateComplete; - expect(editorMode(el)).toBe('generate'); + await expect.element(editorRegion('generate')).toBeVisible(); el.sourceUuid = SAMPLE_UUID; - await el.updateComplete; - expect(editorMode(el)).toBe('edit'); + await expect.element(editorRegion('edit')).toBeVisible(); + await expect.element(promptBox()).toHaveAccessibleName('Edit image...'); }); it('renders configurable prompt presets per mode', async () => { @@ -42,52 +46,39 @@ describe(' mounting and configuration', () => { ], edit: [{ label: 'Enhance', prompt: 'Enhance it' }], }; - await el.updateComplete; - - const chipLabels = async (): Promise => { - const chips = el.shadowRoot!.querySelector('uc-ai-chips') as unknown as { - updateComplete: Promise; - shadowRoot: ShadowRoot; - } | null; - await chips?.updateComplete; - return [...(chips?.shadowRoot.querySelectorAll('.chip') ?? [])].map((c) => c.textContent!.trim()); - }; // generate mode uses presets.generate… - expect(await chipLabels()).toEqual(['Logo', 'Sticker']); + await expect.poll(quickPromptLabels).toEqual(['Logo', 'Sticker']); // …and edit mode uses presets.edit. el.sourceUuid = SAMPLE_UUID; - await el.updateComplete; - expect(await chipLabels()).toEqual(['Enhance']); + await expect.poll(quickPromptLabels).toEqual(['Enhance']); }); it('falls back to built-in presets for modes left out of the presets map', async () => { const el = mount(); el.presets = { edit: [{ label: 'Enhance', prompt: 'Enhance it' }] }; // generate omitted - await el.updateComplete; - const chips = el.shadowRoot!.querySelector('uc-ai-chips') as unknown as { shadowRoot: ShadowRoot }; - expect(chips.shadowRoot.querySelectorAll('.chip').length).toBe(MODES.generate.presets.length); + await expect + .poll(quickPromptLabels) + .toEqual(['Portrait', 'Cinematic', 'Product', 'Landscape', 'Illustration', '3D character']); }); it('hides the chips toolbar when a mode preset set is empty', async () => { const el = mount(); + await expect.element(page.getByRole('toolbar', { name: 'Quick prompts' })).toBeVisible(); el.presets = { generate: [] }; - await el.updateComplete; - expect(el.shadowRoot!.querySelector('uc-ai-chips')).toBeNull(); + await expect.element(page.getByRole('toolbar', { name: 'Quick prompts' })).not.toBeInTheDocument(); }); it('resolves locale strings from localeName + locale-keyed localeDefinitionOverride', async () => { const el = mount(STAGING); - const cancelLabel = () => el.shadowRoot!.querySelector('uc-ai-footer')!.getAttribute('cancel-label'); // Override is keyed by locale name (same shape as the uploader's config). el.localeDefinitionOverride = { en: { 'ai-image-editor-cancel': 'Dismiss' } }; - await el.updateComplete; - await vi.waitFor(() => expect(cancelLabel()).toBe('Dismiss')); + await expect.element(page.getByRole('button', { name: 'Dismiss', exact: true })).toBeVisible(); // Switching the active locale lazy-loads that locale's built-in strings. el.localeName = 'de'; - await vi.waitFor(() => expect(cancelLabel()).toBe('Abbrechen')); + await expect.element(page.getByRole('button', { name: 'Abbrechen', exact: true })).toBeVisible(); }); }); From 222759a393a6e2abc9bd465c1d4a6da59fabd567 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 20:47:20 +0300 Subject: [PATCH 073/104] test(ai-image-editor): drive the generation tests by role and wait on the rendered DOM Checklist items 31, 32, 30, 28 and 5. The generation tests set the textarea's value, clicked buttons found by internal class names (.btn--primary, .btn--ghost), read the canvas's `url` and the prompt row's `mode` props, and imported the English strings from src/shared/i18n/en. They now type and click through userEvent on locators (the prompt box, Generate, Done, Cancel), and assert with `await expect.element`: the the canvas renders, the region's name for the mode, toBeDisabled / toBeEnabled on Done, toHaveValue on the prompt box, and the alert's text, written out. Every uc:done / uc:cancel / uc:error check asserts toHaveBeenCalledOnce before reading the call. The "no uc:done" tests force-click the disabled Done, which does reach the button: with the disabled attribute and the editor's no-result guard removed, the same click fires uc:done. The abort test now also sees the run in flight (the prompt box locks) before it changes the source, and sees it end afterwards. Fail-first, one break at a time: keeping the prompt after a result; enabling Done without a result (3 tests); dropping the per-code error message; not resetting the run on a source change; ignoring the secure-delivery resolver; a wrong uuid in uc:done. Each failed its test. The skipped uc:change test only swaps to sendPrompt; un-skipping it is item 20/34's. Co-Authored-By: Claude Opus 5.5 --- .../tests/editor/generation.e2e.test.tsx | 159 +++++++----------- 1 file changed, 65 insertions(+), 94 deletions(-) diff --git a/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx b/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx index 9cdd154..9b58dfc 100644 --- a/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx @@ -1,18 +1,20 @@ import { DEMO_FILES } from '@uploadcare/api-emulator'; import { describe, expect, it, vi } from 'vitest'; -import { enLocale } from '../../src/shared/i18n/en'; +import { page, userEvent } from 'vitest/browser'; import { session } from '../emulator'; import { - canvasUrl, - clickPrimary, - clickSend, - editorMode, + canvasImage, + clickDone, + doneButton, + editorRegion, + expectCanvasToShow, + generatedUuid, mount, - primaryBtn, + promptBox, resultUuid, SAMPLE_UUID, STAGING, - typePrompt, + sendPrompt, } from './harness'; /** @@ -21,96 +23,74 @@ import { */ describe(' generation', () => { it('auto-enters edit mode after the first successful generation', async () => { - const el = mount(STAGING); - await el.updateComplete; - expect(editorMode(el)).toBe('generate'); - typePrompt(el, 'a tiger'); - await el.updateComplete; - clickSend(el); - await vi.waitFor(() => expect(canvasUrl(el)).toContain(`https://cdn.example.com/${resultUuid()}/`)); - await el.updateComplete; - expect(editorMode(el)).toBe('edit'); + mount(STAGING); + await expect.element(editorRegion('generate')).toBeVisible(); + await sendPrompt('a tiger'); + await expectCanvasToShow(await generatedUuid()); + await expect.element(editorRegion('edit')).toBeVisible(); }); it('clears the prompt after a successful generation', async () => { - const el = mount(STAGING); - await el.updateComplete; - const input = el.shadowRoot!.querySelector('uc-ai-prompt-row')!.shadowRoot!.querySelector('textarea')!; - typePrompt(el, 'a tiger'); - await el.updateComplete; - clickSend(el); - await vi.waitFor(() => expect(canvasUrl(el)).toContain(`https://cdn.example.com/${resultUuid()}/`)); - await el.updateComplete; - expect(input.value).toBe(''); + mount(STAGING); + await sendPrompt('a tiger'); + await expectCanvasToShow(await generatedUuid()); + await expect.element(promptBox()).toHaveValue(''); }); it('generates via the send button, then the primary commits the result with uc:done', async () => { const el = mount(STAGING); - await el.updateComplete; // Primary is disabled until there is a result to commit. - expect(primaryBtn(el).disabled).toBe(true); + await expect.element(doneButton()).toBeDisabled(); - typePrompt(el, 'a tiger'); - await el.updateComplete; - clickSend(el); - await vi.waitFor(() => { - expect(canvasUrl(el)).toContain(`https://cdn.example.com/${resultUuid()}/`); - }); - await el.updateComplete; + await sendPrompt('a tiger'); + const uuid = await generatedUuid(); + await expectCanvasToShow(uuid); // Now the primary commits the generated result (it never generates). - expect(primaryBtn(el).disabled).toBe(false); + await expect.element(doneButton()).toBeEnabled(); const onDone = vi.fn(); el.addEventListener('uc:done', onDone); - clickPrimary(el); - expect(onDone).toHaveBeenCalledTimes(1); + await clickDone(); + expect(onDone).toHaveBeenCalledOnce(); const detail = onDone.mock.calls[0]![0].detail; - expect(detail.url).toBe(`https://cdn.example.com/${resultUuid()}/`); - expect(detail.file.uuid).toBe(resultUuid()); - expect(detail.file.cdnUrl).toBe(`https://cdn.example.com/${resultUuid()}/`); + expect(detail.url).toBe(`https://cdn.example.com/${uuid}/`); + expect(detail.file.uuid).toBe(uuid); + expect(detail.file.cdnUrl).toBe(`https://cdn.example.com/${uuid}/`); }); it('includes the UploadcareFile and its uuid in uc:done after a generation', async () => { const el = mount(STAGING); - await el.updateComplete; const onDone = vi.fn(); el.addEventListener('uc:done', onDone); - typePrompt(el, 'make it pop'); - await el.updateComplete; - clickSend(el); - await vi.waitFor(() => expect(canvasUrl(el)).toContain(`https://cdn.example.com/${resultUuid()}/`)); - await el.updateComplete; + await sendPrompt('make it pop'); + const uuid = await generatedUuid(); + await expectCanvasToShow(uuid); - clickPrimary(el); - expect(onDone).toHaveBeenCalledTimes(1); - expect(onDone.mock.calls[0]![0].detail.file.uuid).toBe(resultUuid()); - expect(onDone.mock.calls[0]![0].detail.uuid).toBe(resultUuid()); + await clickDone(); + expect(onDone).toHaveBeenCalledOnce(); + expect(onDone.mock.calls[0]![0].detail.file.uuid).toBe(uuid); + expect(onDone.mock.calls[0]![0].detail.uuid).toBe(uuid); }); it('reports a refused run with uc:error and its message, and keeps the prompt', async () => { // derivativesInstant again, so it wraps the failure and the error comes on the first poll. session.use('derivativeFailure', { code: 'content_moderated' }).use('derivativesInstant'); const el = mount(STAGING); - await el.updateComplete; const onError = vi.fn(); el.addEventListener('uc:error', onError); - typePrompt(el, 'a tiger'); - await el.updateComplete; - clickSend(el); - await vi.waitFor(() => expect(onError).toHaveBeenCalledTimes(1)); - await el.updateComplete; + await sendPrompt('a tiger'); + await expect + .element(page.getByRole('alert')) + .toHaveTextContent("That prompt isn't allowed. Try describing it differently."); + expect(onError).toHaveBeenCalledOnce(); expect(onError.mock.calls[0]![0].detail.error.code).toBe('content_moderated'); - expect(el.shadowRoot!.querySelector('.error-box')?.textContent?.trim()).toBe( - enLocale['ai-image-editor-error-content_moderated'], - ); - expect(canvasUrl(el)).toBeNull(); - expect(editorMode(el)).toBe('generate'); - const input = el.shadowRoot!.querySelector('uc-ai-prompt-row')!.shadowRoot!.querySelector('textarea')!; - expect(input.value).toBe('a tiger'); + await expect.element(canvasImage()).not.toBeInTheDocument(); + await expect.element(editorRegion('generate')).toBeVisible(); + await expect.element(promptBox()).toHaveValue('a tiger'); }); it.skip('fires uc:change as the current result appears and clears', async () => { @@ -119,9 +99,7 @@ describe(' generation', () => { const onChange = vi.fn(); el.addEventListener('uc:change', onChange); - typePrompt(el, 'a tiger'); - await el.updateComplete; - clickSend(el); + await sendPrompt('a tiger'); await vi.waitFor(() => { expect(onChange).toHaveBeenCalledTimes(1); }); @@ -145,31 +123,29 @@ describe(' generation', () => { await el.updateComplete; const onCancel = vi.fn(); el.addEventListener('uc:cancel', onCancel); - const footer = el.shadowRoot!.querySelector('uc-ai-footer')!; - (footer.shadowRoot!.querySelector('.btn--ghost') as HTMLButtonElement).click(); - expect(onCancel).toHaveBeenCalledTimes(1); + await userEvent.click(page.getByRole('button', { name: 'Cancel', exact: true })); + expect(onCancel).toHaveBeenCalledOnce(); }); it('keeps the primary disabled and fires no uc:done until a result exists (edit mode with a source)', async () => { const el = mount(STAGING); el.sourceUuid = SAMPLE_UUID; - await el.updateComplete; - expect(editorMode(el)).toBe('edit'); + await expect.element(editorRegion('edit')).toBeVisible(); + await expectCanvasToShow(SAMPLE_UUID); const onDone = vi.fn(); el.addEventListener('uc:done', onDone); // A source image alone is not a result — the primary commits results only. - expect(primaryBtn(el).disabled).toBe(true); - clickPrimary(el); + await expect.element(doneButton()).toBeDisabled(); + await userEvent.click(doneButton(), { force: true }); expect(onDone).not.toHaveBeenCalled(); }); it('does not dispatch uc:done when there is no result (generate mode)', async () => { const el = mount(); - await el.updateComplete; const onDone = vi.fn(); el.addEventListener('uc:done', onDone); - expect(primaryBtn(el).disabled).toBe(true); - primaryBtn(el).click(); + await expect.element(doneButton()).toBeDisabled(); + await userEvent.click(doneButton(), { force: true }); expect(onDone).not.toHaveBeenCalled(); }); @@ -177,34 +153,29 @@ describe(' generation', () => { const el = mount(STAGING); // The signed URL stays on the emulated CDN, so the preview loads instead of reaching for a proxy that doesn't exist. el.secureDeliveryProxyUrlResolver = async (url: string) => `${url}?token=signed`; - await el.updateComplete; - typePrompt(el, 'a tiger'); - await el.updateComplete; - clickSend(el); - await vi.waitFor(() => { - const url = canvasUrl(el); - expect(url).toContain(`https://cdn.example.com/${resultUuid()}/`); - expect(url).toMatch(/\?token=signed$/); - }); + await sendPrompt('a tiger'); + const uuid = await generatedUuid(); + await expect + .element(canvasImage()) + .toHaveAttribute( + 'src', + expect.stringMatching(new RegExp(`^https://cdn\\.example\\.com/${uuid}/.*\\?token=signed$`)), + ); }); it('aborts in-flight generation and shows the new source when source changes', async () => { // The status poll never answers, so the job is still running when the source changes. session.on('GET /derivative/status/', () => new Promise(() => {})); const el = mount({ ...STAGING, 'source-uuid': DEMO_FILES[0] }); - await el.updateComplete; - - typePrompt(el, 'try'); - await el.updateComplete; - clickSend(el); + await sendPrompt('try'); + // The prompt box locks while the run is in flight. + await expect.element(promptBox()).toBeDisabled(); // Change source mid-flight — this aborts the in-flight generation. el.sourceUuid = DEMO_FILES[1]; - await el.updateComplete; // After the abort, the displayed image should be the new source (no result override). - await vi.waitFor(() => { - expect(canvasUrl(el)).toContain(`https://cdn.example.com/${DEMO_FILES[1]}/`); - }); + await expectCanvasToShow(DEMO_FILES[1]!); + await expect.element(promptBox()).toBeEnabled(); }); }); From 349e9dfc67bd5620e05ea456f3c13e02621b7c94 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 20:50:31 +0300 Subject: [PATCH 074/104] test(ai-image-editor): read the history strip's chips, not its entries prop Checklist items 31, 32, 30 and 28. The history tests counted the strip's `entries` prop, found the selected chip by its .chip--selected class, checked the original with `.some(...)).toBe(true)`, and read the canvas's `url` prop. They now find the chips by role in the "Recent prompts" toolbar and assert what renders: the chip count, its name (the prompt), aria-pressed for the selection, the original's thumbnail src, and the canvas , all through `await expect.element` or `expect.poll`. The lineage test seeds its "latest" result as a second demo image instead of a made-up uuid: the canvas only shows an image the CDN can serve, so the old uuid could be asserted only on the prop. The skipped Start over test moves to the same helpers; un-skipping it is item 20/34's. Fail-first, one break at a time: aria-pressed always false (2 tests); every chip named "Result"; the original's thumbnail built from another uuid; never resuming on the lineage's latest result; an empty strip (3 tests). Each failed. Co-Authored-By: Claude Opus 5.5 --- .../tests/editor/history.e2e.test.tsx | 84 ++++++++----------- 1 file changed, 33 insertions(+), 51 deletions(-) diff --git a/packages/ai-image-editor/tests/editor/history.e2e.test.tsx b/packages/ai-image-editor/tests/editor/history.e2e.test.tsx index 26151e1..304778a 100644 --- a/packages/ai-image-editor/tests/editor/history.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/history.e2e.test.tsx @@ -1,14 +1,18 @@ +import { DEMO_FILES } from '@uploadcare/api-emulator'; import type { UploadcareFile } from '@uploadcare/upload-client'; import { describe, expect, it, vi } from 'vitest'; +import { page, userEvent } from 'vitest/browser'; import { - canvasUrl, - clickSend, + canvasImage, editorMode, + editorRegion, + expectCanvasToShow, + historyChips, historyEl, mount, SAMPLE_UUID, STAGING, - typePrompt, + sendPrompt, } from './harness'; /** @@ -17,30 +21,17 @@ import { */ describe(' history', () => { it('populates the history strip after a successful generation', async () => { - const el = mount(STAGING); - await el.updateComplete; - typePrompt(el, 'a tiger'); - await el.updateComplete; - clickSend(el); - await vi.waitFor(() => { - expect(historyEl(el)?.entries.length).toBe(1); - }); + mount(STAGING); + await sendPrompt('a tiger'); + await expect.poll(() => historyChips().elements()).toHaveLength(1); }); - it('shows the generated result as a selectable history chip', async () => { - const el = mount(STAGING); - await el.updateComplete; - typePrompt(el, 'a tiger'); - await el.updateComplete; - clickSend(el); - await vi.waitFor(() => expect(historyEl(el)?.entries.length).toBe(1)); - await el.updateComplete; - - const history = historyEl(el)!; - const chips = history.shadowRoot!.querySelectorAll('.chip'); - expect(chips.length).toBe(1); + it('shows the generated result as a selected history chip named after its prompt', async () => { + mount(STAGING); + await sendPrompt('a tiger'); // The current result's chip is marked selected. - expect(history.shadowRoot!.querySelector('.chip--selected')).toBeTruthy(); + await expect.element(historyChips().first()).toHaveAccessibleName('a tiger'); + await expect.element(historyChips().first()).toHaveAttribute('aria-pressed', 'true'); }); it('shows and persists the original image in the history strip when editing (plugin path)', async () => { @@ -48,16 +39,14 @@ describe(' history', () => { const el = mount(STAGING); // The plugin hands the editor the source's file info directly. el.sourceFileInfo = { uuid: SAMPLE_UUID } as UploadcareFile; - await el.updateComplete; - await vi.waitFor(() => expect(editorMode(el)).toBe('edit')); + await expect.element(editorRegion('edit')).toBeVisible(); // The strip mounts with the original as its base entry — the starting point - // you can revert to — once the source's display url has resolved. - await vi.waitFor(() => { - const strip = historyEl(el) as (HTMLElement & { entries: Array<{ file: { uuid: string } }> }) | null; - expect(strip).not.toBeNull(); - expect(strip!.entries.some((entry) => entry.file.uuid === SAMPLE_UUID)).toBe(true); - }); + // you can revert to — once the source's display url has resolved: one + // selected chip showing the source's thumbnail. + await expect.poll(() => historyChips().elements()).toHaveLength(1); + await expect.element(historyChips().first()).toHaveAttribute('aria-pressed', 'true'); + await expect.poll(() => historyChips().element().querySelector('img')?.src).toContain(`/${SAMPLE_UUID}/`); // …and the original is persisted as a root node so it survives a reload. const stored = JSON.parse(localStorage.getItem(`uc-ai-image-editor/history/${STAGING.pubkey}`) ?? '{}'); @@ -65,7 +54,8 @@ describe(' history', () => { }); it('resumes on the latest result (not the original) when reopening a lineage', async () => { - const LATEST = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; + // A seeded image, so the CDN serves it and the canvas can show it. + const LATEST = DEMO_FILES[1]!; const key = `uc-ai-image-editor/history/${STAGING.pubkey}`; // Seed a prior edit of SAMPLE_UUID into storage (a result whose parent is it). localStorage.setItem( @@ -86,12 +76,11 @@ describe(' history', () => { const el = mount(STAGING); el.sourceFileInfo = { uuid: SAMPLE_UUID } as UploadcareFile; - await el.updateComplete; - await vi.waitFor(() => expect(editorMode(el)).toBe('edit')); + await expect.element(editorRegion('edit')).toBeVisible(); // The canvas (and shimmer) resume on the latest result, not the original. - await vi.waitFor(() => expect(canvasUrl(el)).toContain(LATEST)); - expect(canvasUrl(el)).not.toContain(SAMPLE_UUID); + await expectCanvasToShow(LATEST); + await expect.element(canvasImage()).not.toHaveAttribute('src', expect.stringContaining(SAMPLE_UUID)); }); it('does not render Start over in edit mode opened with a source (uploader AI-edit)', async () => { @@ -109,21 +98,14 @@ describe(' history', () => { }); it.skip('returns to generate mode after Start over (from the history strip)', async () => { - const el = mount(STAGING); - await el.updateComplete; - typePrompt(el, 'a tiger'); - await el.updateComplete; - clickSend(el); - await vi.waitFor(() => expect(editorMode(el)).toBe('edit')); - await el.updateComplete; + mount(STAGING); + await sendPrompt('a tiger'); + await expect.element(editorRegion('edit')).toBeVisible(); - const history = historyEl(el)!; - const startOver = history.shadowRoot!.querySelector('.startover__btn') as HTMLButtonElement; - startOver.click(); - await el.updateComplete; - expect(editorMode(el)).toBe('generate'); - expect(canvasUrl(el)).toBeNull(); + await userEvent.click(page.getByRole('button', { name: 'Start over' })); + await expect.element(editorRegion('generate')).toBeVisible(); + await expect.element(canvasImage()).not.toBeInTheDocument(); // Start over also clears the prompt history (the strip unmounts). - await vi.waitFor(() => expect(historyEl(el)).toBeNull()); + await expect.element(page.getByRole('toolbar', { name: 'Recent prompts' })).not.toBeInTheDocument(); }); }); From b6f9756cf544e3eb0c6a08ce847db33921492d48 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 20:51:06 +0300 Subject: [PATCH 075/104] test(ai-image-editor): delete the Start over absence test, which can't fail Checklist items 30 and 9. "does not render Start over in edit mode opened with a source" asserted that no Start over button renders. No template renders one in any mode: the editor passes a constant `showStartOver = false`, and UcAiHistory's render has no Start over button at all (only its CSS and label property remain). No change to the editor can make the assertion fail short of adding the button, so it guarded nothing. When Start over comes back, the skipped "returns to generate mode after Start over" test covers it. Co-Authored-By: Claude Opus 5.5 --- .../tests/editor/history.e2e.test.tsx | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/packages/ai-image-editor/tests/editor/history.e2e.test.tsx b/packages/ai-image-editor/tests/editor/history.e2e.test.tsx index 304778a..37d449b 100644 --- a/packages/ai-image-editor/tests/editor/history.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/history.e2e.test.tsx @@ -1,14 +1,12 @@ import { DEMO_FILES } from '@uploadcare/api-emulator'; import type { UploadcareFile } from '@uploadcare/upload-client'; -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { page, userEvent } from 'vitest/browser'; import { canvasImage, - editorMode, editorRegion, expectCanvasToShow, historyChips, - historyEl, mount, SAMPLE_UUID, STAGING, @@ -83,20 +81,6 @@ describe(' history', () => { await expect.element(canvasImage()).not.toHaveAttribute('src', expect.stringContaining(SAMPLE_UUID)); }); - it('does not render Start over in edit mode opened with a source (uploader AI-edit)', async () => { - const el = mount(STAGING); - el.sourceUuid = SAMPLE_UUID; - await el.updateComplete; - await vi.waitFor(() => expect(editorMode(el)).toBe('edit')); - await el.updateComplete; - - // Editing an existing image has nothing to "start over" to — the affordance - // is absent (and, with no generation history yet, the strip isn't mounted). - const history = historyEl(el); - const startOver = history?.shadowRoot?.querySelector('.startover__btn') ?? null; - expect(startOver).toBeNull(); - }); - it.skip('returns to generate mode after Start over (from the history strip)', async () => { mount(STAGING); await sendPrompt('a tiger'); From 46ef97c4e5f10aa0aec62649703d5bdb298af36b Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 20:54:33 +0300 Subject: [PATCH 076/104] test(ai-image-editor): pick ratios by role and click the history chip to restore one Checklist items 31, 32, 30, 28 and 5. The ratio tests clicked the picker's .trigger / .option by class, counted options with `.length).toBe(2)`, read the picker's `selected` prop and the history's `entries[0].ratio`, and restored a ratio by dispatching a synthetic uc:select from the strip. They now open the picker by its name, pick options in the "Pick aspect ratio" listbox by role, read the selection off the trigger's accessible name ("Pick aspect ratio: 1:1"), and restore a ratio by clicking the history chip, the way a user does. The restore test also checks the committed image is square. The generate-mode test claimed 3:2 was neither the picker's default nor the fallback, but 3:2 is DEFAULT_GENERATE_RATIO, so the pick was a no-op: with the editor ignoring picks the test still passed. It now offers "3:2 16:9" and picks 16:9 (asserted with toBeCloseTo, since the emulator rounds pixels). Fail-first, one break at a time: ignoring picks (now 3 tests, the generate one included); not restoring the entry's ratio on select; offering Auto in generate mode; defaulting edit mode to a concrete ratio; never sending aspect_ratio (2 tests). Co-Authored-By: Claude Opus 5.5 --- .../tests/editor/aspect-ratio.e2e.test.tsx | 151 +++++++----------- 1 file changed, 60 insertions(+), 91 deletions(-) diff --git a/packages/ai-image-editor/tests/editor/aspect-ratio.e2e.test.tsx b/packages/ai-image-editor/tests/editor/aspect-ratio.e2e.test.tsx index d06e9ff..c565d34 100644 --- a/packages/ai-image-editor/tests/editor/aspect-ratio.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/aspect-ratio.e2e.test.tsx @@ -1,34 +1,40 @@ import type { UploadcareFile } from '@uploadcare/upload-client'; import { describe, expect, it, vi } from 'vitest'; +import { page, userEvent } from 'vitest/browser'; import { session } from '../emulator'; import { - canvasUrl, - clickPrimary, - clickSend, - editorMode, + clickDone, + editorRegion, + expectCanvasToShow, + generatedUuid, + historyChips, mount, - resultUuid, SAMPLE_UUID, STAGING, - typePrompt, + sendPrompt, type UcAiImageEditorType, } from './harness'; +/** The picker's trigger, named "Pick aspect ratio: ". */ +const ratioTrigger = () => page.getByRole('button', { name: /^Pick aspect ratio/ }); +const ratioOptions = () => page.getByRole('listbox', { name: 'Pick aspect ratio' }).getByRole('option'); + +/** Opens the picker and picks the option named `name`. */ +async function pickRatio(name: string): Promise { + await userEvent.click(ratioTrigger()); + await userEvent.click(ratioOptions().filter({ hasText: name })); +} + /** * Waits for the run's result on the canvas, commits it, and returns the image info of the file `uc:done` hands the * host. The emulator draws a result at the requested ratio (or keeps the source's size when none is sent), so the * dimensions show which ratio the editor asked for. */ async function committedImage(el: UcAiImageEditorType): Promise { - await vi.waitFor(() => { - const uuid = resultUuid(); - expect(uuid).toBeDefined(); - expect(canvasUrl(el)).toContain(`/${uuid}/`); - }); - await el.updateComplete; + await expectCanvasToShow(await generatedUuid()); const onDone = vi.fn(); el.addEventListener('uc:done', onDone); - clickPrimary(el); + await clickDone(); expect(onDone).toHaveBeenCalledOnce(); return (onDone.mock.calls[0]![0] as CustomEvent<{ file: UploadcareFile }>).detail.file.imageInfo; } @@ -39,46 +45,35 @@ const ratioOf = (image: UploadcareFile['imageInfo']) => (image?.width ?? Number. /** The ratio picker: what it offers per mode, the shape of the image it produces, what it restores. */ describe(' aspect ratio', () => { it('renders the aspect-ratio picker in generate mode (no Auto) and generates at the selected ratio', async () => { - // 3:2: neither the picker's default (the first ratio) nor the provider's fallback (1:1). - const el = mount({ ...STAGING, 'aspect-ratios': '16:9 3:2' }); - await el.updateComplete; - - const ratio = el.shadowRoot!.querySelector('uc-ai-aspect-ratio')!; - expect(ratio).toBeTruthy(); + // 16:9: neither the generate default (3:2) nor the provider's fallback (1:1). + const el = mount({ ...STAGING, 'aspect-ratios': '3:2 16:9' }); // Generate mode offers only the standard ratios — no "Auto". - (ratio.shadowRoot!.querySelector('.trigger') as HTMLButtonElement).click(); - await el.updateComplete; - const options = Array.from(ratio.shadowRoot!.querySelectorAll('.option')) as HTMLButtonElement[]; - expect(options.length).toBe(2); - - // Pick the second option (3:2) before the only generate — sending flips to edit. - options[1]!.click(); - await el.updateComplete; - typePrompt(el, 'mountain'); - await el.updateComplete; - clickSend(el); - expect(ratioOf(await committedImage(el))).toBe(3 / 2); + await userEvent.click(ratioTrigger()); + await expect.poll(() => ratioOptions().elements()).toHaveLength(2); + await expect.element(ratioOptions().first()).toHaveTextContent('3:2'); + await expect.element(ratioOptions().last()).toHaveTextContent('16:9'); + + // Pick 16:9 before the only generate — sending flips to edit. + await userEvent.click(ratioOptions().last()); + await expect.element(ratioTrigger()).toHaveAccessibleName('Pick aspect ratio: 16:9'); + await sendPrompt('mountain'); + expect(ratioOf(await committedImage(el))).toBeCloseTo(16 / 9, 2); }); it('defaults edit mode to "Auto", which keeps the dimensions of the source image', async () => { const el = mount(STAGING); el.sourceUuid = SAMPLE_UUID; - await el.updateComplete; - expect(editorMode(el)).toBe('edit'); - - // The picker leads with an "Auto" entry (square icon, no w:h numbers). - const ratio = el.shadowRoot!.querySelector('uc-ai-aspect-ratio')!; - (ratio.shadowRoot!.querySelector('.trigger') as HTMLButtonElement).click(); - await el.updateComplete; - const options = Array.from(ratio.shadowRoot!.querySelectorAll('.option')) as HTMLButtonElement[]; - expect(options[0]!.textContent).toContain('Auto'); - // "Auto" reserves the ratio column but shows no "w:h" numbers. - expect(options[0]!.querySelector('.option-ratio')?.textContent).toBe(''); - - typePrompt(el, 'add a hat'); - await el.updateComplete; - clickSend(el); + await expect.element(editorRegion('edit')).toBeVisible(); + await expect.element(ratioTrigger()).toHaveAccessibleName('Pick aspect ratio: Auto'); + + // The picker leads with an "Auto" entry, named without any w:h numbers. + await userEvent.click(ratioTrigger()); + await expect.element(ratioOptions().first()).toHaveAccessibleName('Auto'); + await expect.element(ratioOptions().first()).toHaveAttribute('aria-selected', 'true'); + await userEvent.keyboard('{Escape}'); + + await sendPrompt('add a hat'); // Auto is the default → no aspect_ratio is sent, so the result keeps the source's size. const source = session.files.get(SAMPLE_UUID)!.image!; expect(await committedImage(el)).toMatchObject({ width: source.width, height: source.height }); @@ -87,69 +82,43 @@ describe(' aspect ratio', () => { it('renders the aspect-ratio picker in edit mode too', async () => { const el = mount(STAGING); el.sourceUuid = SAMPLE_UUID; - await el.updateComplete; - expect(editorMode(el)).toBe('edit'); - expect(el.shadowRoot!.querySelector('uc-ai-aspect-ratio')).toBeTruthy(); + await expect.element(editorRegion('edit')).toBeVisible(); + await expect.element(ratioTrigger()).toBeVisible(); }); it('reshapes the result to a ratio the user picks in edit mode', async () => { const el = mount({ ...STAGING, 'aspect-ratios': '1:1' }); el.sourceUuid = SAMPLE_UUID; - await el.updateComplete; + await expect.element(editorRegion('edit')).toBeVisible(); - const ratio = el.shadowRoot!.querySelector('uc-ai-aspect-ratio')!; - (ratio.shadowRoot!.querySelector('.trigger') as HTMLButtonElement).click(); - await el.updateComplete; // [Auto, 1:1] in edit mode — pick the concrete ratio to reshape. - const options = Array.from(ratio.shadowRoot!.querySelectorAll('.option')) as HTMLButtonElement[]; - expect(options.length).toBe(2); - options[1]!.click(); - await el.updateComplete; - - typePrompt(el, 'make it square'); - await el.updateComplete; - clickSend(el); + await userEvent.click(ratioTrigger()); + await expect.poll(() => ratioOptions().elements()).toHaveLength(2); + await userEvent.click(ratioOptions().filter({ hasText: '1:1' })); + await expect.element(ratioTrigger()).toHaveAccessibleName('Pick aspect ratio: 1:1'); + + await sendPrompt('make it square'); // The source is not square, so a square result means the 1:1 was sent rather than Auto. expect(ratioOf(await committedImage(el))).toBe(1); }); it('records the aspect ratio on a history entry and restores it when re-selected', async () => { const el = mount({ ...STAGING, 'aspect-ratios': '16:9 1:1' }); - await el.updateComplete; - - const ratioEl = () => el.shadowRoot!.querySelector('uc-ai-aspect-ratio')!; - const selected = () => (ratioEl() as unknown as { selected: unknown }).selected; - const pickOption = async (i: number) => { - (ratioEl().shadowRoot!.querySelector('.trigger') as HTMLButtonElement).click(); - await el.updateComplete; - (ratioEl().shadowRoot!.querySelectorAll('.option')[i] as HTMLButtonElement).click(); - await el.updateComplete; - }; // Generate with 1:1 (generate options are [16:9, 1:1]). - await pickOption(1); - expect(selected()).toEqual([1, 1]); - typePrompt(el, 'a cat'); - await el.updateComplete; - clickSend(el); - await vi.waitFor(() => expect(editorMode(el)).toBe('edit')); - await el.updateComplete; - - const history = el.shadowRoot!.querySelector('uc-ai-history') as unknown as { - entries: Array<{ ratio: unknown }>; - }; - expect(history.entries[0]!.ratio).toEqual([1, 1]); + await pickRatio('1:1'); + await expect.element(ratioTrigger()).toHaveAccessibleName('Pick aspect ratio: 1:1'); + await sendPrompt('a cat'); + await expect.element(editorRegion('edit')).toBeVisible(); + await expect.element(historyChips().first()).toHaveAccessibleName('a cat'); + expect(ratioOf(await committedImage(el))).toBe(1); // Change the ratio after the fact (edit options are [Auto, 16:9, 1:1]). - await pickOption(1); - expect(selected()).toEqual([16, 9]); + await pickRatio('16:9'); + await expect.element(ratioTrigger()).toHaveAccessibleName('Pick aspect ratio: 16:9'); // Re-selecting the history entry restores the 1:1 ratio it was made with. - const entry = history.entries[0]; - el.shadowRoot!.querySelector('uc-ai-history')!.dispatchEvent( - new CustomEvent('uc:select', { detail: { entry }, bubbles: true, composed: true }), - ); - await el.updateComplete; - expect(selected()).toEqual([1, 1]); + await userEvent.click(historyChips().first()); + await expect.element(ratioTrigger()).toHaveAccessibleName('Pick aspect ratio: 1:1'); }); }); From 1e02b8914e25e7e462df7d81605b2652ac9e3b04 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 20:56:58 +0300 Subject: [PATCH 077/104] test(ai-image-editor): wait for each authToken run's history chip by its prompt Checklist items 31, 32, 30 and 28. The authToken tests' generate() counted the history's `entries` prop before and after a run, and both helpers typed by setting the textarea's value. generate() now sends the prompt through userEvent and waits, with `await expect.element`, for the history chip named after that prompt (a new harness historyChip()), so the second run of a test is awaited by its own chip rather than by a count. The single-call checks use toHaveBeenCalledOnce. Fail-first: naming every history chip "Result" failed the 6 tests that wait on a successful run. Co-Authored-By: Claude Opus 5.5 --- .../tests/editor/auth-token.e2e.test.tsx | 50 ++++++++----------- .../ai-image-editor/tests/editor/harness.ts | 4 ++ 2 files changed, 26 insertions(+), 28 deletions(-) diff --git a/packages/ai-image-editor/tests/editor/auth-token.e2e.test.tsx b/packages/ai-image-editor/tests/editor/auth-token.e2e.test.tsx index 0b27a1f..548a359 100644 --- a/packages/ai-image-editor/tests/editor/auth-token.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/auth-token.e2e.test.tsx @@ -2,7 +2,7 @@ import { mintAuthToken } from '@uploadcare/api-emulator'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { session } from '../emulator'; import type { UcAiImageEditorType } from './harness'; -import { clickSend, historyEl, mount, STAGING, typePrompt } from './harness'; +import { historyChip, mount, STAGING, sendPrompt } from './harness'; /** * The project here has Signed Uploads on, so the emulator refuses every @@ -17,28 +17,22 @@ describe(' authToken', () => { }); /** - * Runs one generation and waits for it to land in the history, counted rather - * than observed through the canvas: after the first run the canvas already - * holds a result URL, so waiting on it would return immediately and the - * second generation would never be awaited. + * Runs one generation and waits for its chip, named after the prompt, in the + * history: after the first run the canvas already holds a result, so waiting + * on it would return immediately and the second generation would never be + * awaited. */ - const generate = async (el: UcAiImageEditorType, prompt: string) => { - const before = historyEl(el)?.entries.length ?? 0; - typePrompt(el, prompt); - await el.updateComplete; - clickSend(el); - await vi.waitFor(() => expect(historyEl(el)?.entries).toHaveLength(before + 1)); - await el.updateComplete; + const generate = async (prompt: string) => { + await sendPrompt(prompt); + await expect.element(historyChip(prompt)).toBeVisible(); }; /** Runs one generation and answers the `uc:error` the signing gate's refusal raises. */ const generateRefused = async (el: UcAiImageEditorType, prompt: string) => { const onError = vi.fn(); el.addEventListener('uc:error', onError); - typePrompt(el, prompt); - await el.updateComplete; - clickSend(el); - await vi.waitFor(() => expect(onError).toHaveBeenCalledTimes(1)); + await sendPrompt(prompt); + await vi.waitFor(() => expect(onError).toHaveBeenCalledOnce()); el.removeEventListener('uc:error', onError); return onError.mock.calls[0]![0].detail.error; }; @@ -61,7 +55,7 @@ describe(' authToken', () => { el.authToken = await mintAuthToken({ tokenId: 'plain' }); await el.updateComplete; - await generate(el, 'a lion'); + await generate('a lion'); el.authToken = undefined; await el.updateComplete; @@ -77,9 +71,9 @@ describe(' authToken', () => { el.authToken = fetchToken; await el.updateComplete; - await generate(el, 'a tiger'); + await generate('a tiger'); - expect(fetchToken).toHaveBeenCalledTimes(1); + expect(fetchToken).toHaveBeenCalledOnce(); }); it('keeps the cached token when the function identity changes', async () => { @@ -92,14 +86,14 @@ describe(' authToken', () => { const el = mount(STAGING); el.authToken = first; await el.updateComplete; - await generate(el, 'a tiger'); + await generate('a tiger'); const second = vi.fn(async () => secondToken); el.authToken = second; await el.updateComplete; - await generate(el, 'a lion'); + await generate('a lion'); - expect(first).toHaveBeenCalledTimes(1); + expect(first).toHaveBeenCalledOnce(); expect(second).not.toHaveBeenCalled(); }); @@ -111,11 +105,11 @@ describe(' authToken', () => { const el = mount(STAGING); el.authToken = fetchToken; await el.updateComplete; - await generate(el, 'a tiger'); - expect(fetchToken).toHaveBeenCalledTimes(1); + await generate('a tiger'); + expect(fetchToken).toHaveBeenCalledOnce(); el.invalidateAuthToken(); - await generate(el, 'a lion'); + await generate('a lion'); expect(fetchToken).toHaveBeenCalledTimes(2); }); @@ -130,7 +124,7 @@ describe(' authToken', () => { el.authToken = fetchToken; await el.updateComplete; - await generate(el, 'a tiger'); + await generate('a tiger'); expect(fetchToken.mock.calls.length).toBeGreaterThan(1); }); @@ -145,7 +139,7 @@ describe(' authToken', () => { el.authToken = { getToken, invalidate }; await el.updateComplete; - await generate(el, 'a tiger'); + await generate('a tiger'); // Called per request: the provider decides what to cache, not the editor. expect(getToken.mock.calls.length).toBeGreaterThan(1); @@ -160,6 +154,6 @@ describe(' authToken', () => { el.invalidateAuthToken(); - expect(invalidate).toHaveBeenCalledTimes(1); + expect(invalidate).toHaveBeenCalledOnce(); }); }); diff --git a/packages/ai-image-editor/tests/editor/harness.ts b/packages/ai-image-editor/tests/editor/harness.ts index eaa47c6..48ffeea 100644 --- a/packages/ai-image-editor/tests/editor/harness.ts +++ b/packages/ai-image-editor/tests/editor/harness.ts @@ -74,6 +74,10 @@ export const canvasImage = () => page.getByRole('img'); /** The result chips in the history strip, each named after the prompt that made it. */ export const historyChips = () => page.getByRole('toolbar', { name: 'Recent prompts' }).getByRole('button'); +/** The history chip of the run that `prompt` made. */ +export const historyChip = (prompt: string) => + page.getByRole('toolbar', { name: 'Recent prompts' }).getByRole('button', { name: prompt, exact: true }); + export const fillPrompt = (value: string) => userEvent.fill(promptBox(), value); export const clickDone = () => userEvent.click(doneButton()); From 2a207bcdd4dba42c7682eb1bf4c6cd684b59d244 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 20:57:27 +0300 Subject: [PATCH 078/104] test(ai-image-editor): assert uc:done fired before reading the committed filename Checklist items 28, 31, 32 and 30. producedFilename() read `onDone.mock.calls[0]![0]` straight after clicking Done, so a missing uc:done surfaced as "Cannot read properties of undefined". It now asserts toHaveBeenCalledOnce first. It also sends the prompt and clicks Done through userEvent on locators, waits with `await expect.element(doneButton()).toBeEnabled()` instead of polling the button's `disabled` with toBe(false), and checks edit mode by the region's name instead of the prompt row's `mode` prop. The imports are sorted, which biome check flagged. Fail-first: with the editor's uc:done dispatch skipped, all 3 tests fail with "expected "vi.fn()" to be called once, but got 0 times". Co-Authored-By: Claude Opus 5.5 --- .../tests/editor/filename.e2e.test.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/ai-image-editor/tests/editor/filename.e2e.test.tsx b/packages/ai-image-editor/tests/editor/filename.e2e.test.tsx index e982351..759b988 100644 --- a/packages/ai-image-editor/tests/editor/filename.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/filename.e2e.test.tsx @@ -1,8 +1,8 @@ -import type { UploadcareFile } from '@uploadcare/upload-client'; import { DEMO_FILES } from '@uploadcare/api-emulator'; +import type { UploadcareFile } from '@uploadcare/upload-client'; import { describe, expect, it, vi } from 'vitest'; import type { UcAiImageEditorType } from './harness'; -import { clickPrimary, clickSend, editorMode, mount, primaryBtn, SAMPLE_UUID, STAGING, typePrompt } from './harness'; +import { clickDone, doneButton, editorRegion, mount, SAMPLE_UUID, STAGING, sendPrompt } from './harness'; /** * What the result is named: the source's name, a resolver, or a fixed string. @@ -12,14 +12,13 @@ import { clickPrimary, clickSend, editorMode, mount, primaryBtn, SAMPLE_UUID, ST describe(' result filename', () => { /** Runs the test's one generation, commits it, and answers the committed file's name. */ const producedFilename = async (el: UcAiImageEditorType, prompt: string) => { - typePrompt(el, prompt); - await el.updateComplete; - clickSend(el); + await sendPrompt(prompt); // The primary commits a result, so it is enabled once the run has one. - await vi.waitFor(() => expect(primaryBtn(el).disabled).toBe(false)); + await expect.element(doneButton()).toBeEnabled(); const onDone = vi.fn(); el.addEventListener('uc:done', onDone); - clickPrimary(el); + await clickDone(); + expect(onDone).toHaveBeenCalledOnce(); return (onDone.mock.calls[0]![0].detail.file as UploadcareFile).originalFilename; }; @@ -32,8 +31,7 @@ describe(' result filename', () => { originalFilename: 'holiday-photo.jpg', imageInfo: null, } as unknown as UploadcareFile; - await el.updateComplete; - expect(editorMode(el)).toBe('edit'); + await expect.element(editorRegion('edit')).toBeVisible(); // The edit result is named after the source, not the provider's default. expect(await producedFilename(el, 'add a hat')).toBe('holiday-photo.jpg'); From 391d7d09217df6491dba18720f737fc9f79f671b Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 21:03:01 +0300 Subject: [PATCH 079/104] test(ai-image-editor): measure the layout tests on rendered boxes, not class names Checklist items 28, 30 and 31. The layout tests asserted internal classes (.composer--overlay-bottom, .composer--docked, .history-pinned--canvas-top, .dock-hotzone--bottom) and DOM order with 28 bare `toBe(true)` checks, so they pinned the template's class vocabulary rather than where things appear. They now locate the prompt box, the history strip and Done by role and measure them against the canvas's box: wholly above or below it, or over its top or bottom half. The auto-hide tests check what auto-hide does. An overlay composer slides off the canvas to a peek and rises wholly back over it when the pointer nears the bottom edge (the hotzone), not on the composer. A docked one slides down inside its slot and rises on hover, and the canvas keeps its size. With auto-hide off, a docked and an overlay prompt both stay shown; those negatives run after the editor's CSS transitions finish, not on the first frame. Fail-first, one break at a time: swapping the overlay edge (5 tests); always docking at the bottom; ignoring toolbar-placement top; ignoring composer-below; never pinning the history; dropping the hotzone; hiding an overlay or a docked composer without auto-hide; never sliding an overlay out (2 tests); never collapsing a docked one. Each failed its test. Co-Authored-By: Claude Opus 5.5 --- .../tests/editor/layout.e2e.test.tsx | 264 +++++++++--------- 1 file changed, 136 insertions(+), 128 deletions(-) diff --git a/packages/ai-image-editor/tests/editor/layout.e2e.test.tsx b/packages/ai-image-editor/tests/editor/layout.e2e.test.tsx index 6621edc..9633661 100644 --- a/packages/ai-image-editor/tests/editor/layout.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/layout.e2e.test.tsx @@ -1,193 +1,206 @@ -import { describe, expect, it, vi } from 'vitest'; -import { canvasUrl, clickSend, editorMode, mount, SAMPLE_UUID, STAGING, typePrompt } from './harness'; +import { describe, expect, it } from 'vitest'; +import { type Locator, page, userEvent } from 'vitest/browser'; +import type { UcAiImageEditorType } from './harness'; +import { + doneButton, + editorRegion, + expectCanvasToShow, + mount, + promptBox, + SAMPLE_UUID, + STAGING, + sendPrompt, +} from './harness'; + +const historyStrip = () => page.getByRole('toolbar', { name: 'Recent prompts' }); + +const rectOf = (locator: Locator) => locator.element().getBoundingClientRect(); + +/** The canvas fills the stage, so its box is the image area the composer and history float over or dock beside. */ +const canvasRect = (el: UcAiImageEditorType) => el.shadowRoot!.querySelector('uc-ai-canvas')!.getBoundingClientRect(); + +/** Where `target` sits against the canvas: wholly above or below it, or over its top or bottom half. */ +function sideOfCanvas(el: UcAiImageEditorType, target: Locator): 'above' | 'below' | 'over-top' | 'over-bottom' { + const box = rectOf(target); + const canvas = canvasRect(el); + if (box.bottom <= canvas.top + 1) return 'above'; + if (box.top >= canvas.bottom - 1) return 'below'; + return (box.top + box.bottom) / 2 < (canvas.top + canvas.bottom) / 2 ? 'over-top' : 'over-bottom'; +} + +/** Runs one generation so the history strip mounts. */ +async function generateOnce(): Promise { + await sendPrompt('a tiger'); + await expect.element(editorRegion('edit')).toBeVisible(); + await expect.element(historyStrip()).toBeVisible(); +} + +/** Parks the pointer in the canvas's top-left corner, away from the composer and the bottom edge. */ +const parkPointer = (el: UcAiImageEditorType) => + userEvent.hover(page.elementLocator(el.shadowRoot!.querySelector('uc-ai-canvas')!), { + position: { x: 2, y: 2 }, + force: true, + }); + +/** How far the prompt box's top sits below the canvas's bottom edge: about 20px docked and shown, about 110px hidden. */ +const promptDrop = (el: UcAiImageEditorType) => rectOf(promptBox()).top - canvasRect(el).bottom; + +/** + * Waits for the editor's CSS transitions to finish: two frames for a style change to start its transition, then every + * running animation in the editor's shadow root. A negative layout check made before then could pass on the old frame. + */ +async function transitionsDone(el: UcAiImageEditorType): Promise { + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + await Promise.all(el.shadowRoot!.getAnimations().map((animation) => animation.finished)); +} /** * Where the composer, history strip and toolbar sit for a given placement, and - * how auto-hide docking changes that. + * how auto-hide docking changes that, measured on the rendered boxes. */ describe(' layout', () => { it('places the composer per composer-placement + canvas-fit', async () => { const el = mount(STAGING); el.canvasFit = 'full'; el.composerPlacement = 'bottom'; - await el.updateComplete; - const shell = el.shadowRoot!.querySelector('.shell')!; - const stage = () => shell.querySelector('.stage')!; - const composer = () => shell.querySelector('.composer')!; - // canvas-fit full → composer floats over the full canvas (inside the stage). - expect(composer().classList.contains('composer--overlay-bottom')).toBe(true); - expect(stage().contains(composer())).toBe(true); + // canvas-fit full → composer floats over the full canvas. + await expect.poll(() => sideOfCanvas(el, promptBox())).toBe('over-bottom'); el.composerPlacement = 'top'; - await el.updateComplete; - expect(composer().classList.contains('composer--overlay-top')).toBe(true); - expect(stage().contains(composer())).toBe(true); + await expect.poll(() => sideOfCanvas(el, promptBox())).toBe('over-top'); - // canvas-fit available → composer docked outside the stage so the canvas - // shrinks. `top` sits before the stage… + // canvas-fit available → composer docked outside the canvas so the canvas + // shrinks. `top` sits above it… el.canvasFit = 'available'; el.composerPlacement = 'top'; - await el.updateComplete; - expect(composer().classList.contains('composer--docked')).toBe(true); - expect(stage().contains(composer())).toBe(false); - let kids = [...shell.children]; - expect(kids.indexOf(composer())).toBeLessThan(kids.indexOf(stage())); + await expect.poll(() => sideOfCanvas(el, promptBox())).toBe('above'); - // …`bottom` after it. + // …`bottom` below it. el.composerPlacement = 'bottom'; - await el.updateComplete; - kids = [...shell.children]; - expect(kids.indexOf(composer())).toBeGreaterThan(kids.indexOf(stage())); + await expect.poll(() => sideOfCanvas(el, promptBox())).toBe('below'); }); it('defaults to a docked composer at the bottom', async () => { const el = mount(STAGING); - await el.updateComplete; - const composer = el.shadowRoot!.querySelector('.composer')!; expect(el.composerPlacement).toBe('bottom'); expect(el.canvasFit).toBe('available'); - expect(composer.classList.contains('composer--docked-bottom')).toBe(true); + await expect.poll(() => sideOfCanvas(el, promptBox())).toBe('below'); }); it('places the toolbar per toolbar-placement', async () => { const el = mount(STAGING); - await el.updateComplete; - const shell = el.shadowRoot!.querySelector('.shell')!; - const footer = () => shell.querySelector('uc-ai-footer')!; - const stage = () => shell.querySelector('.stage')!; - // Default: toolbar at the bottom (after the stage). - let kids = [...shell.children]; - expect(kids.indexOf(footer())).toBeGreaterThan(kids.indexOf(stage())); + // Default: toolbar at the bottom (below the canvas). + await expect.poll(() => sideOfCanvas(el, doneButton())).toBe('below'); - // Top: toolbar before the stage. + // Top: toolbar above the canvas. el.toolbarPlacement = 'top'; - await el.updateComplete; - kids = [...shell.children]; - expect(kids.indexOf(footer())).toBeLessThan(kids.indexOf(stage())); + await expect.poll(() => sideOfCanvas(el, doneButton())).toBe('above'); - // None: no toolbar at all; the stage still renders. + // None: no toolbar at all; the canvas still renders. el.toolbarPlacement = 'none'; - await el.updateComplete; - expect(shell.querySelector('uc-ai-footer')).toBeNull(); - expect(stage()).not.toBeNull(); + await expect.element(doneButton()).not.toBeInTheDocument(); + expect(canvasRect(el).height).toBeGreaterThan(0); // An unknown value falls back to the default (bottom), like the other // placement enums. el.toolbarPlacement = 'junk' as never; - await el.updateComplete; - kids = [...shell.children]; - expect(kids.indexOf(footer())).toBeGreaterThan(kids.indexOf(stage())); + await expect.poll(() => sideOfCanvas(el, doneButton())).toBe('below'); }); it('places the history strip per history-placement (overlay composer)', async () => { const el = mount(STAGING); el.canvasFit = 'full'; // relative history rides an overlay composer - await el.updateComplete; - typePrompt(el, 'a tiger'); - await el.updateComplete; - clickSend(el); - await vi.waitFor(() => expect(editorMode(el)).toBe('edit')); // a result mounts the strip - await el.updateComplete; - - const shell = el.shadowRoot!.querySelector('.shell')!; - const history = () => shell.querySelector('uc-ai-history')!; - const composer = () => shell.querySelector('.composer')!; - const stage = () => shell.querySelector('.stage')!; - const promptRow = () => composer().querySelector('uc-ai-prompt-row')!; - const content = () => composer().querySelector('.composer__content')!; - - // composer-above (default): inside the composer, before the prompt row. - expect(composer().contains(history())).toBe(true); - let kids = [...content().children]; - expect(kids.indexOf(history())).toBeLessThan(kids.indexOf(promptRow())); - - // composer-below: inside the composer, after the prompt row. + await generateOnce(); + + // composer-above (default): over the canvas, right above the prompt. + await expect.poll(() => rectOf(historyStrip()).bottom - rectOf(promptBox()).top).toBeLessThanOrEqual(0); + await expect.poll(() => sideOfCanvas(el, historyStrip())).toBe('over-bottom'); + + // composer-below: under the prompt. el.historyPlacement = 'composer-below'; - await el.updateComplete; - kids = [...content().children]; - expect(kids.indexOf(history())).toBeGreaterThan(kids.indexOf(promptRow())); + await expect.poll(() => rectOf(historyStrip()).top - rectOf(promptBox()).bottom).toBeGreaterThanOrEqual(0); - // canvas-top: pinned in the stage, not inside the composer. + // canvas-top: pinned to the canvas's top edge, away from the composer at the bottom. el.historyPlacement = 'canvas-top'; - await el.updateComplete; - expect(composer().contains(history())).toBe(false); - const pinned = stage().querySelector('.history-pinned')!; - expect(pinned.classList.contains('history-pinned--canvas-top')).toBe(true); - expect(pinned.contains(history())).toBe(true); + await expect.poll(() => sideOfCanvas(el, historyStrip())).toBe('over-top'); + expect(sideOfCanvas(el, promptBox())).toBe('over-bottom'); }); it('pins the history over the canvas when the composer is docked-out', async () => { const el = mount(STAGING); // docked at the bottom by default - await el.updateComplete; - typePrompt(el, 'a tiger'); - await el.updateComplete; - clickSend(el); - await vi.waitFor(() => expect(editorMode(el)).toBe('edit')); - await el.updateComplete; - - const shell = el.shadowRoot!.querySelector('.shell')!; - const stage = shell.querySelector('.stage')!; - const composer = shell.querySelector('.composer')!; - const history = shell.querySelector('uc-ai-history')!; - - // The composer (prompt) is docked outside the canvas; the history chips - // float over the canvas (pinned), not inside the docked composer. - expect(stage.contains(composer)).toBe(false); - expect(composer.contains(history)).toBe(false); - const pinned = stage.querySelector('.history-pinned')!; - expect(pinned.contains(history)).toBe(true); - expect(pinned.classList.contains('history-pinned--canvas-bottom')).toBe(true); + await generateOnce(); + + // The composer (prompt) is docked below the canvas; the history chips + // float over the canvas's bottom edge, not inside the docked composer. + await expect.poll(() => sideOfCanvas(el, promptBox())).toBe('below'); + await expect.poll(() => sideOfCanvas(el, historyStrip())).toBe('over-bottom'); }); - it('docks the overlay composer (renders the dock-hotzone) when composer-auto-hide is on', async () => { + it('slides an overlay composer off the canvas under composer-auto-hide and raises it at the edge', async () => { const el = mount(STAGING); el.canvasFit = 'full'; // an overlay (floating) composer el.composerAutoHide = true; el.sourceUuid = SAMPLE_UUID; // edit mode + an image to dock against - await el.updateComplete; - await vi.waitFor(() => expect(canvasUrl(el)).toBeTruthy()); - await el.updateComplete; + await expectCanvasToShow(SAMPLE_UUID); // The CSS docking keys off this reflected host attribute. - expect(el.hasAttribute('composer-auto-hide')).toBe(true); - - // A pointer catch-strip is rendered on the composer's edge (bottom by default). - const hotzone = el.shadowRoot!.querySelector('.dock-hotzone'); - expect(hotzone).toBeTruthy(); - expect(hotzone!.classList.contains('dock-hotzone--bottom')).toBe(true); + await expect.element(el).toHaveAttribute('composer-auto-hide'); + + // Docked: the prompt slides past the canvas's bottom edge, leaving a peek. + await parkPointer(el); + await expect.poll(() => -promptDrop(el)).toBeLessThan(40); + + // The pointer nearing the bottom edge (beside the composer, not on it) raises it wholly back over the canvas. + const canvas = canvasRect(el); + await userEvent.hover(page.elementLocator(el.shadowRoot!.querySelector('uc-ai-canvas')!), { + position: { x: 2, y: canvas.height - 10 }, + force: true, + }); + await expect.poll(() => rectOf(promptBox()).bottom - canvasRect(el).bottom).toBeLessThan(0); }); - it('does not dock (no hotzone) when auto-hide is disabled', async () => { + it('does not dock when auto-hide is disabled', async () => { const el = mount(STAGING); el.sourceUuid = SAMPLE_UUID; - await el.updateComplete; - await vi.waitFor(() => expect(canvasUrl(el)).toBeTruthy()); + await expectCanvasToShow(SAMPLE_UUID); + await parkPointer(el); + + // Disabled (default): the docked prompt stays shown right below the canvas… + await expect.element(el).not.toHaveAttribute('composer-auto-hide'); + await transitionsDone(el); + expect(sideOfCanvas(el, promptBox())).toBe('below'); + expect(promptDrop(el)).toBeLessThan(40); - // Disabled (default): no dock-hotzone, no reserved space. - expect(el.shadowRoot!.querySelector('.dock-hotzone')).toBeNull(); - expect(el.hasAttribute('composer-auto-hide')).toBe(false); + // …and an overlay one stays wholly over it. + el.canvasFit = 'full'; + await expect.poll(() => sideOfCanvas(el, promptBox())).toBe('over-bottom'); + await transitionsDone(el); + expect(rectOf(promptBox()).bottom - canvasRect(el).bottom).toBeLessThan(0); }); - it('keeps a docked composer docked under auto-hide (no overlay, no hotzone), independent of canvas-fit', async () => { + it('keeps a docked composer docked under auto-hide, independent of canvas-fit', async () => { const el = mount(STAGING); el.sourceUuid = SAMPLE_UUID; el.composerAutoHide = true; el.composerPlacement = 'bottom'; el.canvasFit = 'available'; - await el.updateComplete; - await vi.waitFor(() => expect(canvasUrl(el)).toBeTruthy()); - await el.updateComplete; + await expectCanvasToShow(SAMPLE_UUID); + await parkPointer(el); // Auto-hide is orthogonal to canvas-fit: it does NOT force `full`. A docked - // composer stays docked and hides by collapsing in place (so no overlay - // positioning and no dock-hotzone — that chrome is overlay-only). + // composer stays below the canvas and hides by collapsing in place: the + // prompt slides down inside its reserved slot instead of over the canvas… expect(el.canvasFit).toBe('available'); - const composer = el.shadowRoot!.querySelector('.composer')!; - expect(composer.classList.contains('composer--docked')).toBe(true); - expect(composer.classList.contains('composer--overlay')).toBe(false); - expect(el.shadowRoot!.querySelector('.dock-hotzone')).toBeNull(); + await expect.poll(() => promptDrop(el)).toBeGreaterThan(60); + const canvasHeight = canvasRect(el).height; + + // …and hovering it raises it in the same slot, so the canvas keeps its size. + await userEvent.hover(promptBox(), { force: true }); + await expect.poll(() => promptDrop(el)).toBeLessThan(40); + expect(canvasRect(el).height).toBe(canvasHeight); }); it('docks an overlay composer with a hotzone when auto-hide is on', async () => { @@ -195,14 +208,9 @@ describe(' layout', () => { el.sourceUuid = SAMPLE_UUID; el.composerAutoHide = true; el.canvasFit = 'full'; // floating composer - await el.updateComplete; - await vi.waitFor(() => expect(canvasUrl(el)).toBeTruthy()); - await el.updateComplete; - - const composer = el.shadowRoot!.querySelector('.composer')!; - expect(composer.classList.contains('composer--overlay')).toBe(true); - const hotzone = el.shadowRoot!.querySelector('.dock-hotzone'); - expect(hotzone).toBeTruthy(); - expect(hotzone!.classList.contains('dock-hotzone--bottom')).toBe(true); + await expectCanvasToShow(SAMPLE_UUID); + await parkPointer(el); + + await expect.poll(() => -promptDrop(el)).toBeLessThan(40); }); }); From a5ed0dffdf175ed0970a5abc30df7d3a36ea393d Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 21:03:18 +0300 Subject: [PATCH 080/104] test(ai-image-editor): drop the second overlay auto-hide test, a copy of the first Checklist item 27 (found while doing 28/30). "docks an overlay composer with a hotzone when auto-hide is on" mounts the same editor (canvas-fit full, composer-auto-hide, a source image) and asserts the same slide-off as "slides an overlay composer off the canvas under composer-auto-hide and raises it at the edge", which also checks the reflected attribute and the raise at the edge. Every break that failed it (never sliding the overlay out, swapping the overlay edge) also fails that test. Co-Authored-By: Claude Opus 5.5 --- .../ai-image-editor/tests/editor/layout.e2e.test.tsx | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/packages/ai-image-editor/tests/editor/layout.e2e.test.tsx b/packages/ai-image-editor/tests/editor/layout.e2e.test.tsx index 9633661..1185f0b 100644 --- a/packages/ai-image-editor/tests/editor/layout.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/layout.e2e.test.tsx @@ -202,15 +202,4 @@ describe(' layout', () => { await expect.poll(() => promptDrop(el)).toBeLessThan(40); expect(canvasRect(el).height).toBe(canvasHeight); }); - - it('docks an overlay composer with a hotzone when auto-hide is on', async () => { - const el = mount(STAGING); - el.sourceUuid = SAMPLE_UUID; - el.composerAutoHide = true; - el.canvasFit = 'full'; // floating composer - await expectCanvasToShow(SAMPLE_UUID); - await parkPointer(el); - - await expect.poll(() => -promptDrop(el)).toBeLessThan(40); - }); }); From 975b442cffb77bb50ada079393aa89611f41c9a0 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 21:04:37 +0300 Subject: [PATCH 081/104] test(ai-image-editor): read the rendition off the canvas's images, not its url prop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checklist items 30, 31 and 32. The rendition test read the canvas's `url` prop, and the content-sizing tests measured the internal .stage and .shell. They now read the the canvas renders (through `await expect.element(...).toHaveAttribute`) and measure the canvas (which fills the stage) and the editor's region by role. The prompt is sent through userEvent. The "keeps the sharper rendition when it shrinks" negative used to read the prop right after the resize. It now waits for the canvas to render the 1000px image alone (the 600px one faded out), then, after the resize observers and both elements' updates, asserts the canvas still renders only that image: a smaller rendition would show up at once as a second, preloading , before it ever replaced the shown one. Fail-first: letting the bucket shrink fails it with "expected [ …(2) ] to deeply equal [ StringContaining{…} ]"; never growing the bucket fails it on the 1000px step. Co-Authored-By: Claude Opus 5.5 --- .../tests/editor/sizing.e2e.test.tsx | 40 ++++++++++++------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/packages/ai-image-editor/tests/editor/sizing.e2e.test.tsx b/packages/ai-image-editor/tests/editor/sizing.e2e.test.tsx index 0e2c5bb..b1224c3 100644 --- a/packages/ai-image-editor/tests/editor/sizing.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/sizing.e2e.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import type { UcAiImageEditorType } from './harness'; -import { canvasUrl, clickSend, mount, STAGING, typePrompt } from './harness'; +import { canvasImage, editorRegion, mount, STAGING, sendPrompt } from './harness'; /** * Resolves once a ResizeObserver created now has reported `target`'s current size. Observers are notified in creation @@ -17,7 +17,11 @@ const afterResizeObservers = (target: Element) => /** How the host sizes itself: fill vs content, the clamp, and the CDN rendition it picks. */ describe(' sizing', () => { - const stageEl = (el: UcAiImageEditorType) => el.shadowRoot!.querySelector('.stage') as HTMLElement; + /** The canvas fills the stage, so its box is the stage's. */ + const canvasEl = (el: UcAiImageEditorType) => + el.shadowRoot!.querySelector('uc-ai-canvas') as HTMLElement & { + updateComplete: Promise; + }; it('defaults to fill and reflects the property to the attribute', async () => { const el = mount(STAGING); @@ -62,17 +66,17 @@ describe(' sizing', () => { await el.updateComplete; // The stage expresses the frame fit exactly: (640 - 40) / 1 + 40 = 640. - await vi.waitFor(() => expect(stageEl(el).getBoundingClientRect().height).toBeCloseTo(640, 0)); + await vi.waitFor(() => expect(canvasEl(el).getBoundingClientRect().height).toBeCloseTo(640, 0)); // The host wraps its content — stage plus composer/toolbar chrome. - const shell = el.shadowRoot!.querySelector('.shell') as HTMLElement; + const shell = editorRegion('generate').element(); expect(shell.getBoundingClientRect().height).toBeGreaterThan(640); expect(el.getBoundingClientRect().height).toBeCloseTo(shell.getBoundingClientRect().height, 0); // Picking a different ratio re-derives it: (640 - 40) / 2 + 40 = 340. el.setAttribute('aspect-ratios', '2:1'); await el.updateComplete; - await vi.waitFor(() => expect(stageEl(el).getBoundingClientRect().height).toBeCloseTo(340, 0)); + await vi.waitFor(() => expect(canvasEl(el).getBoundingClientRect().height).toBeCloseTo(340, 0)); }); it('clamps to the consumer max-height and letterboxes the canvas', async () => { @@ -86,7 +90,7 @@ describe(' sizing', () => { await el.updateComplete; await vi.waitFor(() => expect(el.getBoundingClientRect().height).toBeCloseTo(600, 0)); - expect(stageEl(el).getBoundingClientRect().height).toBeLessThan(600); + expect(canvasEl(el).getBoundingClientRect().height).toBeLessThan(600); // The frame letterboxes inside the clamped stage: it keeps the 1:2 // ratio instead of stretching to the full width. @@ -102,25 +106,31 @@ describe(' sizing', () => { it('re-picks a sharper CDN preview when the host grows, keeping it when it shrinks', async () => { const el = mount({ ...STAGING, style: 'width: 500px; height: 480px' }); - await el.updateComplete; - typePrompt(el, 'a tiger'); - await el.updateComplete; - clickSend(el); + await sendPrompt('a tiger'); const dpr = Math.max(window.devicePixelRatio, 1); - const size = (bucket: number) => Math.min(Math.ceil(bucket * dpr), 3000); + const rendition = (bucket: number) => + expect.stringContaining( + `/preview/${Math.min(Math.ceil(bucket * dpr), 3000)}x${Math.min(Math.ceil(bucket * dpr), 3000)}/`, + ); + /** Every image the canvas renders: the one shown, plus a preload of the next rendition or the one fading out. */ + const canvasImageSources = () => + [...canvasEl(el).shadowRoot!.querySelectorAll('img')].map((img) => img.getAttribute('src')); // A 500px-wide host lands in the 600px rendition bucket. - await vi.waitFor(() => expect(canvasUrl(el)).toContain(`/preview/${size(600)}x${size(600)}/`)); + await expect.element(canvasImage()).toHaveAttribute('src', rendition(600)); // Growing past the bucket steps the rendition up… el.style.width = '900px'; - await vi.waitFor(() => expect(canvasUrl(el)).toContain(`/preview/${size(1000)}x${size(1000)}/`)); + await expect.element(canvasImage()).toHaveAttribute('src', rendition(1000)); + // (once the 600px one has faded out, so the canvas renders that image alone) + await expect.poll(canvasImageSources).toEqual([expect.stringContaining('/preview/')]); - // …while shrinking keeps the sharper, already-cached one. + // …while shrinking keeps the sharper, already-cached one: no preload of a smaller rendition starts. el.style.width = '300px'; await afterResizeObservers(el); await el.updateComplete; - expect(canvasUrl(el)).toContain(`/preview/${size(1000)}x${size(1000)}/`); + await canvasEl(el).updateComplete; + expect(canvasImageSources()).toEqual([rendition(1000)]); }); }); From ef1e73e2c27751a7915168a056fa18e158b76de6 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 21:05:37 +0300 Subject: [PATCH 082/104] test(ai-image-editor): remove the harness's querySelector helpers Checklist items 31 and 30. No editor test uses typePrompt, clickSend, primaryBtn, clickPrimary, historyEl, canvasUrl or editorMode any more: they reached controls through shadowRoot chains and read child props. They go, and fillPrompt folds into sendPrompt, its only caller. Nothing to break: tsc and the editor e2e run (52 passed, 2 skipped) confirm nothing referenced them. Co-Authored-By: Claude Opus 5.5 --- .../ai-image-editor/tests/editor/harness.ts | 34 +------------------ 1 file changed, 1 insertion(+), 33 deletions(-) diff --git a/packages/ai-image-editor/tests/editor/harness.ts b/packages/ai-image-editor/tests/editor/harness.ts index 48ffeea..2b37c59 100644 --- a/packages/ai-image-editor/tests/editor/harness.ts +++ b/packages/ai-image-editor/tests/editor/harness.ts @@ -78,13 +78,11 @@ export const historyChips = () => page.getByRole('toolbar', { name: 'Recent prom export const historyChip = (prompt: string) => page.getByRole('toolbar', { name: 'Recent prompts' }).getByRole('button', { name: prompt, exact: true }); -export const fillPrompt = (value: string) => userEvent.fill(promptBox(), value); - export const clickDone = () => userEvent.click(doneButton()); /** Types the prompt and sends it. */ export async function sendPrompt(value: string): Promise { - await fillPrompt(value); + await userEvent.fill(promptBox(), value); await userEvent.click(sendButton()); } @@ -97,33 +95,3 @@ export async function generatedUuid(): Promise { /** Waits for the canvas to show the CDN rendition of `uuid`. */ export const expectCanvasToShow = (uuid: string) => expect.element(canvasImage()).toHaveAttribute('src', expect.stringContaining(`https://cdn.example.com/${uuid}/`)); -export function typePrompt(el: UcAiImageEditorType, value: string): void { - const input = el.shadowRoot!.querySelector('uc-ai-prompt-row')!.shadowRoot!.querySelector('textarea')!; - input.value = value; - input.dispatchEvent(new Event('input', { bubbles: true })); -} - -export function primaryBtn(el: UcAiImageEditorType): HTMLButtonElement { - return el.shadowRoot!.querySelector('uc-ai-footer')!.shadowRoot!.querySelector('.btn--primary') as HTMLButtonElement; -} - -/** Footer primary commits the result (fires uc:done). */ -export function clickPrimary(el: UcAiImageEditorType): void { - primaryBtn(el).click(); -} - -/** The prompt row's send button triggers generation. */ -export function clickSend(el: UcAiImageEditorType): void { - const promptRow = el.shadowRoot!.querySelector('uc-ai-prompt-row')!; - (promptRow.shadowRoot!.querySelector('.send') as HTMLButtonElement).click(); -} - -export const historyEl = (el: UcAiImageEditorType) => - el.shadowRoot!.querySelector('uc-ai-history') as (HTMLElement & { entries: unknown[] }) | null; - -export const canvasUrl = (el: UcAiImageEditorType): string | null => - (el.shadowRoot!.querySelector('uc-ai-canvas') as unknown as { url: string | null }).url; - -/** The derived editor mode, read off the prompt-row child the editor feeds. */ -export const editorMode = (el: UcAiImageEditorType): string => - (el.shadowRoot!.querySelector('uc-ai-prompt-row') as unknown as { mode: string }).mode; From 841b25d4a075a7452f1d68cfc20233c383875495 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 21:09:48 +0300 Subject: [PATCH 083/104] test(ai-image-editor): find the plugin's editor by role and commit a real edit Checklist items 31, 32, 30 and 28. The plugin tests found the editor with `querySelector(...)).toBeTruthy()` inside vi.waitFor, read the mode off the prompt row's `mode` prop, read labels off the footer's and prompt row's attributes, and drove the replace-in-place test with a synthetic uc:done carrying a made-up file. Now: - the editor is found by its region ("Generate image" / "Edit image"), which also asserts the mode; - the locale tests wait, with `await expect.element`, for the editor's own buttons by name (Make it! / Generate now, Cancel / Abbrechen), scoped to the editor because the uploader has a Cancel of its own; - the replace test types a prompt, generates against the emulator and clicks Done, then expects the uploader's single entry to be the file uc:done named, which the emulator holds; - the truthiness checks become toMatchObject on the source's uuid, toBeUndefined, toMatch(/.+/) and toBeInstanceOf, and the single-call check toHaveBeenCalledOnce. The `initFlow?.()` calls are left for item 26's fixer. Fail-first, one break at a time: the plugin not handing the editor the source's file info (2 tests: edit mode, replace); subscribing the locale refresh to localeName only (the override test); adding the result as a new entry instead of replacing the source (the replace test). Co-Authored-By: Claude Opus 5.5 --- .../ai-image-editor/tests/plugin.e2e.test.ts | 125 +++++++----------- 1 file changed, 49 insertions(+), 76 deletions(-) diff --git a/packages/ai-image-editor/tests/plugin.e2e.test.ts b/packages/ai-image-editor/tests/plugin.e2e.test.ts index 742d7c8..07075f1 100644 --- a/packages/ai-image-editor/tests/plugin.e2e.test.ts +++ b/packages/ai-image-editor/tests/plugin.e2e.test.ts @@ -1,5 +1,7 @@ import { beforeAll, describe, expect, it, vi } from 'vitest'; -import { page } from 'vitest/browser'; +import { page, userEvent } from 'vitest/browser'; +import type { UcAiImageEditor } from '../src/index'; +import { session } from './emulator'; import { cleanup, getCtxName } from './test-renderer'; const TEST_IMAGE_URL = @@ -39,6 +41,16 @@ function addSource(config: Config, sourceId: string) { config.sourceList += `,${sourceId}`; } +/** Waits for the plugin's editor to open in `mode` (its region is named after the mode) and answers the element. */ +async function openedEditor(mode: 'generate' | 'edit'): Promise { + const name = mode === 'edit' ? 'Edit image' : 'Generate image'; + await expect.element(page.getByRole('region', { name, exact: true })).toBeVisible(); + return document.querySelector('uc-ai-image-editor') as UcAiImageEditor; +} + +/** The plugin's editor, for locators scoped to it (the uploader around it has its own Cancel). */ +const editorLocator = () => page.elementLocator(document.querySelector('uc-ai-image-editor')!); + async function openModal() { await page.getByText('Upload files', { exact: true }).click(); } @@ -75,13 +87,7 @@ describe('AiImageEditorPlugin', () => { await openModal(); await page.getByText('Generate image').click(); - await vi.waitFor(() => { - expect(document.querySelector('uc-ai-image-editor')).toBeTruthy(); - }); - const editor = document.querySelector('uc-ai-image-editor') as Element & { - authToken?: unknown; - cacheAuthToken?: boolean; - }; + const editor = await openedEditor('generate'); expect(editor.cacheAuthToken).toBe(false); expect(typeof editor.authToken).toBe('function'); @@ -91,7 +97,7 @@ describe('AiImageEditorPlugin', () => { const resolve = editor.authToken as () => Promise; await expect(resolve()).resolves.toBe('eyJ.uploader.sig'); await expect(resolve()).resolves.toBe('eyJ.uploader.sig'); - expect(fetchToken).toHaveBeenCalledTimes(1); + expect(fetchToken).toHaveBeenCalledOnce(); cleanup(); }); @@ -101,29 +107,14 @@ describe('AiImageEditorPlugin', () => { addSource(config, 'ai-image-editor'); await openModal(); await page.getByText('Generate image').click(); - await vi.waitFor(() => { - const editor = document.querySelector('uc-ai-image-editor') as (Element & { sourceFileInfo?: unknown }) | null; - expect(editor).toBeTruthy(); - // No source → derived generate mode (read off the prompt-row child). - const promptRow = editor?.shadowRoot?.querySelector('uc-ai-prompt-row') as (Element & { mode?: string }) | null; - expect(promptRow?.mode).toBe('generate'); - expect(editor?.sourceFileInfo).toBeFalsy(); - }); + // No source → derived generate mode. + const editor = await openedEditor('generate'); + expect(editor.sourceFileInfo).toBeUndefined(); cleanup(); }); - // The editor renders the active locale's generate-button label as the prompt - // row's `send-aria-label`, and the cancel label as the footer's `cancel-label`. - const generateLabel = () => - document - .querySelector('uc-ai-image-editor') - ?.shadowRoot?.querySelector('uc-ai-prompt-row') - ?.getAttribute('send-aria-label'); - const cancelLabel = () => - document - .querySelector('uc-ai-image-editor') - ?.shadowRoot?.querySelector('uc-ai-footer') - ?.getAttribute('cancel-label'); + /** The editor's button named `name`. */ + const editorButton = (name: string) => editorLocator().getByRole('button', { name, exact: true }); it('feeds editor locale overrides from the uploader config (localeDefinitionOverride)', async () => { const { AiImageEditorPlugin } = await import('../src/plugin'); @@ -135,14 +126,16 @@ describe('AiImageEditorPlugin', () => { addSource(config, 'ai-image-editor'); await openModal(); await page.getByText('Generate image').click(); + // The send button shows once the prompt holds text. + await userEvent.fill(editorLocator().getByRole('textbox'), 'a tiger'); - await vi.waitFor(() => expect(generateLabel()).toBe('Make it!')); + await expect.element(editorButton('Make it!')).toBeVisible(); // Reactive: changing the override after the editor is open updates it. (config as unknown as L10nConfig).localeDefinitionOverride = { en: { 'ai-image-editor-generate-btn': 'Generate now' }, }; - await vi.waitFor(() => expect(generateLabel()).toBe('Generate now')); + await expect.element(editorButton('Generate now')).toBeVisible(); cleanup(); }); @@ -154,11 +147,12 @@ describe('AiImageEditorPlugin', () => { await page.getByText('Generate image').click(); // Defaults to English. - await vi.waitFor(() => expect(cancelLabel()).toBe('Cancel')); + await openedEditor('generate'); + await expect.element(editorButton('Cancel')).toBeVisible(); // Switching localeName lazy-loads and applies the German strings. (config as unknown as { localeName: string }).localeName = 'de'; - await vi.waitFor(() => expect(cancelLabel()).toBe('Abbrechen')); + await expect.element(editorButton('Abbrechen')).toBeVisible(); // The editor loads its own strings; the uploader loads its locale separately // and may still be resolving. Tearing it down mid-load is a file-uploader bug // (fixed upstream, not yet released), so let the uploader finish switching too. @@ -176,13 +170,9 @@ describe('AiImageEditorPlugin', () => { await expect.element(page.getByRole('button', { name: 'AI Edit' })).toBeVisible(); await page.getByRole('button', { name: 'AI Edit' }).click(); - await vi.waitFor(() => { - const editor = document.querySelector('uc-ai-image-editor') as (Element & { sourceFileInfo?: unknown }) | null; - expect(editor?.sourceFileInfo).toBeTruthy(); - // A source file → derived edit mode (read off the prompt-row child). - const promptRow = editor?.shadowRoot?.querySelector('uc-ai-prompt-row') as (Element & { mode?: string }) | null; - expect(promptRow?.mode).toBe('edit'); - }); + // A source file → derived edit mode, on the uploaded file. + const editor = await openedEditor('edit'); + expect(editor.sourceFileInfo).toMatchObject({ uuid: api.getOutputCollectionState().allEntries[0]!.uuid }); cleanup(); }); @@ -201,45 +191,34 @@ describe('AiImageEditorPlugin', () => { const originalUuid = before[0]!.uuid; const originalInternalId = before[0]!.internalId; const originalName = before[0]!.name; - expect(originalName).toBeTruthy(); + expect(originalName).toMatch(/.+/); await page.getByRole('button', { name: 'AI Edit' }).click(); - const editor = (await vi.waitFor(() => { - const el = document.querySelector('uc-ai-image-editor'); - expect(el).toBeTruthy(); - return el!; - })) as Element & { sourceFileInfo?: { uuid?: string; originalFilename?: string } }; + const editor = await openedEditor('edit'); // The plugin hands the source entry's file info to the editor (so it can // frame the canvas and name the result after the original) — verify it's the // source file's info that was wired through. expect(editor.sourceFileInfo?.uuid).toBe(originalUuid); - // The edit produced this already-uploaded result. Drive uc:done directly so - // the test doesn't depend on the generation backend. - const resultFile = { - uuid: 'edited-result', - cdnUrl: 'https://cdn.example.com/edited-result/', - originalFilename: originalName, - size: 4242, - isImage: true, - mimeType: 'image/png', - contentInfo: { mime: { mime: 'image/png' } }, - }; - editor.dispatchEvent( - new CustomEvent('uc:done', { - detail: { url: resultFile.cdnUrl, file: resultFile }, - bubbles: true, - composed: true, - }), - ); + // Run the edit against the emulator and commit its result. + session.use('derivativesInstant'); + await userEvent.fill(editorLocator().getByRole('textbox'), 'add a hat'); + await userEvent.click(editorButton('Generate')); + await expect.element(editorButton('Done')).toBeEnabled(); + const onDone = vi.fn(); + editor.addEventListener('uc:done', onDone); + await userEvent.click(editorButton('Done')); + expect(onDone).toHaveBeenCalledOnce(); + const resultUuid: string = onDone.mock.calls[0]![0].detail.file.uuid; + expect(resultUuid).not.toBe(originalUuid); + expect(session.files.get(resultUuid)).toBeDefined(); await vi.waitFor(() => { const after = api.getOutputCollectionState().allEntries; // Replaced in place: still a single entry, now carrying the edited result. expect(after).toHaveLength(1); - expect(after[0]!.uuid).toBe('edited-result'); - expect(after[0]!.uuid).not.toBe(originalUuid); + expect(after[0]!.uuid).toBe(resultUuid); // It's a fresh entry (remove + add), so the internalId changed... expect(after[0]!.internalId).not.toBe(originalInternalId); // ...the replacement is attributed to the AI Image Editor... @@ -257,11 +236,7 @@ describe('AiImageEditorPlugin', () => { await openModal(); await page.getByText('Generate image').click(); - const editor = (await vi.waitFor(() => { - const el = document.querySelector('uc-ai-image-editor'); - expect(el).toBeTruthy(); - return el as HTMLElement; - })) as HTMLElement; + const editor = await openedEditor('generate'); // The plugin must NOT map `--uc-ai-floating` to the (undefined) `--uc-floating` // token — doing so resolves to an invalid value and blanks every panel. @@ -269,11 +244,9 @@ describe('AiImageEditorPlugin', () => { // The prompt row's card mixes `--uc-ai-floating`; with a real default it paints. const card = await vi.waitFor(() => { - const c = editor.shadowRoot - ?.querySelector('uc-ai-prompt-row') - ?.shadowRoot?.querySelector('.card') as HTMLElement | null; - expect(c).toBeTruthy(); - return c!; + const c = editor.shadowRoot?.querySelector('uc-ai-prompt-row')?.shadowRoot?.querySelector('.card'); + expect(c).toBeInstanceOf(HTMLElement); + return c as HTMLElement; }); const bg = getComputedStyle(card).backgroundColor; expect(bg).not.toBe('rgba(0, 0, 0, 0)'); From 50f4e970b20c36fbd1a33ecd335d2afcc3011e37 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 21:11:44 +0300 Subject: [PATCH 084/104] test(ai-image-editor): assert DOM state in the component specs with matchers that show it Checklist item 28. The component specs checked DOM state with bare booleans: `classList.contains(...)).toBe(true)`, `.some(...)).toBe(false)`, `.every(...)).toBe(true)`, `.disabled).toBe(true)`, `.hidden).toBe(false)` and `querySelector(...)).toBeTruthy()`. A failure then read "expected false to be true". They now use `expect(classList).toContain(...)`, `toHaveProperty('disabled' | 'hidden', ...)`, `not.toBeNull()`, `toBeInstanceOf(HTMLTextAreaElement)`, a list of the offending chips (`filter(...)).toEqual([])`, after checking there are chips), the disabled state per chip, and the textarea contained in the composer card. The timeout spec's `jobId).toBeTruthy()` becomes `toEqual(expect.any(String))`. Left as `toBe(true / false)`: the pure predicates in aspect-ratio.test.ts (isValidAspectRatio, isConcreteRatio, aspectRatioValueEquals, aspectRatioEquals), GenerationController's `busy` and `signal.aborted`, the JSON `store` field, `isImage`, and translate.test's type-level constant. Those values are booleans, so no matcher says more. Fail-first, one src break at a time, each failing the rewritten checks: chips with aria-pressed; chips, trigger, textarea, send or Done never disabled; no busy class; send never or always hidden; the chips or ratio slot removed; the card renamed; history nav never disabled at the ends, prev or next always disabled, no arrows, no carousel class; the canvas never or always is-empty / is-loading; no frame, error state, fullscreen button or preload. Co-Authored-By: Claude Opus 5.5 --- .../api/uploadcareDerivativeApi.test.ts | 2 +- .../ui/UcAiAspectRatio.test.ts | 2 +- .../preset-chips/ui/UcAiChips.test.ts | 5 +++-- .../prompt-history/ui/UcAiHistory.test.ts | 16 +++++++------- .../prompt-input/ui/UcAiPromptRow.test.ts | 19 +++++++++-------- .../src/shared/ui/canvas/UcAiCanvas.test.ts | 21 +++++++++---------- .../src/shared/ui/footer/UcAiFooter.test.ts | 2 +- 7 files changed, 34 insertions(+), 33 deletions(-) diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts index 65f7b03..aa228f9 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts @@ -114,7 +114,7 @@ describe('UploadcareDerivativeApi', () => { const err = await provider.generate({ prompt: 'x', mode: 'generate' }).catch((e) => e); expect(err).toBeInstanceOf(AiProviderError); expect(err.errorCode).toBe('generation_timeout'); - expect(jobId).toBeTruthy(); + expect(jobId).toEqual(expect.any(String)); expect(err.message).toContain(jobId); }); diff --git a/packages/ai-image-editor/src/features/aspect-ratio-select/ui/UcAiAspectRatio.test.ts b/packages/ai-image-editor/src/features/aspect-ratio-select/ui/UcAiAspectRatio.test.ts index d94acd7..ae5860c 100644 --- a/packages/ai-image-editor/src/features/aspect-ratio-select/ui/UcAiAspectRatio.test.ts +++ b/packages/ai-image-editor/src/features/aspect-ratio-select/ui/UcAiAspectRatio.test.ts @@ -46,7 +46,7 @@ describe('UcAiAspectRatio', () => { it('disables the trigger while busy', async () => { const el = await mount({ busy: true }); - expect(trigger(el).disabled).toBe(true); + expect(trigger(el)).toHaveProperty('disabled', true); }); it('renders an Auto entry when one is provided (edit mode)', async () => { diff --git a/packages/ai-image-editor/src/features/preset-chips/ui/UcAiChips.test.ts b/packages/ai-image-editor/src/features/preset-chips/ui/UcAiChips.test.ts index 10cf5c3..1244a57 100644 --- a/packages/ai-image-editor/src/features/preset-chips/ui/UcAiChips.test.ts +++ b/packages/ai-image-editor/src/features/preset-chips/ui/UcAiChips.test.ts @@ -28,7 +28,8 @@ describe('UcAiChips', () => { it('renders chips as plain buttons (no sticky pressed state)', async () => { const el = await mount({ mode: 'generate' }); - expect(chips(el).some((c) => c.hasAttribute('aria-pressed'))).toBe(false); + expect(chips(el)).not.toHaveLength(0); + expect(chips(el).filter((c) => c.hasAttribute('aria-pressed'))).toEqual([]); }); it('emits uc:select with the preset when a chip is clicked', async () => { @@ -44,6 +45,6 @@ describe('UcAiChips', () => { it('disables the chips while busy', async () => { const el = await mount({ mode: 'generate', busy: true }); - expect(chips(el).every((c) => c.disabled)).toBe(true); + expect(chips(el).map((c) => c.disabled)).toEqual(MODES.generate.presets.map(() => true)); }); }); diff --git a/packages/ai-image-editor/src/features/prompt-history/ui/UcAiHistory.test.ts b/packages/ai-image-editor/src/features/prompt-history/ui/UcAiHistory.test.ts index 5fd091c..b0c872b 100644 --- a/packages/ai-image-editor/src/features/prompt-history/ui/UcAiHistory.test.ts +++ b/packages/ai-image-editor/src/features/prompt-history/ui/UcAiHistory.test.ts @@ -104,8 +104,8 @@ describe('UcAiHistory', () => { const el = await mount({ entries: series(7), selectedUuid: 'e6' }); // ordered oldest→newest is e0…e6; the newest window is e2…e6. expect(labelsOf(el)).toEqual(['e2', 'e3', 'e4', 'e5', 'e6']); - expect(prev(el)!.disabled).toBe(false); // an older result exists - expect(next(el)!.disabled).toBe(true); // already at the newest + expect(prev(el)).toHaveProperty('disabled', false); // an older result exists + expect(next(el)).toHaveProperty('disabled', true); // already at the newest }); it('prev/next step the active result and emit uc:select', async () => { @@ -155,12 +155,12 @@ describe('UcAiHistory', () => { it('disables prev at the oldest result and next at the newest', async () => { const oldest = await mount({ entries: series(7), selectedUuid: 'e0' }); - expect(prev(oldest)!.disabled).toBe(true); - expect(next(oldest)!.disabled).toBe(false); + expect(prev(oldest)).toHaveProperty('disabled', true); + expect(next(oldest)).toHaveProperty('disabled', false); const newest = await mount({ entries: series(7), selectedUuid: 'e6' }); - expect(prev(newest)!.disabled).toBe(false); - expect(next(newest)!.disabled).toBe(true); + expect(prev(newest)).toHaveProperty('disabled', false); + expect(next(newest)).toHaveProperty('disabled', true); }); it('keeps the window steady across unrelated re-renders (fresh array, same selection)', async () => { @@ -226,7 +226,7 @@ describe('UcAiHistory', () => { expect(chips(el)).toHaveLength(9); expect(prev(el)).toBeNull(); expect(next(el)).toBeNull(); - expect(el.shadowRoot!.querySelector('.chips--carousel')).toBeTruthy(); + expect(el.shadowRoot!.querySelector('.chips--carousel')).not.toBeNull(); }); it('selects the chip whose uuid is tapped and emits uc:select', async () => { @@ -243,7 +243,7 @@ describe('UcAiHistory', () => { mockCoarse(false); const el = await mount({ entries: series(9), selectedUuid: 'e8' }); expect(chips(el)).toHaveLength(5); - expect(next(el)).toBeTruthy(); + expect(next(el)).not.toBeNull(); expect(el.shadowRoot!.querySelector('.chips--carousel')).toBeNull(); }); }); diff --git a/packages/ai-image-editor/src/features/prompt-input/ui/UcAiPromptRow.test.ts b/packages/ai-image-editor/src/features/prompt-input/ui/UcAiPromptRow.test.ts index b14d1cb..53aba22 100644 --- a/packages/ai-image-editor/src/features/prompt-input/ui/UcAiPromptRow.test.ts +++ b/packages/ai-image-editor/src/features/prompt-input/ui/UcAiPromptRow.test.ts @@ -19,16 +19,17 @@ const keydown = (el: UcAiPromptRow, init: KeyboardEventInit) => describe('UcAiPromptRow', () => { it('renders a multiline textarea inside the composer card', async () => { const el = await mount({ placeholder: 'Describe your image' }); - expect(textarea(el)).toBeTruthy(); + expect(textarea(el)).toBeInstanceOf(HTMLTextAreaElement); expect(el.shadowRoot!.querySelector('input')).toBeNull(); - expect(el.shadowRoot!.querySelector('.card')).toBeTruthy(); + // The textarea sits inside the composer card. + expect(el.shadowRoot!.querySelector('.card')).toContain(textarea(el)); expect(textarea(el).placeholder).toBe('Describe your image'); }); it('exposes slots for chips and the aspect-ratio picker', async () => { const el = await mount(); - expect(el.shadowRoot!.querySelector('slot[name="chips"]')).toBeTruthy(); - expect(el.shadowRoot!.querySelector('slot[name="aspect-ratio"]')).toBeTruthy(); + expect(el.shadowRoot!.querySelector('slot[name="chips"]')).not.toBeNull(); + expect(el.shadowRoot!.querySelector('slot[name="aspect-ratio"]')).not.toBeNull(); }); it('emits uc:send on Enter when the value is non-empty', async () => { @@ -100,18 +101,18 @@ describe('UcAiPromptRow', () => { it('disables the textarea and send button while busy', async () => { const el = await mount({ value: 'hi', busy: true }); - expect(textarea(el).disabled).toBe(true); - expect(sendBtn(el)!.disabled).toBe(true); - expect(sendBtn(el)!.classList.contains('send--busy')).toBe(true); + expect(textarea(el)).toHaveProperty('disabled', true); + expect(sendBtn(el)).toHaveProperty('disabled', true); + expect(sendBtn(el)!.classList).toContain('send--busy'); }); it('hides (collapses) the send button when empty and reveals it otherwise', async () => { const el = await mount(); - expect(sendBtn(el)!.hidden).toBe(true); + expect(sendBtn(el)).toHaveProperty('hidden', true); el.value = 'hi'; await el.updateComplete; - expect(sendBtn(el)!.hidden).toBe(false); + expect(sendBtn(el)).toHaveProperty('hidden', false); }); it('emits uc:send when the send button is clicked', async () => { diff --git a/packages/ai-image-editor/src/shared/ui/canvas/UcAiCanvas.test.ts b/packages/ai-image-editor/src/shared/ui/canvas/UcAiCanvas.test.ts index be08ab6..6a5eee8 100644 --- a/packages/ai-image-editor/src/shared/ui/canvas/UcAiCanvas.test.ts +++ b/packages/ai-image-editor/src/shared/ui/canvas/UcAiCanvas.test.ts @@ -15,23 +15,22 @@ const preloadImg = (el: UcAiCanvas) => el.shadowRoot!.querySelector('img.preload const shownImg = (el: UcAiCanvas) => el.shadowRoot!.querySelector('img.canvas__image'); const errorState = (el: UcAiCanvas) => el.shadowRoot!.querySelector('.error-state'); const canvasEl = (el: UcAiCanvas) => el.shadowRoot!.querySelector('.canvas')!; -const isEmpty = (el: UcAiCanvas) => canvasEl(el).classList.contains('is-empty'); -const isLoading = (el: UcAiCanvas) => canvasEl(el).classList.contains('is-loading'); +const canvasClasses = (el: UcAiCanvas) => canvasEl(el).classList; describe('UcAiCanvas', () => { it('preloads the new url before displaying it', async () => { const el = await mount('https://example.com/a.png'); // Before load: empty state (dot grid shown), nothing displayed, preloader present. - expect(isEmpty(el)).toBe(true); + expect(canvasClasses(el)).toContain('is-empty'); expect(shownImg(el)).toBeNull(); - expect(preloadImg(el)).toBeTruthy(); + expect(preloadImg(el)).not.toBeNull(); preloadImg(el)!.dispatchEvent(new Event('load')); await el.updateComplete; // After load: the image is displayed, empty state cleared, preloader gone. expect(shownImg(el)?.getAttribute('src')).toBe('https://example.com/a.png'); - expect(isEmpty(el)).toBe(false); + expect(canvasClasses(el)).not.toContain('is-empty'); expect(preloadImg(el)).toBeNull(); }); @@ -57,7 +56,7 @@ describe('UcAiCanvas', () => { el.ratio = 16 / 9; await el.updateComplete; // The frame element exists and is the box the image is cropped into. - expect(el.shadowRoot!.querySelector('.canvas__frame')).toBeTruthy(); + expect(el.shadowRoot!.querySelector('.canvas__frame')).not.toBeNull(); }); describe('frame sizing', () => { @@ -199,7 +198,7 @@ describe('UcAiCanvas', () => { const el = await mount('https://example.com/broken.png'); preloadImg(el)!.dispatchEvent(new Event('error')); await el.updateComplete; - expect(errorState(el)).toBeTruthy(); + expect(errorState(el)).not.toBeNull(); el.url = 'https://example.com/fresh.png'; await el.updateComplete; @@ -210,11 +209,11 @@ describe('UcAiCanvas', () => { it('marks the canvas as loading while busy', async () => { const el = await mount(null); - expect(isLoading(el)).toBe(false); + expect(canvasClasses(el)).not.toContain('is-loading'); el.busy = true; await el.updateComplete; - expect(isLoading(el)).toBe(true); - expect(isEmpty(el)).toBe(true); + expect(canvasClasses(el)).toContain('is-loading'); + expect(canvasClasses(el)).toContain('is-empty'); }); describe('fullscreen button', () => { @@ -256,7 +255,7 @@ describe('UcAiCanvas', () => { preloadImg(el)!.dispatchEvent(new Event('load')); await el.updateComplete; - expect(fullscreenBtn(el)).toBeTruthy(); + expect(fullscreenBtn(el)).not.toBeNull(); }); it('requests fullscreen on the canvas when clicked', async () => { diff --git a/packages/ai-image-editor/src/shared/ui/footer/UcAiFooter.test.ts b/packages/ai-image-editor/src/shared/ui/footer/UcAiFooter.test.ts index e267c40..62665df 100644 --- a/packages/ai-image-editor/src/shared/ui/footer/UcAiFooter.test.ts +++ b/packages/ai-image-editor/src/shared/ui/footer/UcAiFooter.test.ts @@ -40,6 +40,6 @@ describe('UcAiFooter', () => { it('disables Done when primaryDisabled is set', async () => { const el = await mount({ primaryDisabled: true }); - expect(primaryBtn(el)!.disabled).toBe(true); + expect(primaryBtn(el)).toHaveProperty('disabled', true); }); }); From 22094b5a4348efe5519440c69ae00f37855042d8 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 21:12:27 +0300 Subject: [PATCH 085/104] test(ai-image-editor): check uc:select fired before reading it in the history specs Checklist item 28. Two UcAiHistory specs (the prev/next step and the touch tap) read `onSelect.mock.calls[0]![0]` right after the click, so a missing uc:select surfaced as a TypeError. They now assert toHaveBeenCalledOnce first, like the chip-click spec beside them. Fail-first: with _select dispatching nothing, all three fail with "expected "vi.fn()" to be called once, but got 0 times". Co-Authored-By: Claude Opus 5.5 --- .../src/features/prompt-history/ui/UcAiHistory.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/ai-image-editor/src/features/prompt-history/ui/UcAiHistory.test.ts b/packages/ai-image-editor/src/features/prompt-history/ui/UcAiHistory.test.ts index b0c872b..4f0cb38 100644 --- a/packages/ai-image-editor/src/features/prompt-history/ui/UcAiHistory.test.ts +++ b/packages/ai-image-editor/src/features/prompt-history/ui/UcAiHistory.test.ts @@ -114,6 +114,7 @@ describe('UcAiHistory', () => { el.addEventListener('uc:select', onSelect); prev(el)!.click(); // one older than e6 + expect(onSelect).toHaveBeenCalledOnce(); expect((onSelect.mock.calls[0]![0] as CustomEvent).detail.entry.id).toBe('e5'); // The parent restores that result, handing the selection back down. @@ -236,6 +237,7 @@ describe('UcAiHistory', () => { el.addEventListener('uc:select', onSelect); chips(el)[0]!.click(); // oldest (e0) + expect(onSelect).toHaveBeenCalledOnce(); expect((onSelect.mock.calls[0]![0] as CustomEvent).detail.entry.id).toBe('e0'); }); From 94407b974016fe6ad6dad398417310f13820a436 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 21:14:18 +0300 Subject: [PATCH 086/104] test(ai-image-editor): wait for the sizing tests' styles and boxes web-first Checklist item 32. The sizing tests awaited the editor's updateComplete and then read getComputedStyle or a bounding box synchronously, or polled boxes through vi.waitFor. They now use `await expect.element(el).toHaveStyle({ minHeight })` / `toHaveAttribute` and `expect.poll(() => box.height).toBeCloseTo(...)`. The unknown-sizing check first waits for the attribute to reflect "junk", so its "still 480px" can't pass on the frame before the update. Fail-first, one break at a time: a 470px fallback (the min-height test); not reflecting `sizing` (the reflect and min-height tests); dropping the content-mode stage rule (both content-mode tests). Co-Authored-By: Claude Opus 5.5 --- .../tests/editor/sizing.e2e.test.tsx | 29 +++++++------------ 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/packages/ai-image-editor/tests/editor/sizing.e2e.test.tsx b/packages/ai-image-editor/tests/editor/sizing.e2e.test.tsx index b1224c3..1ebe0c6 100644 --- a/packages/ai-image-editor/tests/editor/sizing.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/sizing.e2e.test.tsx @@ -25,27 +25,23 @@ describe(' sizing', () => { it('defaults to fill and reflects the property to the attribute', async () => { const el = mount(STAGING); - await el.updateComplete; expect(el.sizing).toBe('fill'); el.sizing = 'content'; - await el.updateComplete; - expect(el.getAttribute('sizing')).toBe('content'); + await expect.element(el).toHaveAttribute('sizing', 'content'); }); it('keeps an unsized fill host from collapsing (min-height fallback)', async () => { const el = mount(STAGING); - await el.updateComplete; - expect(getComputedStyle(el).minHeight).toBe('480px'); + await expect.element(el).toHaveStyle({ minHeight: '480px' }); // The mode is CSS-driven, so an unknown value behaves as fill too. el.sizing = 'junk' as never; - await el.updateComplete; - expect(getComputedStyle(el).minHeight).toBe('480px'); + await expect.element(el).toHaveAttribute('sizing', 'junk'); + expect(el).toHaveStyle({ minHeight: '480px' }); // Content mode derives its own height instead of guarding a given one. el.sizing = 'content'; - await el.updateComplete; - expect(getComputedStyle(el).minHeight).not.toBe('480px'); + await expect.element(el).not.toHaveStyle({ minHeight: '480px' }); // Plain consumer CSS always beats the :host fallback (shadow-cascade // rule: outer-tree declarations win over :host, specificity aside). @@ -54,8 +50,7 @@ describe(' sizing', () => { consumerCss.textContent = 'uc-ai-image-editor { min-height: 100px; }'; document.head.append(consumerCss); try { - await el.updateComplete; - expect(getComputedStyle(el).minHeight).toBe('100px'); + await expect.element(el).toHaveStyle({ minHeight: '100px' }); } finally { consumerCss.remove(); } @@ -63,10 +58,9 @@ describe(' sizing', () => { it('derives its height from the selected ratio at the given width in content mode', async () => { const el = mount({ ...STAGING, sizing: 'content', 'aspect-ratios': '1:1', style: 'width: 640px' }); - await el.updateComplete; // The stage expresses the frame fit exactly: (640 - 40) / 1 + 40 = 640. - await vi.waitFor(() => expect(canvasEl(el).getBoundingClientRect().height).toBeCloseTo(640, 0)); + await expect.poll(() => canvasEl(el).getBoundingClientRect().height).toBeCloseTo(640, 0); // The host wraps its content — stage plus composer/toolbar chrome. const shell = editorRegion('generate').element(); @@ -75,8 +69,7 @@ describe(' sizing', () => { // Picking a different ratio re-derives it: (640 - 40) / 2 + 40 = 340. el.setAttribute('aspect-ratios', '2:1'); - await el.updateComplete; - await vi.waitFor(() => expect(canvasEl(el).getBoundingClientRect().height).toBeCloseTo(340, 0)); + await expect.poll(() => canvasEl(el).getBoundingClientRect().height).toBeCloseTo(340, 0); }); it('clamps to the consumer max-height and letterboxes the canvas', async () => { @@ -87,15 +80,13 @@ describe(' sizing', () => { 'aspect-ratios': '1:2', style: 'width: 640px; max-height: 600px', }); - await el.updateComplete; - await vi.waitFor(() => expect(el.getBoundingClientRect().height).toBeCloseTo(600, 0)); + await expect.poll(() => el.getBoundingClientRect().height).toBeCloseTo(600, 0); expect(canvasEl(el).getBoundingClientRect().height).toBeLessThan(600); // The frame letterboxes inside the clamped stage: it keeps the 1:2 // ratio instead of stretching to the full width. - const canvas = el.shadowRoot!.querySelector('uc-ai-canvas')!; - const frame = canvas.shadowRoot!.querySelector('.canvas__frame') as HTMLElement; + const frame = canvasEl(el).shadowRoot!.querySelector('.canvas__frame') as HTMLElement; await vi.waitFor(() => { const rect = frame.getBoundingClientRect(); expect(rect.height).toBeGreaterThan(0); From c12a12fa25712ca30c1f79b0264cc3110d3a4983 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 22:19:25 +0300 Subject: [PATCH 087/104] test(ai-image-editor): delete the skipped Start over tests Checklist item 20. Two tests sat in it.skip with no reason or issue: UcAiHistory's "shows a Start over control in edit mode" and the history e2e's "returns to generate mode after Start over". The feature is off: UcAiHistory's render() has no Start over markup at all, and the editor hard-codes `showStartOver = false`. Neither test can pass until someone re-enables the feature, and that change should bring its own tests. Also dropped the `startOver(el)` helper and the one `expect(startOver(el)).toBeNull()` that used it. With no markup that check can't fail; the `.strip` null check beside it still covers "renders nothing". Co-Authored-By: Claude Opus 5.5 --- .../features/prompt-history/ui/UcAiHistory.test.ts | 13 ------------- .../tests/editor/history.e2e.test.tsx | 13 ------------- 2 files changed, 26 deletions(-) diff --git a/packages/ai-image-editor/src/features/prompt-history/ui/UcAiHistory.test.ts b/packages/ai-image-editor/src/features/prompt-history/ui/UcAiHistory.test.ts index 4f0cb38..bc3c14b 100644 --- a/packages/ai-image-editor/src/features/prompt-history/ui/UcAiHistory.test.ts +++ b/packages/ai-image-editor/src/features/prompt-history/ui/UcAiHistory.test.ts @@ -37,7 +37,6 @@ async function mount(overrides: Partial = {}): Promise } const chips = (el: UcAiHistory) => [...el.shadowRoot!.querySelectorAll('.chip')]; -const startOver = (el: UcAiHistory) => el.shadowRoot!.querySelector('.startover__btn'); const prev = (el: UcAiHistory) => el.shadowRoot!.querySelector('.nav--prev'); const next = (el: UcAiHistory) => el.shadowRoot!.querySelector('.nav--next'); @@ -53,7 +52,6 @@ describe('UcAiHistory', () => { it('renders nothing in generate mode with no results', async () => { const el = await mount({ entries: [], showStartOver: false }); expect(chips(el)).toHaveLength(0); - expect(startOver(el)).toBeNull(); expect(el.shadowRoot!.querySelector('.strip')).toBeNull(); }); @@ -249,15 +247,4 @@ describe('UcAiHistory', () => { expect(el.shadowRoot!.querySelector('.chips--carousel')).toBeNull(); }); }); - - it.skip('shows a Start over control in edit mode and emits uc:start-over', async () => { - const el = await mount({ entries: [], showStartOver: true, startOverLabel: 'Start over' }); - expect(el.shadowRoot!.querySelector('.strip')).toBeTruthy(); - expect(startOver(el)?.textContent).toContain('Start over'); - - const onStartOver = vi.fn(); - el.addEventListener('uc:start-over', onStartOver); - startOver(el)!.click(); - expect(onStartOver).toHaveBeenCalledOnce(); - }); }); diff --git a/packages/ai-image-editor/tests/editor/history.e2e.test.tsx b/packages/ai-image-editor/tests/editor/history.e2e.test.tsx index 37d449b..09d3fa7 100644 --- a/packages/ai-image-editor/tests/editor/history.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/history.e2e.test.tsx @@ -1,7 +1,6 @@ import { DEMO_FILES } from '@uploadcare/api-emulator'; import type { UploadcareFile } from '@uploadcare/upload-client'; import { describe, expect, it } from 'vitest'; -import { page, userEvent } from 'vitest/browser'; import { canvasImage, editorRegion, @@ -80,16 +79,4 @@ describe(' history', () => { await expectCanvasToShow(LATEST); await expect.element(canvasImage()).not.toHaveAttribute('src', expect.stringContaining(SAMPLE_UUID)); }); - - it.skip('returns to generate mode after Start over (from the history strip)', async () => { - mount(STAGING); - await sendPrompt('a tiger'); - await expect.element(editorRegion('edit')).toBeVisible(); - - await userEvent.click(page.getByRole('button', { name: 'Start over' })); - await expect.element(editorRegion('generate')).toBeVisible(); - await expect.element(canvasImage()).not.toBeInTheDocument(); - // Start over also clears the prompt history (the strip unmounts). - await expect.element(page.getByRole('toolbar', { name: 'Recent prompts' })).not.toBeInTheDocument(); - }); }); From 2e89ff2640bf6a273c959f1d6656ffafbf672110 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 22:19:57 +0300 Subject: [PATCH 088/104] test(ai-image-editor): un-skip the uc:change test, clearing the result with a new source Checklist items 34 and 20. uc:change is public API (a host drives its own chrome off it), but its only test was in it.skip because it cleared the result through Start over, which is turned off. The rewrite clears it the way a host can today: it sets a new sourceUuid, and that source has no history to resume. The test checks the first event's result (the run's CDN url and file uuid), then the second event's `{ result: null }`. It waits on the canvas rather than on the spy. Fail-first: with updated() skipping a null result, it fails with "expected to be called 2 times, but got 1 times". With the event renamed, it fails with "expected to be called once, but got 0 times". Co-Authored-By: Claude Opus 5.5 --- .../tests/editor/generation.e2e.test.tsx | 29 ++++++++----------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx b/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx index 9b58dfc..aa011f2 100644 --- a/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx @@ -11,7 +11,6 @@ import { generatedUuid, mount, promptBox, - resultUuid, SAMPLE_UUID, STAGING, sendPrompt, @@ -93,29 +92,25 @@ describe(' generation', () => { await expect.element(promptBox()).toHaveValue('a tiger'); }); - it.skip('fires uc:change as the current result appears and clears', async () => { + it('fires uc:change with the result when a run lands, and with null when a new source clears it', async () => { const el = mount(STAGING); - await el.updateComplete; const onChange = vi.fn(); el.addEventListener('uc:change', onChange); await sendPrompt('a tiger'); - await vi.waitFor(() => { - expect(onChange).toHaveBeenCalledTimes(1); + const uuid = await generatedUuid(); + await expectCanvasToShow(uuid); + expect(onChange).toHaveBeenCalledOnce(); + expect(onChange.mock.calls[0]![0].detail.result).toMatchObject({ + url: `https://cdn.example.com/${uuid}/`, + file: { uuid }, }); - const first = onChange.mock.calls[0]![0].detail; - expect(first.result.url).toBe(`https://cdn.example.com/${resultUuid()}/`); - expect(first.result.file.uuid).toBe(resultUuid()); - // Start over clears the current result -> uc:change with null. - await el.updateComplete; - const history = el.shadowRoot!.querySelector('uc-ai-history')!; - const startOver = history.shadowRoot!.querySelector('.startover__btn') as HTMLButtonElement; - startOver.click(); - await vi.waitFor(() => { - expect(onChange).toHaveBeenCalledTimes(2); - }); - expect(onChange.mock.calls[1]![0].detail.result).toBeNull(); + // A host handing over another image drops the result: that source has no history to resume. + el.sourceUuid = SAMPLE_UUID; + await expectCanvasToShow(SAMPLE_UUID); + expect(onChange).toHaveBeenCalledTimes(2); + expect(onChange.mock.calls[1]![0].detail).toEqual({ result: null }); }); it('dispatches uc:cancel when the cancel button is clicked', async () => { From 51b717c055c885f809ec51f00fe5a740df024638 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 22:20:56 +0300 Subject: [PATCH 089/104] test(ai-image-editor): name the seeded images instead of indexing DEMO_FILES Checklist item 25 (Mystery Guest). The tests picked seeded files by position, `DEMO_FILES[0]` and `DEMO_FILES[1]`, so a reader couldn't tell what file a test stood on, or why it took the second one. The emulator now exports a name for each, and the tests use those names. The specs use DEMO_IMAGE_UUID. The harness defines SAMPLE_UUID (DEMO_IMAGE_UUID, documented as a stored, non-square 136x150 JPEG) and SECOND_SAMPLE_UUID for the tests that switch sources. The outputFilename test's comment said it took DEMO_FILES[1] because "no test has edited" it. That reason is wrong, since the harness clears the persisted history after every test, so the test now uses SAMPLE_UUID and its comment says so. The review's other item-25 point, scripting the 4-poll walk at the call site, was already done by "keeps polling through processing, uploading and an unready success". These are renames only, with no new assertions, so there is no fail-first break. Co-Authored-By: Claude Opus 5.5 --- .../provider/api/uploadcareApiClient.test.ts | 21 ++++++++------ .../api/uploadcareDerivativeApi.test.ts | 28 +++++++++---------- .../tests/editor/filename.e2e.test.tsx | 5 ++-- .../tests/editor/generation.e2e.test.tsx | 8 +++--- .../ai-image-editor/tests/editor/harness.ts | 9 ++++-- .../tests/editor/history.e2e.test.tsx | 4 +-- 6 files changed, 41 insertions(+), 34 deletions(-) diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts index 430d8ba..2fc1d79 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts @@ -1,4 +1,4 @@ -import { DEMO_FILES, mintAuthToken } from '@uploadcare/api-emulator'; +import { DEMO_IMAGE_UUID, mintAuthToken } from '@uploadcare/api-emulator'; import { describe, expect, it, vi } from 'vitest'; import { session } from '../../../../tests/specs/setup'; import { AiProviderError } from '../model/types'; @@ -143,7 +143,7 @@ describe('UploadcareApiClient', () => { it('POSTs pub_key + prompt + source uuid + filename to the edit endpoint', async () => { const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); - const job = await client.edit({ prompt: 'remove the cat', source: DEMO_FILES[0], filename: 'edited.png' }); + const job = await client.edit({ prompt: 'remove the cat', source: DEMO_IMAGE_UUID, filename: 'edited.png' }); expect(job).toEqual({ type: 'job', job_id: expect.any(String) }); expect(session.requests[0].url).toBe('https://upload.uploadcare.com/derivative/image/edit/'); @@ -151,7 +151,7 @@ describe('UploadcareApiClient', () => { expect(await session.requests[0].json()).toMatchObject({ pub_key: PUBLIC_KEY, prompt: 'remove the cat', - source: DEMO_FILES[0], + source: DEMO_IMAGE_UUID, filename: 'edited.png', }); }); @@ -159,8 +159,8 @@ describe('UploadcareApiClient', () => { it('includes aspect_ratio only when provided', async () => { const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); - await client.edit({ prompt: 'x', source: DEMO_FILES[0], filename: 'f.png' }); - await client.edit({ prompt: 'x', source: DEMO_FILES[0], filename: 'f.png', aspectRatio: [16, 9] }); + await client.edit({ prompt: 'x', source: DEMO_IMAGE_UUID, filename: 'f.png' }); + await client.edit({ prompt: 'x', source: DEMO_IMAGE_UUID, filename: 'f.png', aspectRatio: [16, 9] }); expect((await session.requests[0].json()).aspect_ratio).toBeUndefined(); expect((await session.requests[1].json()).aspect_ratio).toEqual([16, 9]); @@ -169,10 +169,10 @@ describe('UploadcareApiClient', () => { it('includes metadata only when provided', async () => { const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); - await client.edit({ prompt: 'x', source: DEMO_FILES[0], filename: 'f.png' }); + await client.edit({ prompt: 'x', source: DEMO_IMAGE_UUID, filename: 'f.png' }); await client.edit({ prompt: 'x', - source: DEMO_FILES[0], + source: DEMO_IMAGE_UUID, filename: 'f.png', metadata: { source: 'ai-image-editor' }, }); @@ -203,7 +203,12 @@ describe('UploadcareApiClient', () => { const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); const controller = new AbortController(); - const pending = client.edit({ prompt: 'x', source: DEMO_FILES[0], filename: 'f.png', signal: controller.signal }); + const pending = client.edit({ + prompt: 'x', + source: DEMO_IMAGE_UUID, + filename: 'f.png', + signal: controller.signal, + }); await vi.waitFor(() => expect(session.requests).toHaveLength(1)); controller.abort(); diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts index aa228f9..6d543de 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts @@ -1,4 +1,4 @@ -import { DEMO_FILES, mintAuthToken } from '@uploadcare/api-emulator'; +import { DEMO_IMAGE_UUID, mintAuthToken } from '@uploadcare/api-emulator'; import { getPrefixedCdnBaseAsync } from '@uploadcare/cname-prefix/async'; import type { UploadcareFile } from '@uploadcare/upload-client'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -50,7 +50,7 @@ describe('UploadcareDerivativeApi', () => { await provider.generate({ prompt: 'x', mode: 'edit', - source: DEMO_FILES[0], + source: DEMO_IMAGE_UUID, metadata: { source: 'ai-image-editor' }, }); expect(session.requests[0].url).toBe('https://upload.uploadcare.com/derivative/image/edit/'); @@ -276,12 +276,12 @@ describe('UploadcareDerivativeApi', () => { const result = await provider.generate({ prompt: 'remove the cat', mode: 'edit', - source: DEMO_FILES[0], + source: DEMO_IMAGE_UUID, aspectRatio: [16, 9], }); expect(session.requests[0].url).toBe('https://upload.uploadcare.com/derivative/image/edit/'); - expect(result.uuid).not.toBe(DEMO_FILES[0]); + expect(result.uuid).not.toBe(DEMO_IMAGE_UUID); expect(ratioOf(result.file)).toBe(16 / 9); expect(result.url).toBe(`${CDN}/${result.uuid}/`); expect(result.mode).toBe('edit'); @@ -289,8 +289,8 @@ describe('UploadcareDerivativeApi', () => { it("keeps the source's dimensions when no aspectRatio is given", async () => { const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); - const source = await provider.getFileInfo(DEMO_FILES[0]); - const { file } = await provider.generate({ prompt: 'x', mode: 'edit', source: DEMO_FILES[0] }); + const source = await provider.getFileInfo(DEMO_IMAGE_UUID); + const { file } = await provider.generate({ prompt: 'x', mode: 'edit', source: DEMO_IMAGE_UUID }); expect(file.imageInfo).toMatchObject({ width: source.imageInfo?.width, height: source.imageInfo?.height }); // The seeded demo image is not square, so a 1:1 default would show here. expect(ratioOf(file)).not.toBe(1); @@ -312,10 +312,10 @@ describe('UploadcareDerivativeApi', () => { it('waits for the file to be ready and wraps it as an UploadcareFile on the CDN base', async () => { const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, cdnBaseUrl: CDN }); - const file = await provider.getFileInfo(DEMO_FILES[0]); + const file = await provider.getFileInfo(DEMO_IMAGE_UUID); - expect(file.uuid).toBe(DEMO_FILES[0]); - expect(file.cdnUrl).toBe(`${CDN}/${DEMO_FILES[0]}/`); + expect(file.uuid).toBe(DEMO_IMAGE_UUID); + expect(file.cdnUrl).toBe(`${CDN}/${DEMO_IMAGE_UUID}/`); expect(file.imageInfo).toMatchObject({ width: expect.any(Number), height: expect.any(Number) }); }); @@ -323,7 +323,7 @@ describe('UploadcareDerivativeApi', () => { const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY }); const controller = new AbortController(); controller.abort(); - await expect(provider.getFileInfo(DEMO_FILES[0], controller.signal)).rejects.toThrow(/cancel/i); + await expect(provider.getFileInfo(DEMO_IMAGE_UUID, controller.signal)).rejects.toThrow(/cancel/i); }); it('propagates a lookup failure', async () => { @@ -356,7 +356,7 @@ describe('UploadcareDerivativeApi', () => { await provider.generate({ prompt: 'x', mode: 'generate' }); expect(session.requests[0].headers.get('Authorization')).toBe(`Bearer ${token}`); - await expect(provider.getFileInfo(DEMO_FILES[0])).resolves.toMatchObject({ uuid: DEMO_FILES[0] }); + await expect(provider.getFileInfo(DEMO_IMAGE_UUID)).resolves.toMatchObject({ uuid: DEMO_IMAGE_UUID }); }); it('is refused on both network paths without one', async () => { @@ -365,7 +365,7 @@ describe('UploadcareDerivativeApi', () => { ...NO_DELAY, }); await expect(provider.generate({ prompt: 'x', mode: 'generate' })).rejects.toThrow(/signature/i); - await expect(provider.getFileInfo(DEMO_FILES[0])).rejects.toThrow(/signature/i); + await expect(provider.getFileInfo(DEMO_IMAGE_UUID)).rejects.toThrow(/signature/i); }); it('follows a token that changes, on both network paths', async () => { @@ -382,12 +382,12 @@ describe('UploadcareDerivativeApi', () => { new Set(requests.map((request) => request.headers.get('Authorization'))); await provider.generate({ prompt: 'x', mode: 'generate' }); - await expect(provider.getFileInfo(DEMO_FILES[0])).resolves.toMatchObject({ uuid: DEMO_FILES[0] }); + await expect(provider.getFileInfo(DEMO_IMAGE_UUID)).resolves.toMatchObject({ uuid: DEMO_IMAGE_UUID }); const beforeRotation = session.requests.length; token = second; await provider.generate({ prompt: 'x', mode: 'generate' }); - await expect(provider.getFileInfo(DEMO_FILES[0])).resolves.toMatchObject({ uuid: DEMO_FILES[0] }); + await expect(provider.getFileInfo(DEMO_IMAGE_UUID)).resolves.toMatchObject({ uuid: DEMO_IMAGE_UUID }); expect(tokensSent(session.requests.slice(0, beforeRotation))).toEqual(new Set([`Bearer ${first}`])); expect(tokensSent(session.requests.slice(beforeRotation))).toEqual(new Set([`Bearer ${second}`])); diff --git a/packages/ai-image-editor/tests/editor/filename.e2e.test.tsx b/packages/ai-image-editor/tests/editor/filename.e2e.test.tsx index 759b988..15c1f99 100644 --- a/packages/ai-image-editor/tests/editor/filename.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/filename.e2e.test.tsx @@ -1,4 +1,3 @@ -import { DEMO_FILES } from '@uploadcare/api-emulator'; import type { UploadcareFile } from '@uploadcare/upload-client'; import { describe, expect, it, vi } from 'vitest'; import type { UcAiImageEditorType } from './harness'; @@ -39,9 +38,9 @@ describe(' result filename', () => { it('names the result via the outputFilename resolver (original + counter)', async () => { const el = mount(STAGING); - // A seeded image no test has edited, so no persisted lineage → counter starts at 1. + // History is cleared after every test, so the source has no persisted lineage → counter starts at 1. el.sourceFileInfo = { - uuid: DEMO_FILES[1], + uuid: SAMPLE_UUID, originalFilename: 'cat.png', imageInfo: null, } as unknown as UploadcareFile; diff --git a/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx b/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx index aa011f2..8664680 100644 --- a/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx @@ -1,4 +1,3 @@ -import { DEMO_FILES } from '@uploadcare/api-emulator'; import { describe, expect, it, vi } from 'vitest'; import { page, userEvent } from 'vitest/browser'; import { session } from '../emulator'; @@ -12,6 +11,7 @@ import { mount, promptBox, SAMPLE_UUID, + SECOND_SAMPLE_UUID, STAGING, sendPrompt, } from './harness'; @@ -161,16 +161,16 @@ describe(' generation', () => { it('aborts in-flight generation and shows the new source when source changes', async () => { // The status poll never answers, so the job is still running when the source changes. session.on('GET /derivative/status/', () => new Promise(() => {})); - const el = mount({ ...STAGING, 'source-uuid': DEMO_FILES[0] }); + const el = mount({ ...STAGING, 'source-uuid': SAMPLE_UUID }); await sendPrompt('try'); // The prompt box locks while the run is in flight. await expect.element(promptBox()).toBeDisabled(); // Change source mid-flight — this aborts the in-flight generation. - el.sourceUuid = DEMO_FILES[1]; + el.sourceUuid = SECOND_SAMPLE_UUID; // After the abort, the displayed image should be the new source (no result override). - await expectCanvasToShow(DEMO_FILES[1]!); + await expectCanvasToShow(SECOND_SAMPLE_UUID); await expect.element(promptBox()).toBeEnabled(); }); }); diff --git a/packages/ai-image-editor/tests/editor/harness.ts b/packages/ai-image-editor/tests/editor/harness.ts index 2b37c59..cf6f12b 100644 --- a/packages/ai-image-editor/tests/editor/harness.ts +++ b/packages/ai-image-editor/tests/editor/harness.ts @@ -1,4 +1,4 @@ -import { DEMO_FILES } from '@uploadcare/api-emulator'; +import { ADAPTIVE_IMAGE_UUID, DEMO_FILES, DEMO_IMAGE_UUID } from '@uploadcare/api-emulator'; import { afterEach, beforeEach, expect } from 'vitest'; import { page, userEvent } from 'vitest/browser'; import type { UcAiImageEditor as UcAiImageEditorType } from '../../src/index'; @@ -43,8 +43,11 @@ export function mount(attrs: Record = {}): UcAiImageEditorType { export const STAGING = { pubkey: 'demopublickey', 'cdn-cname': 'https://cdn.example.com' }; -/** An image every fresh emulator session already holds. */ -export const SAMPLE_UUID = DEMO_FILES[0]; +/** An image every fresh emulator session already holds: a stored 136×150 JPEG (not square). */ +export const SAMPLE_UUID = DEMO_IMAGE_UUID; + +/** A second seeded image, for a test that switches sources: the same bytes as `SAMPLE_UUID` under another uuid. */ +export const SECOND_SAMPLE_UUID = ADAPTIVE_IMAGE_UUID; /** The file the test's run produced: the one in the session that isn't a seeded `DEMO_FILES` image. */ export const resultUuid = (): string | undefined => diff --git a/packages/ai-image-editor/tests/editor/history.e2e.test.tsx b/packages/ai-image-editor/tests/editor/history.e2e.test.tsx index 09d3fa7..6634eae 100644 --- a/packages/ai-image-editor/tests/editor/history.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/history.e2e.test.tsx @@ -1,4 +1,3 @@ -import { DEMO_FILES } from '@uploadcare/api-emulator'; import type { UploadcareFile } from '@uploadcare/upload-client'; import { describe, expect, it } from 'vitest'; import { @@ -8,6 +7,7 @@ import { historyChips, mount, SAMPLE_UUID, + SECOND_SAMPLE_UUID, STAGING, sendPrompt, } from './harness'; @@ -52,7 +52,7 @@ describe(' history', () => { it('resumes on the latest result (not the original) when reopening a lineage', async () => { // A seeded image, so the CDN serves it and the canvas can show it. - const LATEST = DEMO_FILES[1]!; + const LATEST = SECOND_SAMPLE_UUID; const key = `uc-ai-image-editor/history/${STAGING.pubkey}`; // Seed a prior edit of SAMPLE_UUID into storage (a result whose parent is it). localStorage.setItem( From 99ec951d0632c07807676b94bde4563291693856 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 22:21:24 +0300 Subject: [PATCH 090/104] test(ai-image-editor): assert the provider rejections with rejects, not .catch(e => e) Checklist item 26. The generation_timeout spec and the job_not_found spec caught the rejection with `.catch((e) => e)` and then read fields off whatever came back. If the call resolved, they were reading the result, not an error. Both now await `expect(pending).rejects.toBeInstanceOf(AiProviderError)` and `rejects.toMatchObject({ errorCode })`. The timeout spec also checks `rejects.toThrow(jobId)`. Fail-first, one break at a time: - A timeout message without the job id fails with "expected to throw error including ''". - A timeout coded `timeout` fails on `{ errorCode: 'generation_timeout' }`. - readJson throwing a plain Error for the envelope fails with "expected Error: Derivative job is not found. to be an instance of AiProviderError". Co-Authored-By: Claude Opus 5.5 --- .../src/entities/provider/api/uploadcareApiClient.test.ts | 6 +++--- .../entities/provider/api/uploadcareDerivativeApi.test.ts | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts index 2fc1d79..a0a6dd9 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts @@ -244,9 +244,9 @@ describe('UploadcareApiClient', () => { it('surfaces a platform error envelope (e.g. job_not_found) as an AiProviderError', async () => { const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); - const err = await client.getJobStatus('job-1').catch((e) => e); - expect(err).toBeInstanceOf(AiProviderError); - expect(err.errorCode).toBe('job_not_found'); + const pending = client.getJobStatus('job-1'); + await expect(pending).rejects.toBeInstanceOf(AiProviderError); + await expect(pending).rejects.toMatchObject({ errorCode: 'job_not_found' }); }); it('gives up on a hanging request when the caller aborts', async () => { diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts index 6d543de..5d97f96 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts @@ -111,11 +111,11 @@ describe('UploadcareDerivativeApi', () => { pollIntervalMs: 0, pollTimeoutMs: 0, }); - const err = await provider.generate({ prompt: 'x', mode: 'generate' }).catch((e) => e); - expect(err).toBeInstanceOf(AiProviderError); - expect(err.errorCode).toBe('generation_timeout'); + const pending = provider.generate({ prompt: 'x', mode: 'generate' }); + await expect(pending).rejects.toBeInstanceOf(AiProviderError); + await expect(pending).rejects.toMatchObject({ errorCode: 'generation_timeout' }); expect(jobId).toEqual(expect.any(String)); - expect(err.message).toContain(jobId); + await expect(pending).rejects.toThrow(jobId); }); describe('with the default poll options', () => { From e026c2dc495b93f4e3309c77d8aa92e9df49b557 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 22:21:54 +0300 Subject: [PATCH 091/104] test(ai-image-editor): call the uploader's initFlow unconditionally in the plugin tests Checklist item 26. The two AI Edit tests called `(api as unknown as { initFlow?: () => void }).initFlow?.()`. If the uploader's API ever lost initFlow, that line would do nothing and the test would carry on without opening the flow. file-uploader 1.34 declares `initFlow: (force?: boolean) => void`, and this file's UploadCtxProvider type already lists it. The tests now call `api.initFlow()`, with no cast and no optional call. Fail-first: pointing the call at a method that doesn't exist fails both tests with "TypeError: api.gone is not a function". With the old `?.()`, the same change would have been skipped without any error. Co-Authored-By: Claude Opus 5.5 --- packages/ai-image-editor/tests/plugin.e2e.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ai-image-editor/tests/plugin.e2e.test.ts b/packages/ai-image-editor/tests/plugin.e2e.test.ts index 07075f1..aaba1ac 100644 --- a/packages/ai-image-editor/tests/plugin.e2e.test.ts +++ b/packages/ai-image-editor/tests/plugin.e2e.test.ts @@ -165,7 +165,7 @@ describe('AiImageEditorPlugin', () => { await renderUploader([AiImageEditorPlugin]); const api = getApi(); api.addFileFromUrl(TEST_IMAGE_URL); - (api as unknown as { initFlow?: () => void }).initFlow?.(); + api.initFlow(); await expect.element(page.getByRole('button', { name: 'AI Edit' })).toBeVisible(); await page.getByRole('button', { name: 'AI Edit' }).click(); @@ -181,7 +181,7 @@ describe('AiImageEditorPlugin', () => { await renderUploader([AiImageEditorPlugin]); const api = getApi(); api.addFileFromUrl(TEST_IMAGE_URL); - (api as unknown as { initFlow?: () => void }).initFlow?.(); + api.initFlow(); // Wait until the source file finished uploading (the AI Edit action only // renders once the entry has a uuid). From b83afd5a2b04a083028087185ce3104d61f02725 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 22:22:09 +0300 Subject: [PATCH 092/104] test(ai-image-editor): check each popular aspect ratio in its own it.each case Checklist item 26. "contains only valid ratios" looped over POPULAR_ASPECT_RATIOS inside one test. A failure didn't say which ratio broke, and an empty list would have passed without running a single assertion. It is now `it.each(POPULAR_ASPECT_RATIOS)`, one case per ratio, each named after it ("offers 9:16, a valid ratio"). Fail-first: changing the 9:16 entry to 9:160 (outside the 10:1 bound) fails exactly "offers 9:160, a valid ratio". Co-Authored-By: Claude Opus 5.5 --- .../src/entities/aspect-ratio/model/aspect-ratio.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/ai-image-editor/src/entities/aspect-ratio/model/aspect-ratio.test.ts b/packages/ai-image-editor/src/entities/aspect-ratio/model/aspect-ratio.test.ts index 1cb7d7b..ce90b0b 100644 --- a/packages/ai-image-editor/src/entities/aspect-ratio/model/aspect-ratio.test.ts +++ b/packages/ai-image-editor/src/entities/aspect-ratio/model/aspect-ratio.test.ts @@ -120,10 +120,8 @@ describe('aspectRatioEquals', () => { }); describe('POPULAR_ASPECT_RATIOS', () => { - it('contains only valid ratios', () => { - for (const ratio of POPULAR_ASPECT_RATIOS) { - expect(isValidAspectRatio(ratio)).toBe(true); - } + it.each(POPULAR_ASPECT_RATIOS)('offers %i:%i, a valid ratio', (w, h) => { + expect(isValidAspectRatio([w, h])).toBe(true); }); it('contains 1:1', () => { From eecc9d9cf9171e14ea139866c24979329ecfb1d5 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 22:22:36 +0300 Subject: [PATCH 093/104] test(ai-image-editor): merge the two uc:done detail tests into one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checklist item 27. Two tests made the same run and read the same uc:done detail. "generates via the send button, then the primary commits the result with uc:done" checked the url, file.uuid and file.cdnUrl. "includes the UploadcareFile and its uuid in uc:done" checked file.uuid and uuid. One test now commits a run and holds the detail to a single toMatchObject covering `{ url, uuid, file: { uuid, cdnUrl } }`. The first test also checked that Done is disabled before a result. That is covered by "does not dispatch uc:done when there is no result (generate mode)", which asserts the same disabled state. Fail-first: with _resultDetail dropping `uuid`, the merged test fails with "expected { …(6) } to match object { …(3) }". Co-Authored-By: Claude Opus 5.5 --- .../tests/editor/generation.e2e.test.tsx | 36 +++++-------------- 1 file changed, 9 insertions(+), 27 deletions(-) diff --git a/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx b/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx index 8664680..ae757ae 100644 --- a/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/generation.e2e.test.tsx @@ -36,41 +36,23 @@ describe(' generation', () => { await expect.element(promptBox()).toHaveValue(''); }); - it('generates via the send button, then the primary commits the result with uc:done', async () => { + it('commits the generated result with uc:done, carrying its url, uuid and UploadcareFile', async () => { const el = mount(STAGING); - - // Primary is disabled until there is a result to commit. - await expect.element(doneButton()).toBeDisabled(); - - await sendPrompt('a tiger'); - const uuid = await generatedUuid(); - await expectCanvasToShow(uuid); - - // Now the primary commits the generated result (it never generates). - await expect.element(doneButton()).toBeEnabled(); const onDone = vi.fn(); el.addEventListener('uc:done', onDone); - await clickDone(); - expect(onDone).toHaveBeenCalledOnce(); - const detail = onDone.mock.calls[0]![0].detail; - expect(detail.url).toBe(`https://cdn.example.com/${uuid}/`); - expect(detail.file.uuid).toBe(uuid); - expect(detail.file.cdnUrl).toBe(`https://cdn.example.com/${uuid}/`); - }); - it('includes the UploadcareFile and its uuid in uc:done after a generation', async () => { - const el = mount(STAGING); - const onDone = vi.fn(); - el.addEventListener('uc:done', onDone); - - await sendPrompt('make it pop'); + await sendPrompt('a tiger'); const uuid = await generatedUuid(); await expectCanvasToShow(uuid); - + // The primary commits the result; it never generates. await clickDone(); + expect(onDone).toHaveBeenCalledOnce(); - expect(onDone.mock.calls[0]![0].detail.file.uuid).toBe(uuid); - expect(onDone.mock.calls[0]![0].detail.uuid).toBe(uuid); + expect(onDone.mock.calls[0]![0].detail).toMatchObject({ + url: `https://cdn.example.com/${uuid}/`, + uuid, + file: { uuid, cdnUrl: `https://cdn.example.com/${uuid}/` }, + }); }); it('reports a refused run with uc:error and its message, and keeps the prompt', async () => { From f383d8f4e1ae27ae43a84fdc4d6b9f5aacca8236 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 22:24:01 +0300 Subject: [PATCH 094/104] test(ai-image-editor): run the layout placement tables with it.each Checklist item 27 (Eager Test). Three layout tests each walked one element through three or four configurations, so a failure named the test but not the configuration. Each is now an it.each table, one fresh editor per row, named after its inputs and the expected side: - composer: canvas-fit x composer-placement, 4 rows - toolbar: bottom, top and junk-falls-back, 3 rows - history strip: composer-above, composer-below and canvas-top, 3 rows. Each row checks the side of the canvas and of the prompt, using a small sideOfPrompt helper next to sideOfCanvas. The toolbar default moved into the defaults test, and `none` got its own test. Fail-first, one break at a time: - Toolbar fallback set to 'top': only the "junk" row fails. - composer-above and composer-below templates swapped: those two history rows fail. - composerOverlay inverted: the four composer rows fail, along with the tests that depend on docking. Co-Authored-By: Claude Opus 5.5 --- .../tests/editor/layout.e2e.test.tsx | 109 +++++++++--------- 1 file changed, 54 insertions(+), 55 deletions(-) diff --git a/packages/ai-image-editor/tests/editor/layout.e2e.test.tsx b/packages/ai-image-editor/tests/editor/layout.e2e.test.tsx index 1185f0b..75d1e30 100644 --- a/packages/ai-image-editor/tests/editor/layout.e2e.test.tsx +++ b/packages/ai-image-editor/tests/editor/layout.e2e.test.tsx @@ -28,6 +28,15 @@ function sideOfCanvas(el: UcAiImageEditorType, target: Locator): 'above' | 'belo return (box.top + box.bottom) / 2 < (canvas.top + canvas.bottom) / 2 ? 'over-top' : 'over-bottom'; } +/** Where `target` sits against the prompt box: wholly above or below it, or overlapping it. */ +function sideOfPrompt(target: Locator): 'above' | 'below' | 'overlapping' { + const box = rectOf(target); + const prompt = rectOf(promptBox()); + if (box.bottom <= prompt.top) return 'above'; + if (box.top >= prompt.bottom) return 'below'; + return 'overlapping'; +} + /** Runs one generation so the history strip mounts. */ async function generateOnce(): Promise { await sendPrompt('a tiger'); @@ -60,74 +69,64 @@ async function transitionsDone(el: UcAiImageEditorType): Promise { * how auto-hide docking changes that, measured on the rendered boxes. */ describe(' layout', () => { - it('places the composer per composer-placement + canvas-fit', async () => { - const el = mount(STAGING); - el.canvasFit = 'full'; - el.composerPlacement = 'bottom'; - - // canvas-fit full → composer floats over the full canvas. - await expect.poll(() => sideOfCanvas(el, promptBox())).toBe('over-bottom'); - - el.composerPlacement = 'top'; - await expect.poll(() => sideOfCanvas(el, promptBox())).toBe('over-top'); - - // canvas-fit available → composer docked outside the canvas so the canvas - // shrinks. `top` sits above it… - el.canvasFit = 'available'; - el.composerPlacement = 'top'; - await expect.poll(() => sideOfCanvas(el, promptBox())).toBe('above'); - - // …`bottom` below it. - el.composerPlacement = 'bottom'; - await expect.poll(() => sideOfCanvas(el, promptBox())).toBe('below'); - }); - - it('defaults to a docked composer at the bottom', async () => { + // canvas-fit full floats the composer over the canvas; available docks it outside, so the canvas shrinks. + it.each([ + ['full', 'bottom', 'over-bottom'], + ['full', 'top', 'over-top'], + ['available', 'top', 'above'], + ['available', 'bottom', 'below'], + ] as const)( + 'with canvas-fit %s and composer-placement %s, puts the composer %s the canvas', + async (fit, placement, side) => { + const el = mount(STAGING); + el.canvasFit = fit; + el.composerPlacement = placement; + await expect.poll(() => sideOfCanvas(el, promptBox())).toBe(side); + }, + ); + + it('defaults to a docked composer and a toolbar, both below the canvas', async () => { const el = mount(STAGING); expect(el.composerPlacement).toBe('bottom'); expect(el.canvasFit).toBe('available'); await expect.poll(() => sideOfCanvas(el, promptBox())).toBe('below'); + await expect.poll(() => sideOfCanvas(el, doneButton())).toBe('below'); }); - it('places the toolbar per toolbar-placement', async () => { + // An unknown value falls back to the default (bottom), like the other placement enums. + it.each([ + ['bottom', 'below'], + ['top', 'above'], + ['junk', 'below'], + ] as const)('with toolbar-placement %s, puts the toolbar %s the canvas', async (placement, side) => { const el = mount(STAGING); + el.toolbarPlacement = placement as UcAiImageEditorType['toolbarPlacement']; + await expect.poll(() => sideOfCanvas(el, doneButton())).toBe(side); + }); - // Default: toolbar at the bottom (below the canvas). - await expect.poll(() => sideOfCanvas(el, doneButton())).toBe('below'); - - // Top: toolbar above the canvas. - el.toolbarPlacement = 'top'; - await expect.poll(() => sideOfCanvas(el, doneButton())).toBe('above'); - - // None: no toolbar at all; the canvas still renders. + it('renders no toolbar with toolbar-placement none, and still renders the canvas', async () => { + const el = mount(STAGING); el.toolbarPlacement = 'none'; await expect.element(doneButton()).not.toBeInTheDocument(); expect(canvasRect(el).height).toBeGreaterThan(0); - - // An unknown value falls back to the default (bottom), like the other - // placement enums. - el.toolbarPlacement = 'junk' as never; - await expect.poll(() => sideOfCanvas(el, doneButton())).toBe('below'); }); - it('places the history strip per history-placement (overlay composer)', async () => { - const el = mount(STAGING); - el.canvasFit = 'full'; // relative history rides an overlay composer - await generateOnce(); - - // composer-above (default): over the canvas, right above the prompt. - await expect.poll(() => rectOf(historyStrip()).bottom - rectOf(promptBox()).top).toBeLessThanOrEqual(0); - await expect.poll(() => sideOfCanvas(el, historyStrip())).toBe('over-bottom'); - - // composer-below: under the prompt. - el.historyPlacement = 'composer-below'; - await expect.poll(() => rectOf(historyStrip()).top - rectOf(promptBox()).bottom).toBeGreaterThanOrEqual(0); - - // canvas-top: pinned to the canvas's top edge, away from the composer at the bottom. - el.historyPlacement = 'canvas-top'; - await expect.poll(() => sideOfCanvas(el, historyStrip())).toBe('over-top'); - expect(sideOfCanvas(el, promptBox())).toBe('over-bottom'); - }); + // An overlay composer (canvas-fit full), which relative history placements ride. + it.each([ + ['composer-above', 'over-bottom', 'above'], + ['composer-below', 'over-bottom', 'below'], + ['canvas-top', 'over-top', 'above'], + ] as const)( + 'with history-placement %s, puts the history strip %s the canvas and %s the prompt', + async (placement, canvasSide, promptSide) => { + const el = mount(STAGING); + el.canvasFit = 'full'; + el.historyPlacement = placement; + await generateOnce(); + await expect.poll(() => sideOfCanvas(el, historyStrip())).toBe(canvasSide); + await expect.poll(() => sideOfPrompt(historyStrip())).toBe(promptSide); + }, + ); it('pins the history over the canvas when the composer is docked-out', async () => { const el = mount(STAGING); // docked at the bottom by default From 660947f82d11241293cad00de4f230dd69062db9 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 22:24:52 +0300 Subject: [PATCH 095/104] test(ai-image-editor): cover a 5xx status poll in the middle of a job Checklist item 35. Every failed-poll test failed the first poll, with a 4xx. None covered an upstream 5xx after the job was already running. The new spec answers `processing` on the first poll and `503 Service Unavailable` on the second, using session.on scenarios. It expects generate() to reject with the status and checks that exactly those two polls went out, so the job neither retries nor waits for the timeout. Fail-first, one break at a time: - A check that swallows poll errors and keeps polling fails with "promise resolved instead of rejecting". - readJson not throwing on !response.ok fails with "expected to throw /503 Service Unavailable/ but got 'Cannot read properties of null'". Co-Authored-By: Claude Opus 5.5 --- .../api/uploadcareDerivativeApi.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts index 5d97f96..600ca82 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts @@ -118,6 +118,25 @@ describe('UploadcareDerivativeApi', () => { await expect(pending).rejects.toThrow(jobId); }); + describe('when a status poll fails mid-job', () => { + /** The job's status polls, in order. */ + const statusPolls = () => session.requests.filter((request) => new URL(request.url).pathname === STATUS_PATH); + + /** The job's first poll answers `processing`; the next one answers `failure`. */ + const failSecondPoll = (failure: () => Response) => { + session.on(STATUS, failure, { times: 1 }); + session.on(STATUS, () => Response.json({ type: 'job', status: 'processing' }), { times: 1 }); + }; + + it('rejects with the status of a 5xx, and polls no more', async () => { + failSecondPoll(() => new Response('upstream down', { status: 503, statusText: 'Service Unavailable' })); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); + + await expect(provider.generate({ prompt: 'x', mode: 'generate' })).rejects.toThrow(/503 Service Unavailable/); + expect(statusPolls()).toHaveLength(2); + }); + }); + describe('with the default poll options', () => { // The documented defaults: a poll every 1.5s, giving up after 1,000,000ms. const INTERVAL = 1500; From 448ddffe453b21b4bdace5b2f9c318334f953533 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 22:25:09 +0300 Subject: [PATCH 096/104] test(ai-image-editor): cover a dropped connection on a status poll mid-job Checklist item 35. In the new spec the second status poll answers `Response.error()`, a network failure, after a `processing` frame. generate() must reject with fetch's own error, without wrapping it and without turning it into a timeout, and send no poll after it. The spec matches `/fetch/i` rather than TypeError because the specs' happy-dom fetch raises its own NetworkError ('Failed to execute "fetch()"...'). A browser raises TypeError: Failed to fetch. Fail-first: a check that treats a network error as `processing` and keeps polling fails with "promise resolved instead of rejecting". Co-Authored-By: Claude Opus 5.5 --- .../provider/api/uploadcareDerivativeApi.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts index 600ca82..275fc5d 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts @@ -135,6 +135,15 @@ describe('UploadcareDerivativeApi', () => { await expect(provider.generate({ prompt: 'x', mode: 'generate' })).rejects.toThrow(/503 Service Unavailable/); expect(statusPolls()).toHaveLength(2); }); + + it('rejects with the network error of a dropped connection, and polls no more', async () => { + failSecondPoll(() => Response.error()); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); + + // fetch's own error, untouched: not a provider error and not a timeout. + await expect(provider.generate({ prompt: 'x', mode: 'generate' })).rejects.toThrow(/fetch/i); + expect(statusPolls()).toHaveLength(2); + }); }); describe('with the default poll options', () => { From 472d1cede6ebfb78a687a82e3a6b2414a3a4664c Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 22:25:50 +0300 Subject: [PATCH 097/104] fix(provider): reject a 2xx derivative response that is not a JSON object Checklist item 35 (a malformed response, and a regression test for the bug it found). If a status poll came back 200 with a body that isn't JSON, such as a proxy's HTML page or an empty body, readJson returned null. The poll then crashed with "Cannot read properties of null (reading 'status')", and the dev schema check reported drift on the way. readJson now throws "Uploadcare failed (): the response is not a JSON object: " for any 2xx that doesn't parse to an object. All three derivative endpoints answer an object. Fail-first: before the fix, the new spec (processing, then a 200 text/html frame) failed with "expected to throw error including 'Uploadcare generate status failed (20...' but got 'Cannot read properties of null'". Co-Authored-By: Claude Opus 5.5 --- .../src/entities/provider/api/uploadcareApiClient.ts | 6 ++++++ .../provider/api/uploadcareDerivativeApi.test.ts | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.ts index 54da2f6..9a716b1 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.ts @@ -152,6 +152,12 @@ async function readJson(response: Response, action: string): Promise { `Uploadcare ${action} failed (${response.status} ${response.statusText})${text ? `: ${text}` : ''}`, ); } + // Every derivative endpoint answers a JSON object; anything else (a proxy's HTML page, an empty body) is a failure. + if (data === null || typeof data !== 'object') { + throw new Error( + `Uploadcare ${action} failed (${response.status}): the response is not a JSON object${text ? `: ${text}` : ''}`, + ); + } return data; } diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts index 275fc5d..8e9d05a 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts @@ -144,6 +144,16 @@ describe('UploadcareDerivativeApi', () => { await expect(provider.generate({ prompt: 'x', mode: 'generate' })).rejects.toThrow(/fetch/i); expect(statusPolls()).toHaveLength(2); }); + + it('rejects a status frame that is not a JSON object, and polls no more', async () => { + failSecondPoll(() => new Response('Bad gateway', { headers: { 'Content-Type': 'text/html' } })); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); + + await expect(provider.generate({ prompt: 'x', mode: 'generate' })).rejects.toThrow( + 'Uploadcare generate status failed (200): the response is not a JSON object: Bad gateway', + ); + expect(statusPolls()).toHaveLength(2); + }); }); describe('with the default poll options', () => { From 03a5db2cc285a9284c75a0b3d0a384b8849104dc Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 22:26:16 +0300 Subject: [PATCH 098/104] test(ai-image-editor): cover an unreadable and a mid-job expired auth token Checklist item 35. The authToken specs covered a missing token (SignatureRequiredError) and rotation, but not a token the API refuses. AccessTokenInvalidError only came up in an e2e comment. Two new specs run against the signedUploads preset: - An unreadable token ('not-a-jwt') rejects with `{ name: 'AiProviderError', errorCode: 'AccessTokenInvalidError' }` after the one refused start request. - A token function that hands out a fresh token and then one two minutes past `exp` (outside the API's 30s leeway). The start goes through, and the first poll rejects the job with AccessTokenExpiredError. Exactly two requests are sent. Fail-first, one break at a time: - readJson throwing a plain Error for the envelope fails both. - The client resolving the token once and reusing it fails the expiry spec with "promise resolved instead of rejecting". Co-Authored-By: Claude Opus 5.5 --- .../api/uploadcareDerivativeApi.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts index 8e9d05a..ec954c3 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts @@ -406,6 +406,30 @@ describe('UploadcareDerivativeApi', () => { await expect(provider.getFileInfo(DEMO_IMAGE_UUID)).rejects.toThrow(/signature/i); }); + it('surfaces a token the API cannot read as AccessTokenInvalidError, without polling', async () => { + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, authToken: 'not-a-jwt', ...NO_DELAY }); + + await expect(provider.generate({ prompt: 'x', mode: 'generate' })).rejects.toMatchObject({ + name: 'AiProviderError', + errorCode: 'AccessTokenInvalidError', + }); + expect(session.requests).toHaveLength(1); + }); + + it('surfaces a token that expires mid-job as AccessTokenExpiredError on the next poll', async () => { + // Two minutes past `exp`, beyond the API's 30s clock leeway. + const [fresh, expired] = [await mintAuthToken(), await mintAuthToken({ lifetime: -120_000 })]; + const authToken = vi.fn().mockResolvedValueOnce(fresh).mockResolvedValue(expired); + const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, authToken, ...NO_DELAY }); + + await expect(provider.generate({ prompt: 'x', mode: 'generate' })).rejects.toMatchObject({ + name: 'AiProviderError', + errorCode: 'AccessTokenExpiredError', + }); + // The start went through on the fresh token; the first poll, on the expired one, ended the job. + expect(session.requests).toHaveLength(2); + }); + it('follows a token that changes, on both network paths', async () => { // The provider holds one resolver for its lifetime; whoever owns the // token changes what that resolver returns, and nothing is pushed in. From b73042fb4f761560c1645d21f95342fb648d5764 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 22:26:30 +0300 Subject: [PATCH 099/104] test(ai-image-editor): cover an authToken function that throws Checklist item 35. Nothing covered a token function that fails, for example an app whose token endpoint is down. The new spec, run against the signedUploads preset, passes an authToken that rejects with `token service down`. generate() must reject with that same error and send no request. Sending the call unsigned would hide the real cause behind a SignatureRequiredError. Fail-first: the client swallowing the resolver's error and going on without a header fails with "expected to throw error including 'token service down' but got '`signature` is required.'". Co-Authored-By: Claude Opus 5.5 --- .../provider/api/uploadcareDerivativeApi.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts index ec954c3..1f31a7e 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts @@ -430,6 +430,19 @@ describe('UploadcareDerivativeApi', () => { expect(session.requests).toHaveLength(2); }); + it("rejects with the token function's own error and sends nothing when it throws", async () => { + const provider = new UploadcareDerivativeApi({ + publicKey: PUBLIC_KEY, + authToken: async () => { + throw new Error('token service down'); + }, + ...NO_DELAY, + }); + + await expect(provider.generate({ prompt: 'x', mode: 'generate' })).rejects.toThrow('token service down'); + expect(session.requests).toHaveLength(0); + }); + it('follows a token that changes, on both network paths', async () => { // The provider holds one resolver for its lifetime; whoever owns the // token changes what that resolver returns, and nothing is pushed in. From abb26086a1dd327cb18fcd8f7adbcf9fc4a1618f Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 22:27:22 +0300 Subject: [PATCH 100/104] test(ai-image-editor): property-test the aspect-ratio list parser with fast-check Checklist item 35. parseAspectRatioList reads a host-supplied attribute (aspect-ratios="16:9, 5:4"), but its tests covered only four hand-picked strings. This adds fast-check as a dev dependency and two properties: - Round trip: any list of valid ratios, written as "w:h" with fractional or tiny sides and joined by any of `,`, ` `, `, `, `,,`, a newline or a tab, parses back to the same list in the same order. - Safety: for any string, or a list of "w:h" pairs of arbitrary doubles (NaN, Infinity, -0, negatives, out of the 10:1 bound), every ratio it returns is valid. The lockfile diff also picks up the emulator's current devDependencies (fast-check and @vitest/coverage-v8), since it is linked through the temporary file: dependency. Fail-first, one break at a time: - Splitting on commas only fails the round trip, with counterexample `[[[0.001,0.001],[0.001,0.001]],"\n"]`. - Keeping any positive pair fails the safety property, with counterexample `"5e-324:5.4e-323"`. Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 43 +++++++++++++++++++ packages/ai-image-editor/package.json | 1 + .../aspect-ratio/model/aspect-ratio.test.ts | 26 +++++++++++ 3 files changed, 70 insertions(+) diff --git a/package-lock.json b/package-lock.json index 5f6b2b8..631c880 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,8 +40,10 @@ "devDependencies": { "@mswjs/interceptors": "^0.45.7", "@vitest/browser-playwright": "^4.1.11", + "@vitest/coverage-v8": "^4.1.11", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", + "fast-check": "^4.10.2", "msw": "^3.0.2", "oxfmt": "^0.53.0", "oxlint": "^1.68.0", @@ -7280,6 +7282,29 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-check": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.2.tgz", + "integrity": "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -13149,6 +13174,23 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/quansync": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", @@ -16521,6 +16563,7 @@ "@uploadcare/api-emulator": "file:/Users/nd0ut/workspace/uc-586-sync/packages/api-emulator", "@uploadcare/file-uploader": "1.34.0", "@vitest/browser-playwright": "^4.1.11", + "fast-check": "^4.10.2", "lit-analyzer": "^2.0.3", "playwright": "^1.49.0", "typedoc": "^0.28.19", diff --git a/packages/ai-image-editor/package.json b/packages/ai-image-editor/package.json index 4b8b1f9..057ff87 100644 --- a/packages/ai-image-editor/package.json +++ b/packages/ai-image-editor/package.json @@ -64,6 +64,7 @@ "@uploadcare/api-emulator": "file:/Users/nd0ut/workspace/uc-586-sync/packages/api-emulator", "@uploadcare/file-uploader": "1.34.0", "@vitest/browser-playwright": "^4.1.11", + "fast-check": "^4.10.2", "lit-analyzer": "^2.0.3", "playwright": "^1.49.0", "typedoc": "^0.28.19", diff --git a/packages/ai-image-editor/src/entities/aspect-ratio/model/aspect-ratio.test.ts b/packages/ai-image-editor/src/entities/aspect-ratio/model/aspect-ratio.test.ts index ce90b0b..1db7639 100644 --- a/packages/ai-image-editor/src/entities/aspect-ratio/model/aspect-ratio.test.ts +++ b/packages/ai-image-editor/src/entities/aspect-ratio/model/aspect-ratio.test.ts @@ -1,3 +1,4 @@ +import fc from 'fast-check'; import { describe, expect, it } from 'vitest'; import { aspectRatioEquals, @@ -107,6 +108,31 @@ describe('parseAspectRatioList', () => { expect(parseAspectRatioList('')).toEqual([]); expect(parseAspectRatioList(' ')).toEqual([]); }); + + /** A side length, as a host might write it: whole or fractional, tiny to huge. */ + const side = fc.double({ min: 0.001, max: 10_000, noNaN: true }); + const validRatio = fc.tuple(side, side).filter((ratio) => isValidAspectRatio(ratio)); + const separator = fc.constantFrom(',', ' ', ', ', ' , ', ',,', '\n', '\t'); + + it('reads back any list of valid ratios, in order, whatever separates them', () => { + fc.assert( + fc.property(fc.array(validRatio), separator, (ratios, sep) => { + expect(parseAspectRatioList(ratios.map(aspectRatioKey).join(sep))).toEqual(ratios); + }), + ); + }); + + it('answers only valid ratios, whatever it is given', () => { + // Any string, or "w:h" pairs of any doubles (NaN, ±Infinity, -0, negatives, out of the 10:1 bound). + const pairs = fc + .array(fc.tuple(fc.double(), fc.double())) + .map((list) => list.map(([w, h]) => `${w}:${h}`).join(',')); + fc.assert( + fc.property(fc.oneof(fc.string(), pairs), (input) => { + expect(parseAspectRatioList(input).filter((ratio) => !isValidAspectRatio(ratio))).toEqual([]); + }), + ); + }); }); describe('aspectRatioEquals', () => { From 4b353ab8c976b6f241a4f9ef2ce64503abdad368 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 22:28:03 +0300 Subject: [PATCH 101/104] test(ai-image-editor): property-test the CDN preview and thumbnail builders with fast-check Checklist item 35. cdnPreviewUrl and cdnSquareThumbUrl had four example tests, all on one url at dpr 1 or 2. Two fast-check properties now run over the default, prefixed and custom-cname origins, any uuid, any box size from 1 to 5000 CSS px, and any devicePixelRatio from 0.25 to 4. Each URL must be the file URL followed by exactly one square directive (`-/preview/NxN/`, or `-/scale_crop/NxN/center/`). The side N must stay within the CDN's 3000 limit. It must not fall short of the box's device pixels (counting a dpr below 1 as 1) unless the limit stops it, and it must not exceed them by a whole pixel. Fail-first, one break at a time; each fails both properties: - Math.round instead of Math.ceil: "expected 4 to be greater than or equal to 4.000000000000001". - No cap: "expected 3001 to be less than or equal to 3000". - dpr not floored at 1: "expected 1 to be greater than or equal to 1.0000000000000002". Co-Authored-By: Claude Opus 5.5 --- .../src/shared/lib/cdn.test.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/packages/ai-image-editor/src/shared/lib/cdn.test.ts b/packages/ai-image-editor/src/shared/lib/cdn.test.ts index 6b477df..4bb9573 100644 --- a/packages/ai-image-editor/src/shared/lib/cdn.test.ts +++ b/packages/ai-image-editor/src/shared/lib/cdn.test.ts @@ -1,3 +1,4 @@ +import fc from 'fast-check'; import { describe, expect, it, vi } from 'vitest'; import { CDN_MAX_OUTPUT_DIMENSION, cdnPreviewUrl, cdnSquareThumbUrl } from './cdn'; @@ -28,4 +29,48 @@ describe('cdn helpers', () => { // scale_crop (not preview) so a non-square source fills the square tile sharply. expect(cdnSquareThumbUrl(UC_URL, 48)).toBe(`${UC_URL}-/scale_crop/96x96/center/`); }); + + describe('for any file, box size and devicePixelRatio', () => { + const fileUrl = fc + .tuple( + fc.constantFrom('https://ucarecdn.com', 'https://1a2b3c4d5e.ucarecd.net', 'https://cdn.example.com'), + fc.uuid(), + ) + .map(([origin, uuid]) => `${origin}/${uuid}/`); + const cssSize = fc.double({ min: 1, max: 5000, noNaN: true }); + const dpr = fc.double({ min: 0.25, max: 4, noNaN: true }); + + /** + * Holds `side` (the n of an n×n directive) to the box: never past the CDN limit, never short of the box's device + * pixels (counting a dpr below 1 as 1) unless the limit stops it, and never a whole pixel more. + */ + const expectSideToFit = (side: number, css: number, ratio: number) => { + const needed = css * Math.max(ratio, 1); + expect(side).toBeLessThanOrEqual(CDN_MAX_OUTPUT_DIMENSION); + expect(side).toBeGreaterThanOrEqual(Math.min(needed, CDN_MAX_OUTPUT_DIMENSION)); + expect(side - 1).toBeLessThan(needed); + }; + + it('appends one square preview sized to the box', () => { + fc.assert( + fc.property(fileUrl, cssSize, dpr, (url, css, ratio) => { + vi.stubGlobal('devicePixelRatio', ratio); + const [, w] = cdnPreviewUrl(url, css).match(/\/-\/preview\/(\d+)x/) ?? []; + expect(cdnPreviewUrl(url, css)).toBe(`${url}-/preview/${w}x${w}/`); + expectSideToFit(Number(w), css, ratio); + }), + ); + }); + + it('appends one centred square crop sized to the box', () => { + fc.assert( + fc.property(fileUrl, cssSize, dpr, (url, css, ratio) => { + vi.stubGlobal('devicePixelRatio', ratio); + const [, w] = cdnSquareThumbUrl(url, css).match(/\/-\/scale_crop\/(\d+)x/) ?? []; + expect(cdnSquareThumbUrl(url, css)).toBe(`${url}-/scale_crop/${w}x${w}/center/`); + expectSideToFit(Number(w), css, ratio); + }), + ); + }); + }); }); From 110ce611ff30ec6b188b3edaab05405499cbcf51 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 9 Oct 2026 22:28:28 +0300 Subject: [PATCH 102/104] docs(ai-image-editor): note in the e2e harness that preset order matters Checklist item 18. The refused-run test applies derivativesInstant again after derivativeFailure, and that order only made sense to someone who had read the emulator's source. The emulator was left alone. Order dependence is inherent to its scenario chain, where the newest scenario answers first and derivativesInstant can only speed up the scenarios behind it. The README's preset table and the preset's doc comment (both from #586) already say so. The harness's beforeEach, which every editor test inherits, now says so too and points to the README. Without the second derivativesInstant, the refused-run test still passes. It just waits the editor's 1.5s interval for the error frame. Documentation only; nothing to break. Co-Authored-By: Claude Opus 5.5 --- packages/ai-image-editor/tests/editor/harness.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/ai-image-editor/tests/editor/harness.ts b/packages/ai-image-editor/tests/editor/harness.ts index cf6f12b..2297890 100644 --- a/packages/ai-image-editor/tests/editor/harness.ts +++ b/packages/ai-image-editor/tests/editor/harness.ts @@ -16,7 +16,13 @@ import { cleanup } from '../test-renderer'; export { UcAiImageEditor }; export type { UcAiImageEditorType }; -/** Generations finish on their first status poll, so a test doesn't sit through the editor's 1.5s interval. */ +/** + * Generations finish on their first status poll, so a test doesn't sit through the editor's 1.5s interval. + * + * Order matters: a preset or `session.on` answers before the ones registered earlier, and derivativesInstant only + * speeds up the scenarios registered *before* it (see the emulator README's preset table). A test that adds its own + * status scenario, `derivativeFailure` say, applies derivativesInstant again after it to keep instant answers. + */ beforeEach(() => { session.use('derivativesInstant'); }); From d6df8061702f761a0680145176c7a6c9e5618986 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Sat, 10 Oct 2026 00:36:01 +0300 Subject: [PATCH 103/104] test(ai-image-editor): read the provider specs' requests by route, not by position Checklist item 17 (the nit). The client and provider specs read session.requests[0] and [1] and assumed which call landed where, so an extra or reordered request broke unrelated assertions. A new requestsTo('METHOD /path/') helper in the specs' setup returns only the requests to that route, and the specs now read the generate, edit and status requests through it. Counts that waited for "the start, then a poll" now count status polls, and the wire tests first check that exactly one request went to the route. The method checks are gone because the route filter covers them. Whole-session reads stay where the claim is about every request: "sends nothing" and the token-rotation split. Each rewritten assertion was checked by breaking the client: dropping the bearer header on start or status, store, metadata on generate or edit, edit's aspect_ratio, Accept, or job_id; ignoring baseUrl; and sending edit to the generate endpoint. Each break failed the tests that guard it. Making requestsTo return every request failed 11 tests. Co-Authored-By: Claude Opus 5.5 --- .../provider/api/uploadcareApiClient.test.ts | 63 ++++++++++--------- .../api/uploadcareDerivativeApi.test.ts | 33 +++++----- packages/ai-image-editor/tests/specs/setup.ts | 6 ++ 3 files changed, 56 insertions(+), 46 deletions(-) diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts index a0a6dd9..6ecd61b 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareApiClient.test.ts @@ -1,10 +1,13 @@ import { DEMO_IMAGE_UUID, mintAuthToken } from '@uploadcare/api-emulator'; import { describe, expect, it, vi } from 'vitest'; -import { session } from '../../../../tests/specs/setup'; +import { requestsTo, session } from '../../../../tests/specs/setup'; import { AiProviderError } from '../model/types'; import { UploadcareApiClient } from './uploadcareApiClient'; const PUBLIC_KEY = 'demopublickey'; +const GENERATE = 'POST /derivative/image/generate/'; +const EDIT = 'POST /derivative/image/edit/'; +const STATUS = 'GET /derivative/status/'; /** Answers nothing, ever: a request to `route` stays in flight until its caller aborts it. */ const hang = (route: string) => session.on(route, () => new Promise(() => {})); @@ -20,7 +23,7 @@ describe('UploadcareApiClient', () => { await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); - expect(session.requests[0].headers.get('Authorization')).toBeNull(); + expect(requestsTo(GENERATE)[0].headers.get('Authorization')).toBeNull(); }); it('sends a plain token as a bearer header', async () => { @@ -29,7 +32,7 @@ describe('UploadcareApiClient', () => { await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); - expect(session.requests[0].headers.get('Authorization')).toBe(`Bearer ${token}`); + expect(requestsTo(GENERATE)[0].headers.get('Authorization')).toBe(`Bearer ${token}`); }); it('re-resolves a resolver per request, so a job can rotate tokens mid-flight', async () => { @@ -40,8 +43,8 @@ describe('UploadcareApiClient', () => { const { job_id } = await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); await client.getJobStatus(job_id!); - expect(session.requests[0].headers.get('Authorization')).toBe(`Bearer ${first}`); - expect(session.requests[1].headers.get('Authorization')).toBe(`Bearer ${second}`); + expect(requestsTo(GENERATE)[0].headers.get('Authorization')).toBe(`Bearer ${first}`); + expect(requestsTo(STATUS)[0].headers.get('Authorization')).toBe(`Bearer ${second}`); }); }); @@ -52,9 +55,10 @@ describe('UploadcareApiClient', () => { const job = await client.generate({ prompt: 'a hat', aspectRatio: [16, 9], filename: 'generated.png' }); expect(job).toEqual({ type: 'job', job_id: expect.any(String) }); - expect(session.requests[0].url).toBe('https://upload.uploadcare.com/derivative/image/generate/'); - expect(session.requests[0].method).toBe('POST'); - expect(await session.requests[0].json()).toMatchObject({ + expect(requestsTo(GENERATE)).toHaveLength(1); + const [sent] = requestsTo(GENERATE); + expect(sent.url).toBe('https://upload.uploadcare.com/derivative/image/generate/'); + expect(await sent.json()).toMatchObject({ pub_key: PUBLIC_KEY, prompt: 'a hat', aspect_ratio: [16, 9], @@ -68,8 +72,8 @@ describe('UploadcareApiClient', () => { await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png', store: true }); - expect((await session.requests[0].json()).store).toBeUndefined(); - expect((await session.requests[1].json()).store).toBe(true); + expect((await requestsTo(GENERATE)[0].json()).store).toBeUndefined(); + expect((await requestsTo(GENERATE)[1].json()).store).toBe(true); }); it('includes metadata only when provided', async () => { @@ -83,8 +87,8 @@ describe('UploadcareApiClient', () => { metadata: { source: 'ai-image-editor' }, }); - expect((await session.requests[0].json()).metadata).toBeUndefined(); - expect((await session.requests[1].json()).metadata).toEqual({ source: 'ai-image-editor' }); + expect((await requestsTo(GENERATE)[0].json()).metadata).toBeUndefined(); + expect((await requestsTo(GENERATE)[1].json()).metadata).toEqual({ source: 'ai-image-editor' }); }); it('honours baseUrl override', async () => { @@ -93,7 +97,7 @@ describe('UploadcareApiClient', () => { baseUrl: 'https://upload.example.com/', }); await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); - expect(session.requests[0].url).toBe('https://upload.example.com/derivative/image/generate/'); + expect(requestsTo(GENERATE)[0].url).toBe('https://upload.example.com/derivative/image/generate/'); }); it('throws with status text on a non-2xx response that is not the error envelope', async () => { @@ -106,7 +110,7 @@ describe('UploadcareApiClient', () => { }); it('gives up on a hanging request when the caller aborts', async () => { - hang('POST /derivative/image/generate/'); + hang(GENERATE); const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); const controller = new AbortController(); @@ -116,7 +120,7 @@ describe('UploadcareApiClient', () => { filename: 'f.png', signal: controller.signal, }); - await vi.waitFor(() => expect(session.requests).toHaveLength(1)); + await vi.waitFor(() => expect(requestsTo(GENERATE)).toHaveLength(1)); controller.abort(); await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); @@ -125,7 +129,7 @@ describe('UploadcareApiClient', () => { it('sends Accept: application/json', async () => { const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); await client.generate({ prompt: 'x', aspectRatio: [1, 1], filename: 'f.png' }); - expect(session.requests[0].headers.get('Accept')).toBe('application/json'); + expect(requestsTo(GENERATE)[0].headers.get('Accept')).toBe('application/json'); }); it('surfaces a platform error envelope as an AiProviderError with its code', async () => { @@ -146,9 +150,10 @@ describe('UploadcareApiClient', () => { const job = await client.edit({ prompt: 'remove the cat', source: DEMO_IMAGE_UUID, filename: 'edited.png' }); expect(job).toEqual({ type: 'job', job_id: expect.any(String) }); - expect(session.requests[0].url).toBe('https://upload.uploadcare.com/derivative/image/edit/'); - expect(session.requests[0].method).toBe('POST'); - expect(await session.requests[0].json()).toMatchObject({ + expect(requestsTo(EDIT)).toHaveLength(1); + const [sent] = requestsTo(EDIT); + expect(sent.url).toBe('https://upload.uploadcare.com/derivative/image/edit/'); + expect(await sent.json()).toMatchObject({ pub_key: PUBLIC_KEY, prompt: 'remove the cat', source: DEMO_IMAGE_UUID, @@ -162,8 +167,8 @@ describe('UploadcareApiClient', () => { await client.edit({ prompt: 'x', source: DEMO_IMAGE_UUID, filename: 'f.png' }); await client.edit({ prompt: 'x', source: DEMO_IMAGE_UUID, filename: 'f.png', aspectRatio: [16, 9] }); - expect((await session.requests[0].json()).aspect_ratio).toBeUndefined(); - expect((await session.requests[1].json()).aspect_ratio).toEqual([16, 9]); + expect((await requestsTo(EDIT)[0].json()).aspect_ratio).toBeUndefined(); + expect((await requestsTo(EDIT)[1].json()).aspect_ratio).toEqual([16, 9]); }); it('includes metadata only when provided', async () => { @@ -177,8 +182,8 @@ describe('UploadcareApiClient', () => { metadata: { source: 'ai-image-editor' }, }); - expect((await session.requests[0].json()).metadata).toBeUndefined(); - expect((await session.requests[1].json()).metadata).toEqual({ source: 'ai-image-editor' }); + expect((await requestsTo(EDIT)[0].json()).metadata).toBeUndefined(); + expect((await requestsTo(EDIT)[1].json()).metadata).toEqual({ source: 'ai-image-editor' }); }); it('surfaces a source the project does not have as an AiProviderError', async () => { @@ -199,7 +204,7 @@ describe('UploadcareApiClient', () => { }); it('gives up on a hanging request when the caller aborts', async () => { - hang('POST /derivative/image/edit/'); + hang(EDIT); const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); const controller = new AbortController(); @@ -209,7 +214,7 @@ describe('UploadcareApiClient', () => { filename: 'f.png', signal: controller.signal, }); - await vi.waitFor(() => expect(session.requests).toHaveLength(1)); + await vi.waitFor(() => expect(requestsTo(EDIT)).toHaveLength(1)); controller.abort(); await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); @@ -227,10 +232,10 @@ describe('UploadcareApiClient', () => { const status = await client.getJobStatus(jobId); expect(status).toEqual({ type: 'job', status: 'processing' }); - expect(session.requests[1].url).toBe( + expect(requestsTo(STATUS)).toHaveLength(1); + expect(requestsTo(STATUS)[0].url).toBe( `https://upload.uploadcare.com/derivative/status/?pub_key=${PUBLIC_KEY}&job_id=${jobId}`, ); - expect(session.requests[1].method).toBe('GET'); }); it('throws with status text on a non-2xx response that is not the error envelope', async () => { @@ -250,13 +255,13 @@ describe('UploadcareApiClient', () => { }); it('gives up on a hanging request when the caller aborts', async () => { - hang('GET /derivative/status/'); + hang(STATUS); const client = new UploadcareApiClient({ publicKey: PUBLIC_KEY }); const jobId = await startJob(client); const controller = new AbortController(); const pending = client.getJobStatus(jobId, controller.signal); - await vi.waitFor(() => expect(session.requests).toHaveLength(2)); + await vi.waitFor(() => expect(requestsTo(STATUS)).toHaveLength(1)); controller.abort(); await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); diff --git a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts index 1f31a7e..4ba402f 100644 --- a/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts +++ b/packages/ai-image-editor/src/entities/provider/api/uploadcareDerivativeApi.test.ts @@ -2,15 +2,16 @@ import { DEMO_IMAGE_UUID, mintAuthToken } from '@uploadcare/api-emulator'; import { getPrefixedCdnBaseAsync } from '@uploadcare/cname-prefix/async'; import type { UploadcareFile } from '@uploadcare/upload-client'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { session } from '../../../../tests/specs/setup'; +import { requestsTo, session } from '../../../../tests/specs/setup'; import { AiProviderError } from '../model/types'; import { UploadcareDerivativeApi } from './uploadcareDerivativeApi'; const PUBLIC_KEY = 'demopublickey'; const CDN = 'https://cdn.example.com'; const NO_DELAY = { pollIntervalMs: 0 } as const; -const STATUS_PATH = '/derivative/status/'; -const STATUS = `GET ${STATUS_PATH}`; +const GENERATE = 'POST /derivative/image/generate/'; +const EDIT = 'POST /derivative/image/edit/'; +const STATUS = 'GET /derivative/status/'; /** A result's width over its height. */ const ratioOf = (file: UploadcareFile) => @@ -42,7 +43,7 @@ describe('UploadcareDerivativeApi', () => { it('forwards request metadata to the generate endpoint', async () => { const provider = new UploadcareDerivativeApi({ publicKey: PUBLIC_KEY, ...NO_DELAY }); await provider.generate({ prompt: 'x', mode: 'generate', metadata: { source: 'ai-image-editor' } }); - expect((await session.requests[0].json()).metadata).toEqual({ source: 'ai-image-editor' }); + expect((await requestsTo(GENERATE)[0].json()).metadata).toEqual({ source: 'ai-image-editor' }); }); it('forwards request metadata to the edit endpoint', async () => { @@ -53,8 +54,7 @@ describe('UploadcareDerivativeApi', () => { source: DEMO_IMAGE_UUID, metadata: { source: 'ai-image-editor' }, }); - expect(session.requests[0].url).toBe('https://upload.uploadcare.com/derivative/image/edit/'); - expect((await session.requests[0].json()).metadata).toEqual({ source: 'ai-image-editor' }); + expect((await requestsTo(EDIT)[0].json()).metadata).toEqual({ source: 'ai-image-editor' }); }); it.each([ @@ -85,7 +85,7 @@ describe('UploadcareDerivativeApi', () => { expect(result.url).toBe(`${CDN}/${result.uuid}/`); // processing, uploading, unready success, ready success: one poll each, none after. - expect(session.requests.filter((request) => new URL(request.url).pathname === STATUS_PATH)).toHaveLength(4); + expect(requestsTo(STATUS)).toHaveLength(4); }); it('throws when the job ends in an error status', async () => { @@ -120,7 +120,7 @@ describe('UploadcareDerivativeApi', () => { describe('when a status poll fails mid-job', () => { /** The job's status polls, in order. */ - const statusPolls = () => session.requests.filter((request) => new URL(request.url).pathname === STATUS_PATH); + const statusPolls = () => requestsTo(STATUS); /** The job's first poll answers `processing`; the next one answers `failure`. */ const failSecondPoll = (failure: () => Response) => { @@ -227,8 +227,8 @@ describe('UploadcareDerivativeApi', () => { ...NO_DELAY, }); const result = await provider.generate({ prompt: 'x', mode: 'generate' }); - expect(session.requests[0].url).toBe('https://upload.example.com/derivative/image/generate/'); - expect(session.requests[1].url).toMatch( + expect(requestsTo(GENERATE)[0].url).toBe('https://upload.example.com/derivative/image/generate/'); + expect(requestsTo(STATUS)[0].url).toMatch( new RegExp(`^https://upload\\.example\\.com/derivative/status/\\?pub_key=${PUBLIC_KEY}&job_id=`), ); expect(result.url).toBe(`${CDN}/${result.uuid}/`); @@ -274,7 +274,7 @@ describe('UploadcareDerivativeApi', () => { const controller = new AbortController(); const pending = provider.generate({ prompt: 'x', mode: 'generate', signal: controller.signal }); - await vi.waitFor(() => expect(session.requests).toHaveLength(1)); + await vi.waitFor(() => expect(requestsTo(GENERATE)).toHaveLength(1)); controller.abort(); await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); @@ -286,8 +286,7 @@ describe('UploadcareDerivativeApi', () => { const controller = new AbortController(); const pending = provider.generate({ prompt: 'x', mode: 'generate', signal: controller.signal }); - // The start, then the first status poll. - await vi.waitFor(() => expect(session.requests).toHaveLength(2)); + await vi.waitFor(() => expect(requestsTo(STATUS)).toHaveLength(1)); controller.abort(); await expect(pending).rejects.toThrow(/cancel/i); @@ -318,7 +317,7 @@ describe('UploadcareDerivativeApi', () => { aspectRatio: [16, 9], }); - expect(session.requests[0].url).toBe('https://upload.uploadcare.com/derivative/image/edit/'); + expect(requestsTo(EDIT)).toHaveLength(1); expect(result.uuid).not.toBe(DEMO_IMAGE_UUID); expect(ratioOf(result.file)).toBe(16 / 9); expect(result.url).toBe(`${CDN}/${result.uuid}/`); @@ -392,7 +391,7 @@ describe('UploadcareDerivativeApi', () => { }); await provider.generate({ prompt: 'x', mode: 'generate' }); - expect(session.requests[0].headers.get('Authorization')).toBe(`Bearer ${token}`); + expect(requestsTo(GENERATE)[0].headers.get('Authorization')).toBe(`Bearer ${token}`); await expect(provider.getFileInfo(DEMO_IMAGE_UUID)).resolves.toMatchObject({ uuid: DEMO_IMAGE_UUID }); }); @@ -413,7 +412,7 @@ describe('UploadcareDerivativeApi', () => { name: 'AiProviderError', errorCode: 'AccessTokenInvalidError', }); - expect(session.requests).toHaveLength(1); + expect(requestsTo(STATUS)).toHaveLength(0); }); it('surfaces a token that expires mid-job as AccessTokenExpiredError on the next poll', async () => { @@ -427,7 +426,7 @@ describe('UploadcareDerivativeApi', () => { errorCode: 'AccessTokenExpiredError', }); // The start went through on the fresh token; the first poll, on the expired one, ended the job. - expect(session.requests).toHaveLength(2); + expect(requestsTo(STATUS)).toHaveLength(1); }); it("rejects with the token function's own error and sends nothing when it throws", async () => { diff --git a/packages/ai-image-editor/tests/specs/setup.ts b/packages/ai-image-editor/tests/specs/setup.ts index b0b7612..99a7bc7 100644 --- a/packages/ai-image-editor/tests/specs/setup.ts +++ b/packages/ai-image-editor/tests/specs/setup.ts @@ -13,6 +13,12 @@ const emulator = setupEmulator({ cdnHosts: ['upload.example.com'] }); /** The current test's emulator session, for a spec that registers a scenario or reads `requests`. */ export let session: EmulatorSession; +/** The current test's requests to `route` (`'METHOD /path/'`, on any host), oldest first. */ +export const requestsTo = (route: string) => { + const [method, path] = route.split(' '); + return session.requests.filter((request) => request.method === method && new URL(request.url).pathname === path); +}; + /** Every test starts against an empty session. */ beforeEach(async () => { session = await emulator.reset(); From ede61ed8388b7764f10260f5164be5879acf79f9 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Sat, 10 Oct 2026 00:46:42 +0300 Subject: [PATCH 104/104] test(ai-image-editor): keep the plugin e2e editors off swiftshader WebGL Checklist item 20. Under the full suite, "replaces the source entry in place ... sourced to ai-image-editor" timed out at 15s. Alone it passed, but it still took 7.3s. Root cause: the plugin creates its own , and its canvas picks the dot grid's backend on first update, before a test can reach the element. The editor harness avoids this for the editors it mounts with shimmerConfig = { useWebgl: false }, but the plugin specs had nothing like it. Headless Chromium's WebGL runs in software (swiftshader), and the shimmer's per-frame GL work while generating starved the main thread. The emulator answers on that same thread, so the edit, status and CDN requests took 0.3 to 1.3s each instead of a few ms. Under suite load this pushed the test past its timeout. A beforeEach in the plugin spec now makes getContext('webgl2') return null. The grid then falls back to its 2D path, as it does in a browser without WebGL. restoreMocks undoes the spy after each test. No assertion, timeout or retry changed. Measured in a single run of the file: the edit went from about 5s to about 0.3s, and the test from 7.3s to 2.8s. The workspace suite passes, 281/281 with --maxWorkers=2 --no-file-parallelism. Co-Authored-By: Claude Opus 5.5 --- .../ai-image-editor/tests/plugin.e2e.test.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/ai-image-editor/tests/plugin.e2e.test.ts b/packages/ai-image-editor/tests/plugin.e2e.test.ts index aaba1ac..79f6689 100644 --- a/packages/ai-image-editor/tests/plugin.e2e.test.ts +++ b/packages/ai-image-editor/tests/plugin.e2e.test.ts @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { page, userEvent } from 'vitest/browser'; import type { UcAiImageEditor } from '../src/index'; import { session } from './emulator'; @@ -65,6 +65,23 @@ beforeAll(async () => { await import('../src/index'); }); +/* + * The editor harness forces the dot grid's 2D path with `shimmerConfig = { useWebgl: false }`, but the plugin creates + * its own editor, and the canvas picks its backend before a test can reach it. Headless Chromium's WebGL is software + * (swiftshader): the generating shimmer's per-frame GL work starves the main thread, which is also where the emulator + * answers, so a single edit took ~5s instead of ~0.3s and, under a loaded suite, ran the test past its timeout. With no + * webgl2 context on offer the grid falls back to 2D, as it does in a browser without WebGL. Restored after each test. + */ +beforeEach(() => { + const getContext = HTMLCanvasElement.prototype.getContext; + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(function ( + this: HTMLCanvasElement, + ...args: Parameters + ) { + return args[0] === 'webgl2' ? null : getContext.apply(this, args); + } as HTMLCanvasElement['getContext']); +}); + describe('AiImageEditorPlugin', () => { it('registers "Generate image" as an upload source', async () => { const { AiImageEditorPlugin } = await import('../src/plugin');