From b69cfc5a5ec4d3e947885c374a1e05b38c2409d5 Mon Sep 17 00:00:00 2001 From: provos Date: Wed, 12 Aug 2026 17:33:56 -0700 Subject: [PATCH 1/3] test: add deterministic browser characterization suite --- .github/workflows/python-app.yml | 19 + .gitignore | 5 +- e2e/README.md | 67 + e2e/fixtures.ts | 93 ++ e2e/helpers/app.ts | 297 +++++ e2e/parallax-maker.spec.ts | 249 ++++ e2e/playwright.config.ts | 51 + package-lock.json | 1599 ++++++++++++++++++++++++ package.json | 7 +- parallax_maker/e2e_server.py | 219 ++++ parallax_maker/e2e_support/__init__.py | 15 + parallax_maker/e2e_support/fakes.py | 312 +++++ parallax_maker/e2e_support/fixtures.py | 73 ++ parallax_maker/test_e2e_support.py | 106 ++ pyproject.toml | 2 +- 15 files changed, 3109 insertions(+), 5 deletions(-) create mode 100644 e2e/README.md create mode 100644 e2e/fixtures.ts create mode 100644 e2e/helpers/app.ts create mode 100644 e2e/parallax-maker.spec.ts create mode 100644 e2e/playwright.config.ts create mode 100644 package-lock.json create mode 100644 parallax_maker/e2e_server.py create mode 100644 parallax_maker/e2e_support/__init__.py create mode 100644 parallax_maker/e2e_support/fakes.py create mode 100644 parallax_maker/e2e_support/fixtures.py create mode 100644 parallax_maker/test_e2e_support.py diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 64c5660..8357b33 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -16,6 +16,7 @@ jobs: build: runs-on: ubuntu-latest + timeout-minutes: 30 steps: - uses: actions/checkout@v4 @@ -36,3 +37,21 @@ jobs: - name: Test with pytest run: | pytest + - name: Set up Node.js 24 + uses: actions/setup-node@v4 + with: + node-version: "24" + cache: npm + - name: Install browser-test dependencies + run: | + npm ci + npx playwright install --with-deps chromium + - name: Test browser workflows + run: npm run test:e2e + - name: Upload browser-test diagnostics + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-diagnostics + path: e2e/test-results/ + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index c950c56..12f7dfb 100644 --- a/.gitignore +++ b/.gitignore @@ -156,7 +156,8 @@ tmp-images/ # Node modules node_modules/ -package-lock.json +test-results/ +playwright-report/ # Parallax Maker -appstate-*/ \ No newline at end of file +appstate-*/ diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..4b42de1 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,67 @@ +# Browser characterization tests + +These Playwright tests exercise the real Dash UI, callbacks, persistence, canvas, +image composition, and exporters. The test-only server replaces only expensive +model inference with deterministic images and masks. + +Run the suite with: + +```sh +npm ci +npx playwright install chromium +npm run test:e2e +``` + +Run the command from the repository root; the server process deliberately uses +the caller's working directory so Python package and virtualenv paths resolve. + +The default server command is: + +```sh +python -m parallax_maker.e2e_server --host 127.0.0.1 --port 8050 +``` + +The harness can target another interpreter, command, or already-running server: + +```sh +E2E_SERVER_COMMAND='poetry run python -m parallax_maker.e2e_server --host 127.0.0.1 --port 8050' npm run test:e2e +E2E_BASE_URL='http://127.0.0.1:9000' E2E_SKIP_SERVER=1 npm run test:e2e +``` + +The suite deliberately uses one worker because the current application has a +process-global `AppState.cache` and filesystem-backed state. Every restore gets +a fresh deterministic state directory. The harness uses a fixed Chromium +viewport and device scale factor, records a trace/video/screenshot on failure, +and fails on uncaught page exceptions, unexpected console errors, failed +same-origin requests, or same-origin HTTP error responses. + +The scenarios cover: + +- upload, deterministic depth, thresholds, and three generated slices; +- point segmentation, including positive and negative points; +- a real canvas stroke, mask persistence, three exact checkerboard candidates, + candidate selection, apply, and undo; +- restoration of images, slices, prompts, camera controls, model, and theme; +- downloaded glTF structure and the current server-side animation behavior. + +The animation test intentionally asserts that no browser download occurs. The +current callback renders numbered PNG files server-side and logs completion but +does not populate `download-animation`. Change this assertion when that product +behavior is fixed. + +## Selector contract for a future Svelte UI + +Most selectors use visible labels, button text, or existing stable IDs. A Svelte +replacement should preserve accessible names for the user-facing contract. The +framework-specific selectors are centralized in `helpers/app.ts`; treat that file +as the current Dash UI adapter. A Svelte adapter should also implement the same +test-only fixture/state API so the behavioral scenario files stay unchanged. The +few image-workflow elements that would benefit from explicit test IDs are: + +- `input-image`, `paint-canvas`, and `preview-canvas`; +- `depth-map-image`; +- `slice-image-{index}` and `slice-overlay-{index}`; +- `inpainting-candidate-{index}`; +- `slice-undo-{index}` and `slice-redo-{index}`. + +Do not add test IDs to every control; semantic roles and labels are preferred. diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts new file mode 100644 index 0000000..c5550e0 --- /dev/null +++ b/e2e/fixtures.ts @@ -0,0 +1,93 @@ +import { test as base, expect, type ConsoleMessage, type Page } from '@playwright/test'; + +type BrowserDiagnostics = { + consoleErrors: string[]; + pageErrors: string[]; + failedRequests: string[]; + errorResponses: string[]; +}; + +const ignoredExternalFailures = [ + /kit\.fontawesome\.com/, + /cdn\.jsdelivr\.net/, + /unpkg\.com/, + // Chromium omits the URL from this console message. A same-origin DNS + // failure is still caught by requestfailed below; the E2E app uses an IP URL. + /Failed to load resource: net::ERR_NAME_NOT_RESOLVED/, +]; + +function isIgnoredExternalFailure(text: string): boolean { + return ignoredExternalFailures.some((pattern) => pattern.test(text)); +} + +function consoleText(message: ConsoleMessage): string { + return `${message.type()}: ${message.text()}`; +} + +export const test = base.extend<{ diagnostics: BrowserDiagnostics }>({ + diagnostics: [async ({ page, baseURL }, use, testInfo) => { + const diagnostics: BrowserDiagnostics = { + consoleErrors: [], + pageErrors: [], + failedRequests: [], + errorResponses: [], + }; + const appOrigin = new URL(baseURL ?? 'http://127.0.0.1:8050').origin; + + page.on('console', (message) => { + if (message.type() !== 'error') return; + const text = consoleText(message); + if (!isIgnoredExternalFailure(text)) diagnostics.consoleErrors.push(text); + }); + page.on('pageerror', (error) => diagnostics.pageErrors.push(error.stack ?? error.message)); + page.on('response', (response) => { + const url = response.url(); + if (new URL(url).origin === appOrigin && response.status() >= 400) { + diagnostics.errorResponses.push(`${response.status()} ${response.request().method()} ${url}`); + } + }); + page.on('requestfailed', (request) => { + const url = request.url(); + const errorText = request.failure()?.errorText ?? 'unknown failure'; + const supersededDashCallback = + new URL(url).pathname === '/_dash-update-component' && errorText.includes('ERR_ABORTED'); + if (new URL(url).origin === appOrigin && !supersededDashCallback) { + diagnostics.failedRequests.push( + `${request.method()} ${url}: ${errorText}`, + ); + } + }); + + await use(diagnostics); + + if ( + diagnostics.consoleErrors.length || + diagnostics.pageErrors.length || + diagnostics.failedRequests.length || + diagnostics.errorResponses.length + ) { + await testInfo.attach('browser-diagnostics', { + body: JSON.stringify(diagnostics, null, 2), + contentType: 'application/json', + }); + } + expect.soft(diagnostics.pageErrors, 'uncaught browser exceptions').toEqual([]); + expect.soft(diagnostics.consoleErrors, 'unexpected browser console errors').toEqual([]); + expect.soft(diagnostics.failedRequests, 'failed same-origin requests').toEqual([]); + expect.soft(diagnostics.errorResponses, 'same-origin HTTP error responses').toEqual([]); + }, { auto: true }], +}); + +export { expect }; + +export async function disableAnimations(page: Page): Promise { + await page.addStyleTag({ + content: ` + *, *::before, *::after { + animation-duration: 0s !important; + transition-duration: 0s !important; + caret-color: transparent !important; + } + `, + }); +} diff --git a/e2e/helpers/app.ts b/e2e/helpers/app.ts new file mode 100644 index 0000000..862767b --- /dev/null +++ b/e2e/helpers/app.ts @@ -0,0 +1,297 @@ +import { expect, type Locator, type Page } from '@playwright/test'; + +export type RGB = readonly [number, number, number]; +export type RGBA = readonly [number, number, number, number]; + +export type E2EState = { + selected_slice: number | null; + selected_inpainting: number | null; + thresholds: number[]; + slice_count: number; + mesh_displacement: number; + slice_mask: E2EMaskStats; + selected_mask_file: E2EMaskStats; + [key: string]: unknown; +}; + +export type E2EMaskStats = { + present: boolean; + nonzero: number; + bounds: [number, number, number, number] | null; + samples: Record; + inside?: [number, number] | null; + outside?: [number, number] | null; + max?: number; +}; + +export type ImageMetadata = { width: number; height: number; hash: string }; + +export const sliceImages = (page: Page): Locator => + page.locator('img[id*=\'"type":"slice"\']'); + +export const inpaintingImages = (page: Page): Locator => + page.locator('img[id*=\'"type":"inpainting-image"\']'); + +export async function gotoApp(page: Page): Promise { + await page.goto('/', { waitUntil: 'domcontentloaded' }); + await expect(page.getByRole('heading', { name: 'Parallax Maker' })).toBeVisible(); +} + +export async function uploadServerFixture( + page: Page, + inputSelector: string, + endpoint: string, + name: string, + mimeType: string, +): Promise { + const response = await page.request.get(endpoint); + expect(response.ok(), `GET ${endpoint}`).toBeTruthy(); + await page.locator(inputSelector).setInputFiles({ + name, + mimeType, + buffer: await response.body(), + }); +} + +export async function uploadInputImage(page: Page): Promise { + await uploadServerFixture( + page, + '#upload-image input[type=file]', + '/__e2e__/fixture/input.png', + 'e2e-input.png', + 'image/png', + ); + await waitForImage(page.locator('#image')); + await waitForImage(page.locator('#depthmap-image')); +} + +export async function restoreFixtureState(page: Page): Promise { + const response = await page.request.get('/__e2e__/fixture/state.json'); + expect(response.ok(), 'GET /__e2e__/fixture/state.json').toBeTruthy(); + const buffer = await response.body(); + const fixture = JSON.parse(buffer.toString('utf8')) as { filename?: unknown }; + expect(typeof fixture.filename).toBe('string'); + await page.locator('#upload-state input[type=file]').setInputFiles({ + name: 'appstate.json', + mimeType: 'application/json', + buffer, + }); + await waitForImage(page.locator('#image')); + await waitForImage(page.locator('#depthmap-image')); + await expect(sliceImages(page)).toHaveCount(3); + return fixture.filename as string; +} + +export async function readE2EState(page: Page, filename: string): Promise { + const response = await page.request.get( + `/__e2e__/state?filename=${encodeURIComponent(filename)}`, + ); + expect(response.ok(), 'GET /__e2e__/state').toBeTruthy(); + return response.json(); +} + +export async function waitForImage(image: Locator): Promise { + await expect(image).toBeVisible(); + await expect + .poll(() => + image.evaluate((element: HTMLImageElement) => ({ + complete: element.complete, + width: element.naturalWidth, + height: element.naturalHeight, + })), + ) + .toMatchObject({ complete: true }); + await expect.poll(() => image.evaluate((element: HTMLImageElement) => element.naturalWidth)).toBeGreaterThan(0); +} + +export async function imageDimensions(image: Locator): Promise<{ width: number; height: number }> { + await waitForImage(image); + return image.evaluate((element: HTMLImageElement) => ({ + width: element.naturalWidth, + height: element.naturalHeight, + })); +} + +export async function imagePixel(image: Locator, x: number, y: number): Promise { + await waitForImage(image); + return image.evaluate( + (element: HTMLImageElement, point): RGBA => { + const canvas = document.createElement('canvas'); + canvas.width = element.naturalWidth; + canvas.height = element.naturalHeight; + const context = canvas.getContext('2d', { willReadFrequently: true }); + if (!context) throw new Error('Unable to create 2D context for pixel assertion'); + context.drawImage(element, 0, 0); + return Array.from(context.getImageData(point.x, point.y, 1, 1).data) as unknown as RGBA; + }, + { x, y }, + ); +} + +export async function imageContainsRGB(image: Locator, expected: RGB): Promise { + await waitForImage(image); + return image.evaluate( + (element: HTMLImageElement, color): boolean => { + const canvas = document.createElement('canvas'); + canvas.width = element.naturalWidth; + canvas.height = element.naturalHeight; + const context = canvas.getContext('2d', { willReadFrequently: true }); + if (!context) throw new Error('Unable to create 2D context for pixel assertion'); + context.drawImage(element, 0, 0); + const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data; + for (let index = 0; index < pixels.length; index += 4) { + if ( + pixels[index] === color[0] && + pixels[index + 1] === color[1] && + pixels[index + 2] === color[2] + ) { + return true; + } + } + return false; + }, + expected, + ); +} + +export async function imageSignature(image: Locator, points: readonly [number, number][]): Promise { + return Promise.all(points.map(([x, y]) => imagePixel(image, x, y))); +} + +export async function imageHash(image: Locator): Promise { + await waitForImage(image); + return image.evaluate((element: HTMLImageElement): string => { + const canvas = document.createElement('canvas'); + canvas.width = element.naturalWidth; + canvas.height = element.naturalHeight; + const context = canvas.getContext('2d', { willReadFrequently: true }); + if (!context) throw new Error('Unable to create 2D context for image hash'); + context.drawImage(element, 0, 0); + const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data; + let hash = 0x811c9dc5; + for (const value of pixels) { + hash ^= value; + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(16).padStart(8, '0'); + }); +} + +export async function imageBufferMetadata(page: Page, buffer: Buffer): Promise { + return page.evaluate(async (base64): Promise => { + const image = new Image(); + image.src = `data:image/png;base64,${base64}`; + await image.decode(); + const canvas = document.createElement('canvas'); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const context = canvas.getContext('2d', { willReadFrequently: true }); + if (!context) throw new Error('Unable to create 2D context for artifact image'); + context.drawImage(image, 0, 0); + let hash = 0x811c9dc5; + for (const value of context.getImageData(0, 0, canvas.width, canvas.height).data) { + hash ^= value; + hash = Math.imul(hash, 0x01000193); + } + return { + width: image.naturalWidth, + height: image.naturalHeight, + hash: (hash >>> 0).toString(16).padStart(8, '0'), + }; + }, buffer.toString('base64')); +} + +export async function sourceImagePixel(page: Page, src: string, x: number, y: number): Promise { + return page.evaluate( + async ({ src, x, y }): Promise => { + const image = new Image(); + image.src = src; + await image.decode(); + const canvas = document.createElement('canvas'); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const context = canvas.getContext('2d', { willReadFrequently: true }); + if (!context) throw new Error('Unable to create 2D context for source image'); + context.drawImage(image, 0, 0); + return Array.from(context.getImageData(x, y, 1, 1).data) as unknown as RGBA; + }, + { src, x, y }, + ); +} + +export async function clickImagePixel( + image: Locator, + x: number, + y: number, + modifiers: ('Alt' | 'Control' | 'Meta' | 'Shift')[] = [], +): Promise { + const position = await image.evaluate( + (element: HTMLImageElement, point) => { + const rect = element.getBoundingClientRect(); + const scale = Math.min(rect.width / element.naturalWidth, rect.height / element.naturalHeight); + return { x: point.x * scale, y: point.y * scale }; + }, + { x, y }, + ); + await image.click({ position, modifiers }); +} + +export async function clickMainTab(page: Page, name: string): Promise { + const label = page.locator('label').filter({ hasText: new RegExp(`^${name}$`) }); + await expect(label).toHaveCount(1); + await label.click(); + await expect(label).toHaveClass(/underline/); +} + +export async function selectDashOption(page: Page, id: string, name: string): Promise { + const dropdown = page.locator(`#${id}`); + await dropdown.click(); + const option = page.getByText(name, { exact: true }).last(); + await expect(option).toBeVisible(); + await option.click(); +} + +export async function selectSlice(page: Page, filename: string, index: number): Promise { + const image = sliceImages(page).nth(index); + await expect(image).toBeVisible(); + const bounds = await image.boundingBox(); + if (!bounds) throw new Error(`Slice ${index} has no clickable bounds`); + // The depth-number overlay covers the center and the label covers the bottom. + // Click the unobstructed upper-left area with normal browser hit-testing. + await image.click({ position: { x: bounds.width * 0.1, y: bounds.height * 0.15 } }); + const overlay = page.locator('div[id*=\'"type":"slicer-overlay"\']').nth(index); + await expect(overlay).toHaveClass(/overlay/); + await expect.poll(async () => (await readE2EState(page, filename)).selected_slice).toBe(index); + return image; +} + +export async function drawCanvasStroke(page: Page): Promise { + const canvas = page.locator('#canvas'); + await expect(canvas).toBeVisible(); + const box = await canvas.boundingBox(); + if (!box) throw new Error('Canvas has no bounding box'); + + await page.mouse.move(box.x + box.width * 0.4, box.y + box.height * 0.45); + await page.mouse.down(); + await page.mouse.move(box.x + box.width * 0.6, box.y + box.height * 0.55, { steps: 10 }); + await page.mouse.up(); + // The client intentionally serializes the mask on mouseout, not mouseup. + await page.mouse.move(box.x + box.width + 10, box.y + box.height / 2); + await expect(page.locator('#log')).toContainText(/Saved mask for slice/); +} + +export async function setDashSlider(page: Page, id: string, value: number): Promise { + const slider = page.locator(`#${id} [role=slider]`); + await slider.focus(); + const min = Number(await slider.getAttribute('aria-valuemin')); + const max = Number(await slider.getAttribute('aria-valuemax')); + const step = Number(await slider.getAttribute('aria-valuestep')) || 1; + if (value < min || value > max || (value - min) % step !== 0) { + throw new Error(`Value ${value} is not valid for ${id} (${min}..${max}, step ${step})`); + } + await slider.press('Home'); + for (let current = min; current < value; current += step) { + await slider.press('ArrowRight'); + } + await expect(slider).toHaveAttribute('aria-valuenow', String(value)); +} diff --git a/e2e/parallax-maker.spec.ts b/e2e/parallax-maker.spec.ts new file mode 100644 index 0000000..5baa99c --- /dev/null +++ b/e2e/parallax-maker.spec.ts @@ -0,0 +1,249 @@ +import { readFile } from 'node:fs/promises'; +import { test, expect, disableAnimations } from './fixtures'; +import { + clickMainTab, + drawCanvasStroke, + gotoApp, + imageContainsRGB, + imageDimensions, + imageHash, + imageBufferMetadata, + imagePixel, + imageSignature, + sourceImagePixel, + inpaintingImages, + restoreFixtureState, + readE2EState, + selectDashOption, + selectSlice, + setDashSlider, + sliceImages, + clickImagePixel, + uploadInputImage, +} from './helpers/app'; + +test.beforeEach(async ({ page }) => { + await gotoApp(page); + await disableAnimations(page); +}); + +test('upload generates deterministic depth and three real slices', async ({ page }) => { + await uploadInputImage(page); + + const input = page.locator('#image'); + const depth = page.locator('#depthmap-image'); + expect(await imageDimensions(input)).toEqual({ width: 320, height: 240 }); + expect(await imageDimensions(depth)).toEqual({ width: 320, height: 240 }); + + const left = await imagePixel(depth, 16, 16); + const right = await imagePixel(depth, 304, 223); + expect(left).toEqual([2, 2, 2, 255]); + expect(right).toEqual([252, 252, 252, 255]); + + await expect(page.locator('#thresholds-container [role=slider]')).toHaveCount(2); + await clickMainTab(page, 'Segmentation'); + await page.locator('#generate-slice-button').click(); + await expect(sliceImages(page)).toHaveCount(3); + const slices = await sliceImages(page).all(); + for (const image of slices) { + expect(await imageDimensions(image)).toEqual({ width: 320, height: 240 }); + } + const samplePoints: [number, number][] = [[16, 16], [160, 120], [304, 223]]; + expect(await imageSignature(slices[0], samplePoints)).toEqual([ + [30, 21, 70, 255], [200, 200, 200, 255], [75, 75, 75, 255], + ]); + expect(await imageSignature(slices[1], samplePoints)).toEqual([ + [200, 200, 200, 255], [120, 95, 70, 255], [75, 75, 75, 255], + ]); + expect(await imageSignature(slices[2], samplePoints)).toEqual([ + [200, 200, 200, 255], [200, 200, 200, 255], [210, 168, 70, 255], + ]); +}); + +test('point segmentation honors positive and negative points through the UI', async ({ page }) => { + const filename = await restoreFixtureState(page); + await selectDashOption(page, 'mode-selector', 'Instance Segmentation'); + await clickMainTab(page, 'Segmentation'); + + const image = page.locator('#image'); + const before = await imageSignature(image, [ + [128, 96], + [8, 8], + ]); + await clickImagePixel(image, 128, 96); + await expect(page.locator('#log')).toContainText(/Segment Anything/); + const positiveMask = (await readE2EState(page, filename)).slice_mask; + expect(positiveMask.samples).toMatchObject({ '8,8': 0, '128,96': 255 }); + expect(positiveMask.nonzero).toBeGreaterThan(4_000); + const afterPositive = await imageSignature(image, [ + [128, 96], + [8, 8], + ]); + expect(afterPositive[0]).not.toEqual(before[0]); + expect(afterPositive[1]).not.toEqual(before[1]); + expect(afterPositive[0]).not.toEqual(afterPositive[1]); + + await page.locator('#multi-point').click(); + await clickImagePixel(image, 90, 96); + await clickImagePixel(image, 128, 96, ['Control']); + await page.locator('#multi-commit').click(); + await expect(page.locator('#log')).toContainText(/Committed points/); + const afterNegative = await imagePixel(image, 128, 96); + expect(afterNegative).not.toEqual(afterPositive[0]); + const negativeMask = (await readE2EState(page, filename)).slice_mask; + expect(negativeMask.samples).toMatchObject({ '8,8': 0, '90,96': 255, '128,96': 0 }); + expect(negativeMask.nonzero).toBeGreaterThan(0); +}); + +test('painted mask drives three checkerboard candidates, apply, and undo', async ({ page }) => { + const filename = await restoreFixtureState(page); + await clickMainTab(page, 'Segmentation'); + const selectedSlice = await selectSlice(page, filename, 1); + const originalHash = await imageHash(selectedSlice); + const rawSliceResponse = await page.request.get( + `/__e2e__/artifact/${encodeURIComponent(filename)}/image_slice_1.png`, + ); + expect(rawSliceResponse.ok(), 'download raw slice 1').toBeTruthy(); + const rawSliceSrc = `data:image/png;base64,${(await rawSliceResponse.body()).toString('base64')}`; + await clickMainTab(page, 'Inpainting'); + await expect(page.locator('#generate-inpainting-button')).toBeEnabled(); + await drawCanvasStroke(page); + const persistedMask = (await readE2EState(page, filename)).selected_mask_file; + expect(persistedMask.present).toBe(true); + expect(persistedMask.nonzero).toBeGreaterThan(0); + expect(persistedMask.bounds).not.toBeNull(); + expect(persistedMask.max).toBe(255); + expect(persistedMask.inside).not.toBeNull(); + expect(persistedMask.outside).not.toBeNull(); + + await page.locator('#positive-prompt').fill('deterministic browser test'); + await page.locator('#generate-inpainting-button').click(); + await expect(inpaintingImages(page)).toHaveCount(3); + await expect.poll(() => imageContainsRGB(inpaintingImages(page).nth(0), [0, 255, 255])).toBe(true); + await expect.poll(() => imageContainsRGB(inpaintingImages(page).nth(0), [255, 0, 255])).toBe(true); + await expect.poll(() => imageContainsRGB(inpaintingImages(page).nth(1), [255, 128, 0])).toBe(true); + await expect.poll(() => imageContainsRGB(inpaintingImages(page).nth(1), [0, 64, 255])).toBe(true); + const inside = persistedMask.inside!; + const outside = persistedMask.outside!; + const candidateInside = await imagePixel(inpaintingImages(page).nth(1), ...inside); + const candidateOutside = await imagePixel(inpaintingImages(page).nth(1), ...outside); + const originalOutside = await sourceImagePixel(page, rawSliceSrc, ...outside); + const candidateRGB = candidateInside.slice(0, 3); + const distanceFromPalette = ([red, green, blue]: number[]) => + Math.max( + Math.abs(candidateRGB[0] - red), + Math.abs(candidateRGB[1] - green), + Math.abs(candidateRGB[2] - blue), + ); + // The real mask blur/composition intentionally leaves a tiny contribution + // from the source even at the selected maximum-mask pixel. + expect( + Math.min(distanceFromPalette([255, 128, 0]), distanceFromPalette([0, 64, 255])), + ).toBeLessThanOrEqual(5); + expect(candidateOutside).toEqual(originalOutside); + + await inpaintingImages(page).nth(1).click(); + await expect(inpaintingImages(page).nth(1)).toHaveClass(/color-is-selected-light/); + await expect(page.locator('#apply-inpainting-button')).toBeEnabled(); + await page.locator('#apply-inpainting-button').click(); + await expect(page.locator('#log')).toContainText(/Inpainting applied to slice 1/); + + await clickMainTab(page, 'Segmentation'); + const undo = page.locator('[title="Undo last change"]').nth(1); + await expect(undo).toBeEnabled(); + expect(await imageHash(sliceImages(page).nth(1))).not.toBe(originalHash); + await undo.click(); + await expect.poll(() => imageHash(sliceImages(page).nth(1))).toBe(originalHash); +}); + +test('saved state restores images, controls, prompts, camera, and theme', async ({ page }) => { + const filename = await restoreFixtureState(page); + + await expect(page.locator('#app-container')).toHaveClass(/\bdark\b/); + expect(await imageHash(page.locator('#image'))).toBe('528b56cf'); + expect(await imageHash(page.locator('#depthmap-image'))).toBe('c6c301c5'); + expect(await imagePixel(page.locator('#image'), 30, 30)).toEqual([240, 50, 45, 255]); + expect(await imagePixel(page.locator('#image'), 248, 96)).toEqual([35, 210, 90, 255]); + expect(await imagePixel(page.locator('#depthmap-image'), 0, 0)).toEqual([0, 0, 0, 255]); + expect(await imagePixel(page.locator('#depthmap-image'), 319, 239)).toEqual([255, 255, 255, 255]); + await expect(page.locator('#num-slices-slider [role=slider]')).toHaveAttribute('aria-valuenow', '3'); + await expect(page.locator('#thresholds-container [role=slider]')).toHaveCount(2); + await expect(page.locator('#camera-distance-slider [role=slider]')).toHaveAttribute('aria-valuenow', '125'); + await expect(page.locator('#max-distance-slider [role=slider]')).toHaveAttribute('aria-valuenow', '140'); + await expect(page.locator('#focal-length-slider [role=slider]')).toHaveAttribute('aria-valuenow', '475'); + await expect(page.locator('#displacement-slider [role=slider]')).toHaveAttribute('aria-valuenow', '15'); + expect((await readE2EState(page, filename)).thresholds).toEqual([0, 85, 170, 255]); + + await clickMainTab(page, 'Segmentation'); + await selectSlice(page, filename, 1); + await clickMainTab(page, 'Inpainting'); + await expect(page.locator('#positive-prompt')).toHaveValue('fixture foreground 1'); + await expect(page.locator('#negative-prompt')).toHaveValue('fixture exclusion 1'); + + await clickMainTab(page, 'Configuration'); + await expect(page.locator('#depth-model-dropdown')).toContainText('DINOv2'); + await expect(page.locator('#inpainting-model-dropdown')).toContainText('SD XL 1.0'); +}); + +test('glTF downloads as a valid scene and animation renders four frames without a download', async ({ page }) => { + const filename = await restoreFixtureState(page); + await clickMainTab(page, 'Export'); + await setDashSlider(page, 'displacement-slider', 0); + + const downloadPromise = page.waitForEvent('download'); + await page.locator('#gltf-export').click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe('scene.gltf'); + const path = await download.path(); + expect(path).not.toBeNull(); + const scene = JSON.parse(await readFile(path!, 'utf8')) as { + asset?: { version?: string }; + scenes?: unknown[]; + nodes?: unknown[]; + meshes?: unknown[]; + images?: Array<{ uri?: string }>; + }; + expect(scene.asset?.version).toBe('2.0'); + expect(scene.scenes?.length).toBeGreaterThan(0); + expect(scene.nodes?.length).toBe(4); + expect(scene.meshes?.length).toBe(3); + expect(scene.images).toHaveLength(3); + expect(scene.images?.every((image) => image.uri?.startsWith('data:image/png;base64,'))).toBe(true); + + await setDashSlider(page, 'number-of-frames-slider', 4); + const downloads: string[] = []; + page.on('download', (event) => downloads.push(event.suggestedFilename())); + await page.locator('#animation-export').click(); + await expect(page.locator('#log')).toContainText('Exported 4 frames to animation'); + // This locks in the current contract: animation writes server-side frames but the + // otherwise-present dcc.Download is not populated by the callback. + expect(downloads).toEqual([]); + + const artifacts = await page.request.get( + `/__e2e__/artifacts?filename=${encodeURIComponent(filename)}`, + ); + expect(artifacts.ok()).toBeTruthy(); + const body = (await artifacts.json()) as { files: Array<{ path: string; size: number }> }; + const frames = body.files + .filter((file) => /^rendered_image_\d{3}\.png$/.test(file.path)) + .sort((left, right) => left.path.localeCompare(right.path)); + expect(frames).toEqual([ + { path: 'rendered_image_000.png', size: expect.any(Number) }, + { path: 'rendered_image_001.png', size: expect.any(Number) }, + { path: 'rendered_image_002.png', size: expect.any(Number) }, + { path: 'rendered_image_003.png', size: expect.any(Number) }, + ]); + expect(frames.every((frame) => frame.size > 100)).toBe(true); + const frameMetadata = await Promise.all( + frames.map(async (frame) => { + const response = await page.request.get( + `/__e2e__/artifact/${encodeURIComponent(filename)}/${frame.path}`, + ); + expect(response.ok(), `download ${frame.path}`).toBeTruthy(); + expect(response.headers()['content-type']).toContain('image/png'); + return imageBufferMetadata(page, await response.body()); + }), + ); + expect(frameMetadata.every((frame) => frame.width === 320 && frame.height === 240)).toBe(true); + expect(new Set(frameMetadata.map((frame) => frame.hash)).size).toBeGreaterThan(1); +}); diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..7729338 --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,51 @@ +import { defineConfig, devices } from '@playwright/test'; + +const baseURL = process.env.E2E_BASE_URL ?? 'http://127.0.0.1:8050'; +const serverCommand = + process.env.E2E_SERVER_COMMAND ?? + 'python -m parallax_maker.e2e_server --host 127.0.0.1 --port 8050'; + +export default defineConfig({ + testDir: '.', + testMatch: '**/*.spec.ts', + fullyParallel: false, + workers: 1, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI + ? [['line'], ['html', { open: 'never', outputFolder: 'test-results/report' }]] + : 'line', + outputDir: 'test-results/artifacts', + timeout: 45_000, + expect: { + timeout: 10_000, + }, + use: { + ...devices['Desktop Chrome'], + baseURL, + browserName: 'chromium', + viewport: { width: 1440, height: 1000 }, + deviceScaleFactor: 1, + colorScheme: 'light', + locale: 'en-US', + timezoneId: 'America/Los_Angeles', + reducedMotion: 'reduce', + serviceWorkers: 'block', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + acceptDownloads: true, + }, + webServer: + process.env.E2E_SKIP_SERVER === '1' + ? undefined + : { + command: serverCommand, + cwd: process.cwd(), + url: new URL('/__e2e__/ready', baseURL).toString(), + reuseExistingServer: false, + timeout: 120_000, + stdout: 'pipe', + stderr: 'pipe', + }, +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..129b825 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1599 @@ +{ + "name": "parallax-maker", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "parallax-maker", + "version": "1.0.0", + "license": "AGPL-3.0-only", + "dependencies": { + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38" + }, + "devDependencies": { + "@playwright/test": "1.62.1", + "tailwindcss": "^3.4.4" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "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, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true + }, + "node_modules/autoprefixer": { + "version": "10.4.19", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz", + "integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "browserslist": "^4.23.0", + "caniuse-lite": "^1.0.30001599", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.0", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.1.tgz", + "integrity": "sha512-TUfofFo/KsK/bWZ9TWQ5O26tsWW4Uhmt8IYklbnUa70udB6P2wA7w7o4PY4muaEPBQaAX+CEnmmIA41NVHtPVw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001629", + "electron-to-chromium": "^1.4.796", + "node-releases": "^2.0.14", + "update-browserslist-db": "^1.0.16" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001636", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001636.tgz", + "integrity": "sha512-bMg2vmr8XBsbL6Lr0UHXy/21m84FTxDLWn2FSqMd5PrlbMxwJlQnC2YWYxVgp66PZE+BBNF2jYQUBKCo1FDeZg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true + }, + "node_modules/electron-to-chromium": { + "version": "1.4.805", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.805.tgz", + "integrity": "sha512-8W4UJwX/w9T0QSzINJckTKG6CYpAUTqsaWcWIsdud3I1FYJcMgW9QqT1/4CBff/pP/TihWh13OmiyY8neto6vw==" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true + }, + "node_modules/escalade": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/foreground-child": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.2.1.tgz", + "integrity": "sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz", + "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/jackspeak": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.0.tgz", + "integrity": "sha512-JVYhQnN59LVPFCEcVa2C3CrEKYacvjRfqIQl+h8oi91aLYQVWRYbxjPcv1bUiUy/kLmQaANrYfNMCO3kuEDHfw==", + "dev": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "1.21.6", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", + "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==", + "dev": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/lru-cache": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz", + "integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==", + "dev": true, + "engines": { + "node": "14 || >=16.14" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", + "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", + "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", + "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/picocolors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.4.38", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", + "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.0", + "source-map-js": "^1.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-load-config/node_modules/lilconfig": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.2.tgz", + "integrity": "sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/postcss-nested": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz", + "integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.0.11" + }, + "engines": { + "node": ">=12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.0.tgz", + "integrity": "sha512-UMz42UD0UY0EApS0ZL9o1XnLhSTtvvvLe5Dc2H2O56fvRZi+KulDyf5ctDhhtYJBGKStV2FL1fy6253cmLgqVQ==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "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, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", + "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.4.tgz", + "integrity": "sha512-ZoyXOdJjISB7/BcLTR6SEsLgKtDStYyYZVLsUtWChO4Ps20CBad7lfJKVDiejocV4ME1hLmyY0WJE3hSDcmQ2A==", + "dev": true, + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.5.3", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.0", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.0", + "lilconfig": "^2.1.0", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.23", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.1", + "postcss-nested": "^6.0.1", + "postcss-selector-parser": "^6.0.11", + "resolve": "^1.22.2", + "sucrase": "^3.32.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true + }, + "node_modules/update-browserslist-db": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.16.tgz", + "integrity": "sha512-KVbTxlBYlckhF5wgfyZXTWnMn7MMZjMu9XG8bPlliUOP9ThaF4QnhP8qrjrH7DRzHfSk0oQv1wToW+iA5GajEQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.1.2", + "picocolors": "^1.0.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yaml": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.5.tgz", + "integrity": "sha512-aBx2bnqDzVOyNKfsysjA2ms5ZlnjSAW2eG3/L5G/CSujfjLJTJsEw1bGw8kCf04KodQWk1pxlGnZ56CRxiawmg==", + "dev": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + } + } +} diff --git a/package.json b/package.json index 11094e2..08a24e1 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "scripts": { "build": "tailwindcss -i ./tailwind.css -o ./parallax_maker/assets/css/tailwind_compiled.css", "watch": "tailwindcss -i ./tailwind.css -o ./parallax_maker/assets/css/tailwind_compiled.css --watch", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"Error: no test specified\" && exit 1", + "test:e2e": "playwright test --config=e2e/playwright.config.ts", + "test:e2e:headed": "playwright test --config=e2e/playwright.config.ts --headed" }, "author": "Niels Provos", "license": "AGPL-3.0-only", @@ -15,6 +17,7 @@ "postcss": "^8.4.38" }, "devDependencies": { + "@playwright/test": "1.62.1", "tailwindcss": "^3.4.4" } -} \ No newline at end of file +} diff --git a/parallax_maker/e2e_server.py b/parallax_maker/e2e_server.py new file mode 100644 index 0000000..b9cd265 --- /dev/null +++ b/parallax_maker/e2e_server.py @@ -0,0 +1,219 @@ +"""Explicit test-only server for deterministic offline Playwright workflows. + +Run with ``python -m parallax_maker.e2e_server``. The production console script +does not import or activate this module. +""" + +from __future__ import annotations + +import argparse +import io +import os +from pathlib import Path +import signal +import tempfile +from uuid import uuid4 + +from flask import abort, jsonify, request, send_file +import numpy as np +from PIL import Image + +from .e2e_support import INPAINT_PALETTES, create_fixture_state, create_input_image +from .e2e_support.fakes import CHECK_SIZE, install_fakes + + +def _exit_on_sigterm(signum, frame) -> None: + """Turn Playwright's SIGTERM into normal unwinding and temp-dir cleanup.""" + + del signum, frame + raise SystemExit(0) + + +def _state_directory(filename: str | None) -> Path: + if not filename or Path(filename).name != filename or not filename.startswith( + "appstate-" + ): + abort(400, "filename must be a single appstate-* directory name") + return Path.cwd() / filename + + +def _mask_metadata(mask: np.ndarray | None) -> dict: + if mask is None: + return { + "present": False, + "nonzero": 0, + "bounds": None, + "max": 0, + "inside": None, + "outside": None, + "samples": {}, + } + nonzero_y, nonzero_x = np.nonzero(mask > 0) + bounds = None + if len(nonzero_x): + bounds = [ + int(nonzero_x.min()), + int(nonzero_y.min()), + int(nonzero_x.max()), + int(nonzero_y.max()), + ] + height, width = mask.shape[:2] + maximum = int(mask.max()) + max_y, max_x = np.nonzero(mask == maximum) + center_x, center_y = (width - 1) / 2, (height - 1) / 2 + nearest_max = np.argmin((max_x - center_x) ** 2 + (max_y - center_y) ** 2) + inside = [int(max_x[nearest_max]), int(max_y[nearest_max])] + zero_y, zero_x = np.nonzero(mask == 0) + outside = [int(zero_x[0]), int(zero_y[0])] if len(zero_x) else None + samples = { + "8,8": int(mask[min(8, height - 1), min(8, width - 1)]), + "90,96": int(mask[min(96, height - 1), min(90, width - 1)]), + "128,96": int(mask[min(96, height - 1), min(128, width - 1)]), + } + return { + "present": True, + "nonzero": int(np.count_nonzero(mask)), + "bounds": bounds, + "max": maximum, + "inside": inside, + "outside": outside, + "samples": samples, + } + + +def _read_mask_file(state) -> np.ndarray | None: + """Return a uniquely selected persisted canvas mask, if one exists.""" + + if state.selected_slice is not None: + mask_path = state.mask_filename(state.selected_slice) + if mask_path.exists(): + return np.asarray(Image.open(mask_path).convert("L")) + + mask_paths = sorted(Path(state.filename).glob("image_slice_*_mask.png")) + if len(mask_paths) == 1: + return np.asarray(Image.open(mask_paths[0]).convert("L")) + return None + + +def _register_routes(app, fixture_root: Path) -> None: + @app.server.get("/__e2e__/ready") + def e2e_ready(): + return jsonify( + { + "ready": True, + "inpaint_check_size": CHECK_SIZE, + "inpaint_palettes": INPAINT_PALETTES, + "fixture_state": "/__e2e__/fixture/state.json", + } + ) + + @app.server.get("/__e2e__/fixture/input.png") + def e2e_input_fixture(): + output = io.BytesIO() + create_input_image().save(output, format="PNG") + output.seek(0) + return send_file(output, mimetype="image/png", download_name="e2e-input.png") + + @app.server.get("/__e2e__/fixture/state.json") + def e2e_state_fixture(): + # Every request gets a clean state directory and an independent cache key. + # This is intentionally a state-creating test route, not a production API. + state_name = f"appstate-e2e-{uuid4().hex}" + fixture_state = create_fixture_state(fixture_root, state_name=state_name) + return send_file( + fixture_state, + mimetype="application/json", + download_name="e2e-state.json", + ) + + @app.server.get("/__e2e__/state") + def e2e_state(): + """Expose compact state metadata for assertions, never production data.""" + + from .controller import AppState + + filename = request.args.get("filename") + state_dir = _state_directory(filename) + if not state_dir.exists(): + abort(404) + state = AppState.from_cache(filename) + selected_mask = _read_mask_file(state) + return jsonify( + { + "filename": state.filename, + "image_size": list(state.imgData.size), + "num_slices": state.num_slices, + "thresholds": state.imgThresholds, + "slice_count": len(state.image_slices), + "slice_depths": [float(item.depth) for item in state.image_slices], + "positive_prompts": [ + item.positive_prompt for item in state.image_slices + ], + "negative_prompts": [ + item.negative_prompt for item in state.image_slices + ], + "selected_slice": state.selected_slice, + "selected_inpainting": state.selected_inpainting, + "slice_mask": _mask_metadata(state.slice_mask), + "selected_mask_file": _mask_metadata(selected_mask), + "dark_mode": state.dark_mode, + "camera": { + "distance": state.camera.camera_distance, + "focal_length": state.camera.focal_length, + "max_distance": state.camera.max_distance, + }, + "mesh_displacement": state.mesh_displacement, + } + ) + + @app.server.get("/__e2e__/artifacts") + def e2e_artifacts(): + """List generated files so exports can be verified outside the temp cwd.""" + + state_dir = _state_directory(request.args.get("filename")) + if not state_dir.exists(): + abort(404) + files = [ + {"path": str(path.relative_to(state_dir)), "size": path.stat().st_size} + for path in sorted(state_dir.rglob("*")) + if path.is_file() + ] + return jsonify({"filename": state_dir.name, "files": files}) + + @app.server.get("/__e2e__/artifact//") + def e2e_artifact(filename: str, artifact: str): + """Download one generated artifact, including rendered animation frames.""" + + state_dir = _state_directory(filename).resolve() + artifact_path = (state_dir / artifact).resolve() + if state_dir not in artifact_path.parents or not artifact_path.is_file(): + abort(404) + return send_file(artifact_path) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Offline Parallax Maker E2E server") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", default=8050, type=int) + args = parser.parse_args() + + signal.signal(signal.SIGTERM, _exit_on_sigterm) + + # State paths are relative by design. An isolated cwd prevents test runs from + # reading or overwriting a developer's appstate-* directories. + with tempfile.TemporaryDirectory(prefix="parallax-maker-e2e-") as work_dir: + os.chdir(work_dir) + webui = install_fakes() + fixture_root = Path(work_dir) + _register_routes(webui.app, fixture_root) + print(f"E2E_READY http://{args.host}:{args.port}/__e2e__/ready", flush=True) + webui.app.run_server( + host=args.host, + port=args.port, + debug=False, + use_reloader=False, + ) + + +if __name__ == "__main__": + main() diff --git a/parallax_maker/e2e_support/__init__.py b/parallax_maker/e2e_support/__init__.py new file mode 100644 index 0000000..2bf3b31 --- /dev/null +++ b/parallax_maker/e2e_support/__init__.py @@ -0,0 +1,15 @@ +"""Test-only support for deterministic browser tests. + +Nothing in the production entry point imports this package. The fakes are installed +only by :mod:`parallax_maker.e2e_server`, which must be invoked explicitly. +""" + +from .fakes import INPAINT_PALETTES, install_fakes +from .fixtures import create_fixture_state, create_input_image + +__all__ = [ + "INPAINT_PALETTES", + "create_fixture_state", + "create_input_image", + "install_fakes", +] diff --git a/parallax_maker/e2e_support/fakes.py b/parallax_maker/e2e_support/fakes.py new file mode 100644 index 0000000..36e130a --- /dev/null +++ b/parallax_maker/e2e_support/fakes.py @@ -0,0 +1,312 @@ +"""Deterministic inference substitutes used exclusively by browser tests. + +The substitutes sit at model/provider boundaries. Dash callbacks, ``AppState``, +mask feathering/compositing, versioning, exports, and filesystem persistence keep +using the production implementations. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from itertools import count +from typing import Any + +import numpy as np +from PIL import Image, ImageDraw + + +INPAINT_PALETTES = ( + ((0, 255, 255), (255, 0, 255)), + ((255, 128, 0), (0, 64, 255)), + ((64, 255, 64), (128, 0, 192)), +) +CHECK_SIZE = 8 + + +class FakeDepthEstimationModel: + """Return an eight-band depth field with a ramp inside every band.""" + + MODELS = ["midas", "zoedepth", "dinov2"] + + def __init__(self, model: str = "midas") -> None: + if model not in self.MODELS: + raise ValueError(f"Unsupported depth model: {model}") + self._model_name = model + self.model = "e2e-depth-model" + + def __eq__(self, other: object) -> bool: + return ( + isinstance(other, FakeDepthEstimationModel) + and self._model_name == other._model_name + ) + + @property + def model_name(self) -> str: + return self._model_name + + def load_model(self, progress_callback=None) -> str: + if progress_callback: + progress_callback(100, 100) + return self.model + + def depth_map(self, image: Any, progress_callback=None) -> np.ndarray: + height, width = np.asarray(image).shape[:2] + x = np.arange(width, dtype=np.uint16) + y = np.arange(height, dtype=np.uint16) + bands = np.minimum((x * 8) // max(width, 1), 7) * 32 + ramp = (y * 31) // max(height - 1, 1) + result = np.minimum(bands[None, :] + ramp[:, None], 255).astype(np.uint8) + if progress_callback: + progress_callback(100, 100) + return result + + +def _normalise_points( + point_input: Any, +) -> tuple[list[tuple[int, int]], list[tuple[int, int]]]: + if isinstance(point_input, tuple): + return [point_input], [] + if isinstance(point_input, list): + return list(point_input), [] + if isinstance(point_input, dict): + return ( + list(point_input.get("positive_points", [])), + list(point_input.get("negative_points", [])), + ) + raise ValueError("Invalid point input") + + +class FakeSegmentationModel: + """Point-guided disk masks: positives add regions and negatives cut holes.""" + + MODELS = ["mask2former", "sam"] + + def __init__(self, model: str = "sam") -> None: + if model not in self.MODELS: + raise ValueError(f"Unsupported segmentation model: {model}") + self.model_name = model + self.model = "e2e-segmentation-model" + self.image_processor = None + self.image: Image.Image | None = None + self.mask: np.ndarray | None = None + + def __eq__(self, other: object) -> bool: + return ( + isinstance(other, FakeSegmentationModel) + and self.model_name == other.model_name + ) + + def load_model(self) -> str: + return self.model + + def segment_image(self, image: Any) -> None: + self.image = ( + Image.fromarray(np.asarray(image)) + if isinstance(image, np.ndarray) + else image.copy() + ).convert("RGB") + self.mask = None + return None + + def mask_at_point(self, point_input: Any) -> np.ndarray: + return self.mask_at_point_blended(point_input) + + def mask_at_point_blended(self, point_input: Any) -> np.ndarray: + if self.image is None: + raise RuntimeError("segment_image must be called before mask_at_point") + + positive_points, negative_points = _normalise_points(point_input) + width, height = self.image.size + radius = max(8, min(width, height) // 6) + mask = Image.new("L", (width, height), 0) + draw = ImageDraw.Draw(mask) + + for x, y in positive_points: + draw.ellipse( + (x - radius, y - radius, x + radius, y + radius), fill=255 + ) + hole_radius = max(5, (radius * 2) // 3) + for x, y in negative_points: + draw.ellipse( + ( + x - hole_radius, + y - hole_radius, + x + hole_radius, + y + hole_radius, + ), + fill=0, + ) + + self.mask = np.asarray(mask, dtype=np.uint8) + return self.mask + + +def checkerboard(size: tuple[int, int], variant: int) -> Image.Image: + """Create an exact RGB checkerboard for one of the three candidates.""" + + width, height = size + first, second = INPAINT_PALETTES[variant % len(INPAINT_PALETTES)] + yy, xx = np.indices((height, width)) + cells = ((xx // CHECK_SIZE) + (yy // CHECK_SIZE)) % 2 + result = np.empty((height, width, 3), dtype=np.uint8) + result[cells == 0] = first + result[cells == 1] = second + return Image.fromarray(result, mode="RGB") + + +def _load_inpainting_base(): + from parallax_maker.inpainting import InpaintingModel + + return InpaintingModel + + +class FakeInpaintingModel(_load_inpainting_base()): + """Use the real inpainting composition around a checkerboard generator.""" + + def __init__( + self, + model="diffusers/stable-diffusion-xl-1.0-inpainting-0.1", + **kwargs, + ): + super().__init__(model=model, **kwargs) + self._e2e_requested_model = model + # Force every selectable backend through the same local production branch. + self.model = self.MODELS[0] + self._e2e_call_count = 0 + + def __eq__(self, other: object) -> bool: + return ( + isinstance(other, FakeInpaintingModel) + and self._e2e_requested_model == other._e2e_requested_model + ) + + def load_model(self): + self._dimension = None + self.pipeline = "e2e-checkerboard-pipeline" + return self.pipeline + + def inpaint_diffusers( + self, + resize_init_image, + resize_mask_image, + prompt, + negative_prompt, + strength, + guidance_scale, + num_inference_steps, + seed, + ): + del resize_mask_image, prompt, negative_prompt, strength + del guidance_scale, num_inference_steps, seed + variant = self._e2e_call_count % len(INPAINT_PALETTES) + self._e2e_call_count += 1 + return checkerboard(resize_init_image.size, variant) + + +class FakeUpscaler: + """Nearest-neighbour 2x enlargement with a conspicuous yellow border.""" + + def __init__(self, model_name="swin2sr", external_model=None) -> None: + del external_model + self.model_name = model_name + self.scale_factor = 2 + + def upscale_image_tiled(self, image, overlap=64, prompt=None, negative_prompt=None): + del overlap, prompt, negative_prompt + source = image if isinstance(image, Image.Image) else Image.fromarray(image) + result = source.resize( + (source.width * self.scale_factor, source.height * self.scale_factor), + Image.Resampling.NEAREST, + ) + draw = ImageDraw.Draw(result) + color = (255, 255, 0, 255) if result.mode == "RGBA" else (255, 255, 0) + draw.rectangle( + (0, 0, result.width - 1, result.height - 1), outline=color, width=4 + ) + return result + + +@dataclass +class FakeExternalProvider: + """Provider-shaped object for configuration callbacks, without any I/O.""" + + api_key: str | None = None + + def validate_key(self): + return True, 123.0 + + def upscale_image(self, image, prompt="", negative_prompt=""): + del prompt, negative_prompt + return FakeUpscaler().upscale_image_tiled(image) + + +def _blocked_network(*args, **kwargs): + del args, kwargs + raise RuntimeError("External network access is disabled by the E2E test server") + + +def _offline_gltf_iframe(gltf_uri: str) -> str: + return ( + "

