From d2f9e0b5bec3dec7c1d72729a7a986bfff7767fa Mon Sep 17 00:00:00 2001 From: Peter Todd Date: Tue, 28 Jul 2026 06:55:00 +0100 Subject: [PATCH] Fix FlatPolygon production initialization --- .github/workflows/test.yml | 56 +++++++++++ .gitignore | 2 + package.json | 10 +- packages/layers/src/FlatPolygonLayer.ts | 5 +- .../layers/tests/flatPolygonLayer.spec.ts | 67 ++++++++++--- pnpm-lock.yaml | 38 ++++++++ tests/production/browser/index.html | 12 +++ tests/production/browser/playwright.config.ts | 31 ++++++ .../production/browser/polygon-shapes.spec.ts | 67 +++++++++++++ tests/production/browser/src.tsx | 97 +++++++++++++++++++ tests/production/browser/vite.config.ts | 76 +++++++++++++++ 11 files changed, 444 insertions(+), 17 deletions(-) create mode 100644 tests/production/browser/index.html create mode 100644 tests/production/browser/playwright.config.ts create mode 100644 tests/production/browser/polygon-shapes.spec.ts create mode 100644 tests/production/browser/src.tsx create mode 100644 tests/production/browser/vite.config.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1037c00..b77321e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,6 +24,8 @@ jobs: - uses: actions/checkout@v5 with: persist-credentials: false + with: + persist-credentials: false - name: Setup pnpm uses: pnpm/action-setup@v5 @@ -157,3 +159,57 @@ jobs: # Clean up server kill "$SERVER_PID" || true + + production-browser: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@v5 + with: + run_install: false + + - uses: actions/setup-node@v5 + with: + node-version: '24.14.1' + cache: 'pnpm' + + - uses: astral-sh/setup-uv@v7 + with: + version: "latest" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Cache generated production browser fixtures + id: cache-production-browser-fixtures + uses: actions/cache@v5 + with: + path: test-fixtures-production-browser + key: ${{ runner.os }}-production-browser-fixtures-0.7.2-${{ hashFiles('python/scripts/generate_fixtures.py', 'python/v0.7.2/**') }} + + - name: Generate test fixtures (Python spatialdata 0.7.2) + if: steps.cache-production-browser-fixtures.outputs.cache-hit != 'true' + run: pnpm test:fixtures:generate:0.7.2 -- --output-dir test-fixtures-production-browser + + - name: Expose production browser fixtures + run: ln -s test-fixtures-production-browser test-fixtures + + - name: Install Chromium and Linux dependencies + run: pnpm exec playwright install --with-deps chromium + + - name: Build and browser-test the production browser consumer + run: pnpm test:browser:production + + - name: Upload production browser artifacts + if: always() + uses: actions/upload-artifact@v5 + with: + name: production-browser-artifacts + path: test-results + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index dca7774..9810296 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,8 @@ packages/zarrextra/src/*.d.ts.map # Testing coverage/ test-fixtures/ +test-fixtures-production-browser/ +test-results/ validation-results/ # Python virtual environments (version-specific, completely separate) diff --git a/package.json b/package.json index 03b6f71..7e563e3 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "test": "pnpm -r --filter='!docs' run test", "test:unit": "vitest run --exclude tests/integration/**", "test:integration": "vitest run tests/integration", + "test:browser:production": "pnpm build && pnpm exec vite build --config tests/production/browser/vite.config.ts && pnpm exec playwright test --config tests/production/browser/playwright.config.ts", "test:all": "pnpm test:unit && pnpm test:integration", "test:fixtures:generate": "uv run python/scripts/generate_fixtures.py", "test:fixtures:generate:0.5.0": "uv run python/scripts/generate_fixtures.py --version 0.5.0", @@ -44,13 +45,14 @@ "@biomejs/biome": "^2.5.3", "@changesets/changelog-github": "^0.7.0", "@changesets/cli": "^2.31.0", - "@typescript-eslint/parser": "^8.62.0", - "eslint": "^10.6.0", - "eslint-plugin-react-hooks": "^7.1.1", + "@playwright/test": "^1.62.0", "@rolldown/plugin-babel": "catalog:", + "@typescript-eslint/parser": "^8.62.0", "@vitejs/plugin-react": "catalog:", - "babel-plugin-react-compiler": "catalog:", "@zarrita/storage": "catalog:", + "babel-plugin-react-compiler": "catalog:", + "eslint": "^10.6.0", + "eslint-plugin-react-hooks": "^7.1.1", "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:" diff --git a/packages/layers/src/FlatPolygonLayer.ts b/packages/layers/src/FlatPolygonLayer.ts index 498f72b..6199585 100644 --- a/packages/layers/src/FlatPolygonLayer.ts +++ b/packages/layers/src/FlatPolygonLayer.ts @@ -104,7 +104,10 @@ export class FlatPolygonLayer extends (Layer as any) { }): void { super.updateState(params); const { props, oldProps, changeFlags } = params; - if (changeFlags.extensionsChanged) { + // Deck marks extensionsChanged on the update immediately following + // initializeState. `oldProps.extensions` is absent in that first update, so + // the model created during initialization is already current. + if (changeFlags.extensionsChanged && oldProps.extensions !== undefined) { this.state.model?.destroy(); this.state.model = this._getModel(); } diff --git a/packages/layers/tests/flatPolygonLayer.spec.ts b/packages/layers/tests/flatPolygonLayer.spec.ts index 83ed955..6276bd6 100644 --- a/packages/layers/tests/flatPolygonLayer.spec.ts +++ b/packages/layers/tests/flatPolygonLayer.spec.ts @@ -1,29 +1,42 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; const modelOptions = vi.hoisted((): Array<{ shaderAssembler?: unknown }> => []); +const modelInstances = vi.hoisted((): Array<{ destroy: ReturnType }> => []); vi.mock('@luma.gl/engine', () => ({ Model: class { + destroy = vi.fn(); + constructor(_device: unknown, options: { shaderAssembler?: unknown }) { modelOptions.push(options); + modelInstances.push(this); } }, })); import { FlatPolygonLayer } from '../src/FlatPolygonLayer'; +function makeProps() { + return { + id: 'flat-polygon-shader-modules', + ringPositions: new Float32Array([0, 0, 1, 0, 0, 1]), + ringVertexCount: 3, + triangleData: new Uint32Array([0, 1, 2, 7]), + triangleCount: 1, + featureColors: new Uint8Array([0, 0, 0, 255]), + featureCount: 1, + featureScale: new Float32Array([1]), + }; +} + describe('FlatPolygonLayer shaders', () => { - it('uses Deck’s shader assembler when creating its hand-rolled Model', () => { - const layer = new FlatPolygonLayer({ - id: 'flat-polygon-shader-modules', - ringPositions: new Float32Array([0, 0, 1, 0, 0, 1]), - ringVertexCount: 3, - triangleData: new Uint32Array([0, 1, 2, 7]), - triangleCount: 1, - featureColors: new Uint8Array([0, 0, 0, 255]), - featureCount: 1, - featureScale: new Float32Array([1]), - }); + beforeEach(() => { + modelOptions.length = 0; + modelInstances.length = 0; + }); + + it("uses Deck's shader assembler when creating its hand-rolled Model", () => { + const layer = new FlatPolygonLayer(makeProps()); const shaderAssembler = {}; layer.context = { defaultShaderModules: [], shaderAssembler }; @@ -31,4 +44,34 @@ describe('FlatPolygonLayer shaders', () => { expect(modelOptions.at(-1)).toMatchObject({ shaderAssembler }); }); + + it("does not recreate the model for Deck's first forced extensionsChanged update", () => { + const props = makeProps(); + const layer = new FlatPolygonLayer(props); + const initialModel = { destroy: vi.fn() }; + layer.state = { model: initialModel }; + layer.context = { defaultShaderModules: [], shaderAssembler: {}, device: {} }; + + layer.updateState({ props, oldProps: { ...props }, changeFlags: { extensionsChanged: true } }); + + expect(initialModel.destroy).not.toHaveBeenCalled(); + expect(modelOptions).toHaveLength(0); + }); + + it('recreates the model for later extension changes', () => { + const props = makeProps(); + const layer = new FlatPolygonLayer(props); + const initialModel = { destroy: vi.fn() }; + layer.state = { model: initialModel }; + layer.context = { defaultShaderModules: [], shaderAssembler: {}, device: {} }; + + layer.updateState({ + props, + oldProps: { ...props, extensions: [] }, + changeFlags: { extensionsChanged: true }, + }); + + expect(initialModel.destroy).toHaveBeenCalledOnce(); + expect(modelInstances).toHaveLength(1); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b76fe9..f8da56e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -109,6 +109,9 @@ importers: '@changesets/cli': specifier: ^2.31.0 version: 2.31.0(@types/node@22.18.8) + '@playwright/test': + specifier: ^1.62.0 + version: 1.62.0 '@rolldown/plugin-babel': specifier: 'catalog:' version: 0.2.3(@babel/core@8.0.1)(@babel/runtime@7.28.4)(rolldown@1.1.5)(vite@8.1.4(@types/node@22.18.8)(jiti@1.21.7)(terser@5.44.0)) @@ -2453,6 +2456,11 @@ packages: '@petamoriken/float16@3.9.3': resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==} + '@playwright/test@1.62.0': + resolution: {integrity: sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==} + engines: {node: '>=20'} + hasBin: true + '@pnpm/config.env-replace@1.1.0': resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} engines: {node: '>=12.22.0'} @@ -4941,6 +4949,11 @@ packages: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -6441,6 +6454,16 @@ packages: resolution: {integrity: sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==} engines: {node: '>=14.16'} + playwright-core@1.62.0: + resolution: {integrity: sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.0: + resolution: {integrity: sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==} + engines: {node: '>=20'} + hasBin: true + png-js@1.0.0: resolution: {integrity: sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g==} @@ -11346,6 +11369,10 @@ snapshots: '@petamoriken/float16@3.9.3': {} + '@playwright/test@1.62.0': + dependencies: + playwright: 1.62.0 + '@pnpm/config.env-replace@1.1.0': {} '@pnpm/network.ca-file@1.0.2': @@ -14053,6 +14080,9 @@ snapshots: jsonfile: 4.0.0 universalify: 0.1.2 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -15830,6 +15860,14 @@ snapshots: dependencies: find-up: 6.3.0 + playwright-core@1.62.0: {} + + playwright@1.62.0: + dependencies: + playwright-core: 1.62.0 + optionalDependencies: + fsevents: 2.3.2 + png-js@1.0.0: {} polylabel@1.1.0: diff --git a/tests/production/browser/index.html b/tests/production/browser/index.html new file mode 100644 index 0000000..816db18 --- /dev/null +++ b/tests/production/browser/index.html @@ -0,0 +1,12 @@ + + + + + + Production layer browser smoke test + + +
+ + + diff --git a/tests/production/browser/playwright.config.ts b/tests/production/browser/playwright.config.ts new file mode 100644 index 0000000..191aedb --- /dev/null +++ b/tests/production/browser/playwright.config.ts @@ -0,0 +1,31 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: '.', + testMatch: '*.spec.ts', + fullyParallel: false, + workers: 1, + use: { + baseURL: 'http://127.0.0.1:4173', + browserName: 'chromium', + headless: true, + // CI has no hardware GPU. Chromium's SwiftShader ANGLE path still exposes + // WebGL2, which the polygon-shapes scenario probes before compiling the + // FlatPolygonLayer shader. + launchOptions: { args: ['--use-gl=angle', '--use-angle=swiftshader'] }, + }, + webServer: [ + { + command: 'pnpm test:server', + port: 38473, + reuseExistingServer: !process.env.CI, + timeout: 30_000, + }, + { + command: 'pnpm exec vite preview --config vite.config.ts', + port: 4173, + reuseExistingServer: !process.env.CI, + timeout: 30_000, + }, + ], +}); diff --git a/tests/production/browser/polygon-shapes.spec.ts b/tests/production/browser/polygon-shapes.spec.ts new file mode 100644 index 0000000..a39272a --- /dev/null +++ b/tests/production/browser/polygon-shapes.spec.ts @@ -0,0 +1,67 @@ +import { expect, test } from '@playwright/test'; + +// Zarrita probes a root or nested Zarr metadata file before falling back to the +// fixture's inline consolidated metadata. The fixture deliberately omits some of +// those files; only those exact 404s are tolerated. +const expectedMetadata404 = + /\/test-fixtures\/v0\.7\.2\/blobs\.zarr\/(?:.*\/)?(?:zarr\.json|\.zmetadata|\.zattrs|\.zgroup)$/; +const expectedMetadataConsoleError = + /^Failed to load resource: the server responded with a status of 404 \(Not Found\)$/; + +test('the built layer consumer renders canonical polygon shapes without WebGL errors', async ({ + page, +}, testInfo) => { + const consoleErrors: string[] = []; + const pageErrors: string[] = []; + let expectedMetadata404Count = 0; + const unexpected404Urls: string[] = []; + let expectedMetadataConsoleErrorCount = 0; + + page.on('response', (response) => { + if (response.status() === 404 && expectedMetadata404.test(response.url())) { + expectedMetadata404Count += 1; + } + if (response.status() === 404 && !expectedMetadata404.test(response.url())) { + unexpected404Urls.push(response.url()); + } + }); + page.on('console', (message) => { + if (message.type() !== 'error') return; + if (expectedMetadataConsoleError.test(message.text())) { + expectedMetadataConsoleErrorCount += 1; + return; + } + consoleErrors.push(message.text()); + }); + page.on('pageerror', (error) => pageErrors.push(error.message)); + + await page.goto('/', { waitUntil: 'networkidle' }); + await expect + .poll(() => page.evaluate(() => Boolean(document.createElement('canvas').getContext('webgl2')))) + .toBe(true); + await expect(page.getByTestId('fixture-ready')).toBeVisible(); + await expect.poll(() => page.evaluate(() => document.querySelectorAll('canvas').length)).toBe(1); + await page.waitForTimeout(500); + await page.screenshot({ path: testInfo.outputPath('polygon-shapes.png') }); + const runtime = await page.evaluate(() => ({ + frames: window.polygonShapesRenderFrames, + deckErrors: window.polygonShapesDeckErrors, + canvasCount: document.querySelectorAll('canvas').length, + })); + expect(runtime).toMatchObject({ deckErrors: [], canvasCount: 1 }); + expect(runtime.frames).toBeGreaterThan(1); + + expect(pageErrors).toEqual([]); + expect({ + consoleErrors, + unexpected404Urls, + unmatchedExpectedMetadataConsoleErrors: Math.max( + 0, + expectedMetadataConsoleErrorCount - expectedMetadata404Count + ), + }).toEqual({ + consoleErrors: [], + unexpected404Urls: [], + unmatchedExpectedMetadataConsoleErrors: 0, + }); +}); diff --git a/tests/production/browser/src.tsx b/tests/production/browser/src.tsx new file mode 100644 index 0000000..3531cd0 --- /dev/null +++ b/tests/production/browser/src.tsx @@ -0,0 +1,97 @@ +import { Deck, OrthographicView } from '@deck.gl/core'; +import { createShapesDeckLayer } from '@spatialdata/layers'; +import { useEffect, useRef, useState } from 'react'; +import { createRoot } from 'react-dom/client'; + +const fixtureMetadataUrl = new URL( + '/test-fixtures/v0.7.2/blobs.zarr/shapes/blobs_polygons/zarr.json', + window.location.href +).href; + +declare global { + interface Window { + polygonShapesDeckErrors: string[]; + polygonShapesRenderFrames: number; + } +} + +window.polygonShapesDeckErrors = []; +window.polygonShapesRenderFrames = 0; + +function PolygonFixtureConsumer() { + const container = useRef(null); + const [fixtureReady, setFixtureReady] = useState(false); + const [fixtureError, setFixtureError] = useState(null); + + useEffect(() => { + let active = true; + fetch(fixtureMetadataUrl) + .then((response) => { + if (!response.ok) throw new Error(`Fixture metadata request failed: ${response.status}`); + return response.json(); + }) + .then((metadata: { attributes?: { 'encoding-type'?: string } }) => { + if (metadata.attributes?.['encoding-type'] !== 'ngff:shapes') { + throw new Error('Canonical fixture did not contain an ngff:shapes element'); + } + if (active) setFixtureReady(true); + }) + .catch((error: unknown) => { + if (active) setFixtureError(error instanceof Error ? error.message : String(error)); + }); + return () => { + active = false; + }; + }, []); + + useEffect(() => { + if (!fixtureReady || !container.current) return; + + // Triangle from the first polygon in the canonical blobs_polygons GeoParquet + // fixture. It is intentionally handed to the published vertex-pulling layer, + // not a circle or a Deck built-in polygon layer. + const layer = createShapesDeckLayer( + { + kind: 'flat-polygons', + geometryKind: 'polygon', + elementKey: 'blobs_polygons', + featureIds: ['blob-0'], + polygonBinary: { + positions: new Float32Array([ + 340.19708, 258.2137, 316.17697, 197.0654, 291.0622, 205.28772, + ]), + startIndices: new Int32Array([0, 3]), + }, + rowIndexByFeatureIndex: new Int32Array([0]), + }, + { kind: 'shapes', elementKey: 'blobs_polygons', visible: true }, + { id: 'shapes:blobs_polygons', pickingEnabled: false } + ); + const deck = new Deck({ + parent: container.current, + views: new OrthographicView({ id: 'fixture' }), + initialViewState: { target: [315, 225, 0], zoom: 2 }, + controller: false, + layers: layer ? [layer] : [], + onAfterRender: () => { + window.polygonShapesRenderFrames += 1; + }, + onError: (error) => { + window.polygonShapesDeckErrors.push(error.message); + console.error(`FlatPolygon deck error: ${error.message}`); + }, + }); + return () => deck.finalize(); + }, [fixtureReady]); + + if (fixtureError) return {fixtureError}; + if (!fixtureReady) + return Loading canonical polygon fixture...; + return
; +} + +const rootElement = document.getElementById('root'); +if (!rootElement) { + throw new Error('Production browser consumer root element is missing'); +} +createRoot(rootElement).render(); diff --git a/tests/production/browser/vite.config.ts b/tests/production/browser/vite.config.ts new file mode 100644 index 0000000..8ad0958 --- /dev/null +++ b/tests/production/browser/vite.config.ts @@ -0,0 +1,76 @@ +import { cpSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +const testRoot = path.resolve(import.meta.dirname); +const workspaceRoot = path.resolve(testRoot, '../../..'); +const require = createRequire(import.meta.url); +const layersRequire = createRequire(path.join(workspaceRoot, 'packages/layers/package.json')); +const reactRoot = path.dirname(require.resolve('react/package.json')); +const reactDomRoot = path.dirname(require.resolve('react-dom/package.json')); +const deckCoreRoot = layersRequire.resolve('@deck.gl/core'); +const distRoot = (workspacePackage: string) => path.join(workspaceRoot, workspacePackage, 'dist'); +const packageRootAliases = (packageName: string, root: string) => [ + { find: new RegExp(`^${packageName}$`), replacement: path.join(root, 'index.js') }, + { find: new RegExp(`^${packageName}/(.+)$`), replacement: path.join(root, '$1') }, +]; +const fixtureProxy = { + target: 'http://127.0.0.1:38473', + changeOrigin: true, + // VTableSource probes a partitioned fallback after the canonical single-file + // GeoParquet path. The blobs fixture is deliberately single-file, so serve that + // same canonical file for the fallback probe too. + rewrite: (requestPath: string) => + requestPath.endsWith('/shapes.parquet/part.0.parquet') + ? requestPath.replace('/shapes.parquet/part.0.parquet', '/shapes.parquet') + : requestPath, +}; +const coreVendorDir = path.join(workspaceRoot, 'packages/core/dist/vendor'); + +export default defineConfig({ + root: testRoot, + plugins: [ + react(), + { + name: 'copy-core-parquet-wasm', + writeBundle() { + // `@spatialdata/core` publishes this vendored dynamic-import asset. Vite + // cannot discover the intentionally @vite-ignore import, so a consumer + // application must make it available at the URL used by the built module. + cpSync(coreVendorDir, path.join(testRoot, 'dist/vendor'), { recursive: true }); + }, + }, + ], + build: { + // The consumer has one entry point. Keeping it in one chunk avoids a current + // Rolldown cross-chunk panic in apache-arrow's WHATWG iterator re-export. + rolldownOptions: { output: { codeSplitting: false } }, + }, + resolve: { + // Deliberately resolve package entry points to dist, rather than workspace + // source: this is a consumer-artifact smoke test. + alias: [ + { + find: 'zarrextra/workers', + replacement: path.join(workspaceRoot, 'packages/zarrextra/dist/workers.js'), + }, + ...packageRootAliases('zarrextra', distRoot('packages/zarrextra')), + ...packageRootAliases('@spatialdata/avivatorish', distRoot('packages/avivatorish')), + ...packageRootAliases('@spatialdata/core', distRoot('packages/core')), + ...packageRootAliases('@spatialdata/layers', distRoot('packages/layers')), + { find: '@deck.gl/core', replacement: deckCoreRoot }, + { find: 'react', replacement: reactRoot }, + { find: 'react-dom', replacement: reactDomRoot }, + ], + dedupe: ['react', 'react-dom'], + }, + preview: { + host: '127.0.0.1', + port: 4173, + strictPort: true, + proxy: { '/test-fixtures': fixtureProxy }, + }, + server: { proxy: { '/test-fixtures': fixtureProxy } }, +});