glTF created

" + ) + + +def install_fakes(): + """Install process-local fakes and return the already configured Dash module.""" + + import requests.sessions + + from parallax_maker import automatic1111, comfyui, controller, falai, inpainting + from parallax_maker import stabilityai, webui + from parallax_maker import components + + # Classes captured as module globals by callback functions. + webui.DepthEstimationModel = FakeDepthEstimationModel + webui.SegmentationModel = FakeSegmentationModel + webui.InpaintingModel = FakeInpaintingModel + controller.Upscaler = FakeUpscaler + controller.StabilityAI = FakeExternalProvider + controller.FalAI = FakeExternalProvider + + # Production URLs use whole-second timestamps. Several legitimate callbacks + # can complete inside one second, leaving React with an unchanged ``src`` and + # a stale image. Give the test process a monotonic query value so each real + # file write is observable without sleeps. + original_serve_main_image = controller.AppState.serve_main_image + image_versions = count() + + def serve_main_image_with_unique_url(state, image): + url = original_serve_main_image(state, image) + path = url.split("?", maxsplit=1)[0] + return f"{path}?e2e-v={next(image_versions)}" + + controller.AppState.serve_main_image = serve_main_image_with_unique_url + + # The production factory resolves this module global when a callback invokes it. + inpainting.InpaintingModel = FakeInpaintingModel + inpainting.StabilityAI = FakeExternalProvider + inpainting.FalAI = FakeExternalProvider + stabilityai.StabilityAI = FakeExternalProvider + falai.FalAI = FakeExternalProvider + + # Configuration probes are deterministic too. + components.StabilityAI = FakeExternalProvider + components.make_models_request = lambda server_address: ["e2e-model"] + components.get_history = lambda server_address, prompt_id: {"e2e": "ready"} + automatic1111.make_models_request = components.make_models_request + automatic1111.make_img2img_request = _blocked_network + comfyui.inpainting_comfyui = _blocked_network + inpainting.inpainting_comfyui = _blocked_network + + # Browser tests run offline. The normal application retains both scripts. + webui.app.config.external_scripts = [] + webui.get_gltf_iframe = _offline_gltf_iframe + # Font Awesome normally gives icon-only controls their glyph dimensions. + # Preserve clickable geometry offline without changing production assets. + webui.app.index_string = webui.app.index_string.replace( + "", + "", + ) + requests.sessions.Session.request = _blocked_network + return webui diff --git a/parallax_maker/e2e_support/fixtures.py b/parallax_maker/e2e_support/fixtures.py new file mode 100644 index 0000000..90f0733 --- /dev/null +++ b/parallax_maker/e2e_support/fixtures.py @@ -0,0 +1,73 @@ +"""Small, deterministic artifacts shared by browser workflows.""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +from PIL import Image, ImageDraw + +from parallax_maker.controller import AppState +from parallax_maker.segmentation import generate_image_slices + +from .fakes import FakeDepthEstimationModel + + +FIXTURE_STATE_PREFIX = "appstate-e2e-restore" + + +def create_input_image(size: tuple[int, int] = (320, 240)) -> Image.Image: + """Create a colorful input whose orientation and pixel positions are obvious.""" + + width, height = size + x = np.linspace(20, 220, width, dtype=np.uint8) + y = np.linspace(10, 180, height, dtype=np.uint8) + image = np.empty((height, width, 3), dtype=np.uint8) + image[:, :, 0] = x[None, :] + image[:, :, 1] = y[:, None] + image[:, :, 2] = 70 + result = Image.fromarray(image, mode="RGB") + draw = ImageDraw.Draw(result) + draw.rectangle((24, 24, 104, 96), fill=(240, 50, 45)) + draw.ellipse((205, 55, 292, 142), fill=(35, 210, 90)) + draw.polygon(((135, 205), (175, 125), (215, 205)), fill=(35, 90, 245)) + return result + + +def create_fixture_state(root: Path, state_name: str | None = None) -> Path: + """Write a complete restorable state and return its JSON path.""" + + if state_name is None: + state_name = FIXTURE_STATE_PREFIX + if Path(state_name).name != state_name or not state_name.startswith("appstate-"): + raise ValueError("state_name must be a single appstate-* directory name") + state_dir = root / state_name + state = AppState() + state.filename = state_name + state.set_img_data(create_input_image()) + state.depthMapData = FakeDepthEstimationModel("dinov2").depth_map(state.imgData) + state.num_slices = 3 + state.imgThresholds = [0, 85, 170, 255] + state.depth_model_name = "dinov2" + state.inpainting_model_name = ( + "diffusers/stable-diffusion-xl-1.0-inpainting-0.1" + ) + state.dark_mode = True + state.camera.camera_distance = 125.0 + state.camera.focal_length = 475.0 + state.camera.max_distance = 140.0 + state.mesh_displacement = 15 + state.image_slices = generate_image_slices( + np.asarray(state.imgData), + state.depthMapData, + state.imgThresholds, + num_expand=5, + ) + for index, image_slice in enumerate(state.image_slices): + image_slice.positive_prompt = f"fixture foreground {index}" + image_slice.negative_prompt = f"fixture exclusion {index}" + # AppState's serving URLs assume slice filenames remain cwd-relative. The + # E2E server has already changed into ``root``, so persist with that relative + # directory while retaining an absolute path for Flask's fixture response. + state.to_file(Path(state_name)) + return state_dir / AppState.STATE_FILE diff --git a/parallax_maker/test_e2e_support.py b/parallax_maker/test_e2e_support.py new file mode 100644 index 0000000..8264525 --- /dev/null +++ b/parallax_maker/test_e2e_support.py @@ -0,0 +1,106 @@ +import json +from pathlib import Path + +import numpy as np +from PIL import Image + +from .e2e_support.fakes import ( + INPAINT_PALETTES, + FakeDepthEstimationModel, + FakeInpaintingModel, + FakeSegmentationModel, + FakeUpscaler, +) +from .e2e_support.fixtures import create_fixture_state, create_input_image +from .e2e_server import _mask_metadata + + +def test_fake_depth_is_banded_and_spans_byte_range(): + depth = FakeDepthEstimationModel("dinov2").depth_map( + np.zeros((32, 80, 3), dtype=np.uint8) + ) + + assert depth.shape == (32, 80) + assert depth.dtype == np.uint8 + assert depth[0, 0] == 0 + assert depth[-1, -1] == 255 + assert depth[0, 10] - depth[0, 9] == 32 + assert depth[-1, 0] == 31 + + +def test_fake_segmentation_uses_points_and_negative_holes(): + model = FakeSegmentationModel() + model.segment_image(Image.new("RGB", (120, 100))) + + mask = model.mask_at_point_blended( + {"positive_points": [(40, 50)], "negative_points": [(45, 50)]} + ) + + assert mask[50, 25] == 255 + assert mask[50, 45] == 0 + assert mask[10, 100] == 0 + + +def test_fake_inpainting_cycles_exact_checkerboard_palettes(): + model = FakeInpaintingModel() + model.load_model() + image = Image.new("RGB", (32, 32), "black") + mask = Image.new("L", image.size, 255) + + results = [ + model.inpaint("", "", image, mask, padding=0, blur_radius=0) + for _ in range(3) + ] + + for result, palette in zip(results, INPAINT_PALETTES): + pixels = np.asarray(result.convert("RGB")) + assert tuple(pixels[2, 2]) == palette[0] + assert tuple(pixels[2, 10]) == palette[1] + + +def test_fake_upscaler_is_two_x_and_marks_border(): + result = FakeUpscaler().upscale_image_tiled(Image.new("RGB", (10, 6), "navy")) + + assert result.size == (20, 12) + assert result.getpixel((0, 0)) == (255, 255, 0) + + +def test_restore_fixture_uses_relative_safe_state_paths(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + state_path = create_fixture_state(Path.cwd()) + + assert state_path.exists() + assert create_input_image().size == (320, 240) + state_json = json.loads(state_path.read_text(encoding="utf-8")) + assert state_json["mesh_displacement"] == 15 + assert state_json["image_slices_filenames"] == [ + f"appstate-e2e-restore/image_slice_{index}.png" for index in range(3) + ] + + +def test_restore_fixture_supports_isolated_state_names(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + first = create_fixture_state(Path.cwd(), "appstate-e2e-first") + second = create_fixture_state(Path.cwd(), "appstate-e2e-second") + + assert first.parent != second.parent + assert json.loads(first.read_text(encoding="utf-8"))["filename"] == ( + "appstate-e2e-first" + ) + assert json.loads(second.read_text(encoding="utf-8"))["filename"] == ( + "appstate-e2e-second" + ) + + +def test_mask_metadata_reports_exact_inside_outside_and_bounds(): + mask = np.zeros((20, 30), dtype=np.uint8) + mask[7:13, 9:18] = 255 + + metadata = _mask_metadata(mask) + + assert metadata["bounds"] == [9, 7, 17, 12] + assert metadata["max"] == 255 + assert metadata["nonzero"] == 54 + assert mask[metadata["inside"][1], metadata["inside"][0]] == 255 + assert mask[metadata["outside"][1], metadata["outside"][0]] == 0 diff --git a/pyproject.toml b/pyproject.toml index a144cc2..0ba24e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,7 +73,7 @@ parallax-gltf-cli = "parallax_maker.gltf_cli:main" include-package-data = false [tool.setuptools.packages.find] -include = ["parallax_maker"] +include = ["parallax_maker*"] [tool.setuptools.package-data] parallax_maker = [ From b45a6a13e3092cf6ba1b9a95f85530d087d77854 Mon Sep 17 00:00:00 2001 From: provos Date: Wed, 12 Aug 2026 17:39:06 -0700 Subject: [PATCH 2/3] fix: constrain transformers to supported major --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0ba24e5..4eb4210 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ dependencies = [ "opencv-python>=4.9.0", "timm==0.6.12", "diffusers[torch] (>=0.34.0,<0.35.0)", - "transformers>=4.53.0", + "transformers (>=4.53.0,<5.0.0)", "orjson>=3.10.1", "numba>=0.59.1", "websocket-client>=1.8.0", From e0dd2efeb3495e1ee8efec5deb51df6fd5ff87f6 Mon Sep 17 00:00:00 2001 From: provos Date: Wed, 12 Aug 2026 17:43:16 -0700 Subject: [PATCH 3/3] fix: address Copilot E2E edge cases --- parallax_maker/e2e_server.py | 12 ++++++++---- parallax_maker/e2e_support/fixtures.py | 3 +++ parallax_maker/test_e2e_support.py | 25 +++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/parallax_maker/e2e_server.py b/parallax_maker/e2e_server.py index b9cd265..5275158 100644 --- a/parallax_maker/e2e_server.py +++ b/parallax_maker/e2e_server.py @@ -59,10 +59,14 @@ def _mask_metadata(mask: np.ndarray | None) -> dict: ] height, width = mask.shape[:2] maximum = int(mask.max()) - max_y, max_x = np.nonzero(mask == maximum) - center_x, center_y = (width - 1) / 2, (height - 1) / 2 - nearest_max = np.argmin((max_x - center_x) ** 2 + (max_y - center_y) ** 2) - inside = [int(max_x[nearest_max]), int(max_y[nearest_max])] + inside = None + if maximum > 0: + max_y, max_x = np.nonzero(mask == maximum) + center_x, center_y = (width - 1) / 2, (height - 1) / 2 + nearest_max = np.argmin( + (max_x - center_x) ** 2 + (max_y - center_y) ** 2 + ) + inside = [int(max_x[nearest_max]), int(max_y[nearest_max])] zero_y, zero_x = np.nonzero(mask == 0) outside = [int(zero_x[0]), int(zero_y[0])] if len(zero_x) else None samples = { diff --git a/parallax_maker/e2e_support/fixtures.py b/parallax_maker/e2e_support/fixtures.py index 90f0733..3117b8e 100644 --- a/parallax_maker/e2e_support/fixtures.py +++ b/parallax_maker/e2e_support/fixtures.py @@ -37,6 +37,9 @@ def create_input_image(size: tuple[int, int] = (320, 240)) -> Image.Image: def create_fixture_state(root: Path, state_name: str | None = None) -> Path: """Write a complete restorable state and return its JSON path.""" + root = root.resolve() + if root != Path.cwd().resolve(): + raise ValueError("fixture root must be the current working directory") if state_name is None: state_name = FIXTURE_STATE_PREFIX if Path(state_name).name != state_name or not state_name.startswith("appstate-"): diff --git a/parallax_maker/test_e2e_support.py b/parallax_maker/test_e2e_support.py index 8264525..9d163b8 100644 --- a/parallax_maker/test_e2e_support.py +++ b/parallax_maker/test_e2e_support.py @@ -3,6 +3,7 @@ import numpy as np from PIL import Image +import pytest from .e2e_support.fakes import ( INPAINT_PALETTES, @@ -93,6 +94,19 @@ def test_restore_fixture_supports_isolated_state_names(tmp_path, monkeypatch): ) +def test_restore_fixture_rejects_a_root_outside_the_working_directory( + tmp_path, monkeypatch +): + working_directory = tmp_path / "working" + other_directory = tmp_path / "other" + working_directory.mkdir() + other_directory.mkdir() + monkeypatch.chdir(working_directory) + + with pytest.raises(ValueError, match="fixture root must be"): + create_fixture_state(other_directory) + + def test_mask_metadata_reports_exact_inside_outside_and_bounds(): mask = np.zeros((20, 30), dtype=np.uint8) mask[7:13, 9:18] = 255 @@ -104,3 +118,14 @@ def test_mask_metadata_reports_exact_inside_outside_and_bounds(): assert metadata["nonzero"] == 54 assert mask[metadata["inside"][1], metadata["inside"][0]] == 255 assert mask[metadata["outside"][1], metadata["outside"][0]] == 0 + + +def test_mask_metadata_has_no_inside_point_for_an_empty_mask(): + metadata = _mask_metadata(np.zeros((20, 30), dtype=np.uint8)) + + assert metadata["present"] is True + assert metadata["nonzero"] == 0 + assert metadata["bounds"] is None + assert metadata["max"] == 0 + assert metadata["inside"] is None + assert metadata["outside"] == [0, 0]