From 21bd8573209a0dbff49f322fcc5d67db44776143 Mon Sep 17 00:00:00 2001 From: Tamay Eser Uysal Date: Thu, 24 Sep 2026 11:45:36 +0200 Subject: [PATCH 1/3] Add DOM emulator viewport and shared web HTML configuration --- README.md | 76 ++++++++++++++++++- docs/ARCHITECTURE.md | 8 +- package.json | 3 +- targets/web/build-dom-web.mjs | 28 +++---- targets/web/dev-web.mjs | 38 +++++++--- targets/web/dom-emulator.mjs | 57 ++++++++++++++ targets/web/dom-web-shared.mjs | 73 ++++++++++++++---- .../test/dev-web-hmr-guard.browser.test.mjs | 48 ++++++++++-- .../web/test/dom-emulator.browser.test.mjs | 68 +++++++++++++++++ targets/web/test/web-test-helpers.mjs | 4 +- 10 files changed, 347 insertions(+), 56 deletions(-) create mode 100644 targets/web/dom-emulator.mjs create mode 100644 targets/web/test/dom-emulator.browser.test.mjs diff --git a/README.md b/README.md index 4dff7b9..53fa233 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Run a specific example through the web target: ```sh targets/web/dev-web.mjs watch -targets/web/build-web.sh watch +targets/web/build-dom-web.mjs watch ``` ## Documentation @@ -84,3 +84,77 @@ built on it. The only GeaStack code under a different license is the embedded board support (`targets` and `@geastack/chips`, GPL-3.0-only): shipping closed-source firmware through those needs a commercial license. Contact [contact@geastack.com](mailto:contact@geastack.com) for commercial terms, support and hosted builds. + +## DOM emulator and web apps + +From an app declaring `gea.targets.web`: + +```sh +gea dev # real DOM + CSS, Vite HMR +gea simulate # DOM app inside an adjustable device viewport +gea simulate --width 320 --height 480 --dpr 2 --zoom 1 --no-open +gea simulate --renderer wasm # embedded C++ renderer; requires Emscripten +gea build --target web # .gea/build/web/site +``` + +`simulate` now defaults to DOM. Scripts that require framebuffer parity must +explicitly pass `--renderer wasm`. The WASM shell (`npm run dev` in this repo) +remains available; it does not provide the DOM app's component HMR. + +The DOM emulator uses the same development pipeline as `gea dev`. Its iframe +contains real Gea DOM elements, browser CSS, and native pointer/keyboard input. +Width and height are CSS pixels. Zoom scales the preview without changing the +app viewport. DPR controls `Display.getDevicePixelRatio()` only; it does not +change the browser's actual `window.devicePixelRatio` or CSS media queries. +Viewport adjustments preserve the running app and HMR connection. + +### HTML and configuration + +An app's `index.html` is used in both development and production. Keep an app +mount element (`#app` for `@geastack/core.mount`) and a module script for your +entry. If HTML is absent, Gea generates it in memory. HTML edits reload the app. + +Put web-only Vite settings in `vite.web.config.ts` (also supported: `.mts`, `.js`, +`.mjs`, `.cts`, `.cjs`). Gea scaffolds this file. Legacy `vite.config.*` is left +for existing embedded builds and is never loaded by this DOM pipeline. +Aliases, plugins, base paths, and ordinary Vite options are merged into both +web commands. Gea owns the root, app entry, runtime aliases, JSX transforms, +and CLI output directory. It installs its Gea compiler plugin once even if the +web config also supplies one. Restart after editing the web config. + +CSS updates and compatible reactive, static/function, and nested component +edits use HMR. Compatible reactive edits preserve state. Changes to runtime +base classes or modules that cannot be patched fall back to a page reload. +Hardware and embedded layout parity must be checked separately using WASM or +a device; browser layout is the browser's own layout implementation. + +### Browser device API contract + +| API | DOM behavior | +| --- | --- | +| Display dimensions | Live iframe/window dimensions; emulator DPR as described above | +| Display brightness, orientation setters, panel/refresh/memory tuning | No-ops; no hardware effects | +| Input, CSS, DOM, fetch, local storage | Browser APIs; storage compatibility returns an empty string for missing keys | +| Oscillator audio | Web Audio where available, subject to browser playback policy | +| Device audio volume | Fixed readback, no-op setter | +| Battery, heap/PSRAM/stack, Wi-Fi | Simulated values: battery 87%, memory 0, Wi-Fi `web`/loopback with empty scan results | +| Camera host | Unavailable; opening/recording fail and capture returns -1; no browser-camera bridge | +| `webPreload` files | Read-only HTTP files at the declared device paths, with range reads in dev | +| Device filesystem writes, native image handles | Unsupported; failure/empty results | + +### Testing local compiler/runtime changes + +The installed dependency artifacts can differ from sibling source checkouts. +Build the plugin and explicitly select both local packages when validating a +cross-repository change (paths below assume sibling repositories): + +```sh +npm --prefix ../gea run build:vite-plugin +GEA_WEB_PLUGIN_DIR="$PWD/../gea/packages/vite-plugin-gea" \ +GEA_WEB_RUNTIME_DIR="$PWD/../gea/packages/gea" npm run test:browser +``` + +The same environment variables work with `gea dev`, `gea simulate`, and web +builds. Without them, packages resolve from the app/core installation as before. +Ship corresponding compiler/runtime and simulator changes together; selecting +an older installed artifact does not exercise the modified sources. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1d87ab3..9eac9c9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -3,7 +3,7 @@ The simulator repo has two related jobs: 1. Provide a browser development app for Gea programs. -2. Provide the web target scripts used by examples, tests, and future CLI work. +2. Provide the web target scripts used by examples, tests, and the CLI. ## Runtime Pieces @@ -24,8 +24,10 @@ framebuffer behavior, fetch/media/RTC/WebSocket parity, and defaults. `targets/web` contains the target entry points: -- `dev-web.mjs`: run one app in a development loop. -- `build-web.sh`: build one app for web output. +- `dev-web.mjs`: run one DOM app with Vite HMR; `--emulator` wraps it in a device iframe. +- `dom-emulator.mjs`: development-only viewport shell. +- `build-dom-web.mjs`: build production HTML/JS/CSS. +- `build-web.sh`: compile the embedded renderer to WASM (`gea simulate --renderer wasm`). Generated outputs under `targets/web/generated` are build products. diff --git a/package.json b/package.json index 6e6dc79..9d5b124 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@geastack/simulator", "version": "0.1.8", - "description": "Browser simulator for GeaStack apps: runs the embedded framework as WebAssembly with a device-shaped preview.", + "description": "DOM emulator and web development with HMR for GeaStack apps, plus an optional WebAssembly device renderer.", "license": "Apache-2.0", "repository": { "type": "git", @@ -29,6 +29,7 @@ "targets/web/build-dom-web.mjs", "targets/web/dev-web.mjs", "targets/web/dom-web-shared.mjs", + "targets/web/dom-emulator.mjs", "targets/web/include/", "targets/web/main/", "LICENSE", diff --git a/targets/web/build-dom-web.mjs b/targets/web/build-dom-web.mjs index bf3e18d..44fd1b0 100644 --- a/targets/web/build-dom-web.mjs +++ b/targets/web/build-dom-web.mjs @@ -17,7 +17,7 @@ // node targets/web/build-dom-web.mjs --app-dir [--out-dir ] [--base /path/] // node targets/web/build-dom-web.mjs [--out-dir ] (run from the project) // -// --out-dir defaults to /.gea/build/web/dist — the app's own build tree, +// --out-dir defaults to /.gea/build/web/site — the app's own build tree, // beside the board builds it already writes there. Never node_modules, never a // newly invented scratch directory. // @@ -35,7 +35,8 @@ import { createCompatPlugin, createDotEnvPlugin, createRuntimeBridgePlugin, - harnessHtml, + appHtml, + webViteConfig, loadBabel, loadCompatTransform, loadDotEnvDefines, @@ -50,7 +51,6 @@ import { } from './dom-web-shared.mjs' const scriptDir = path.dirname(fileURLToPath(import.meta.url)) -const packageRoot = path.resolve(scriptDir, '../..') // ---- args ------------------------------------------------------------------ const args = parseCommonArgs(process.argv.slice(2), ['--help', '-h', '--no-preload']) @@ -58,7 +58,7 @@ if (args.flags['--help'] || args.flags['-h']) { process.stdout.write( 'usage: build-dom-web.mjs [appId] [--app-dir ] [--out-dir ] [--base ]\n' + ' --app-dir absolute path to the app (no apps-root or id registry needed)\n' + - ' --out-dir static site output (default /.gea/build/web/dist)\n' + + ' --out-dir static site output (default /.gea/build/web/site)\n' + ' --base public base path for emitted asset URLs (default ./)\n', ) process.exit(0) @@ -80,8 +80,8 @@ try { } const appDir = app.appDir const webBuildDir = path.join(appDir, '.gea/build/web') -const outDir = path.resolve(args.flags['--out-dir'] || path.join(webBuildDir, 'dist')) -const base = args.flags['--base'] || './' +const outDir = path.resolve(args.flags['--out-dir'] || path.join(webBuildDir, 'site')) +const base = args.flags['--base'] // ---- toolchain, all out of @geastack/core ---------------------------------- let coreRoot @@ -111,8 +111,7 @@ const preloadMounts = args.flags['--no-preload'] ? [] : webPreloadMounts(app) // points at nothing, and Vite reports it only as "didn't resolve at build // time, it will remain unchanged" before shipping a site with no fonts. // -// Vite still needs an html entry inside the root, and the app source tree is -// not ours to write into. So the harness html is VIRTUAL: `/index.html` +// Use application HTML when present, otherwise a virtual fallback: `/index.html` // is named as the rollup input and served from memory by a `pre` load hook, so // it is at the right depth without ever existing on disk. The compat transform // runs as the same plugin dev uses. @@ -125,10 +124,7 @@ const alias = buildAliases({ coreRoot, appDir }) const defines = dotEnvDefines(appDir) const processEnvDefines = Object.fromEntries(Object.entries(defines).filter(([key]) => key.startsWith('process.env.'))) const harnessPath = path.join(appDir, 'index.html') -const harnessSource = harnessHtml({ title: app.appName, entry: app.entry }) -if (fs.existsSync(harnessPath)) { - console.warn(` note: ${path.relative(appDir, harnessPath)} exists in the app and is ignored — the harness html is generated.`) -} +const harnessSource = appHtml(app) // Deliberately NOT `enforce: 'pre'`. A pre plugin's transformIndexHtml runs // ahead of vite:build-html's own html transform, and the inline `
Gea DOM emulator + + + + +Browser layout · simulated device APIs
+
+` +} diff --git a/targets/web/dom-web-shared.mjs b/targets/web/dom-web-shared.mjs index 85d5624..40ebd5c 100644 --- a/targets/web/dom-web-shared.mjs +++ b/targets/web/dom-web-shared.mjs @@ -162,7 +162,9 @@ export async function loadVite(coreRoot) { * but read whichever callable the module exposes rather than assuming. */ export async function loadGeaPlugin(coreRoot) { - const pkgDir = findPackageDirFrom('@geajs/vite-plugin', [coreRoot]) + const pkgDir = process.env.GEA_WEB_PLUGIN_DIR + ? path.resolve(process.env.GEA_WEB_PLUGIN_DIR) + : findPackageDirFrom('@geajs/vite-plugin', [coreRoot]) if (!pkgDir) throw new Error(`@geajs/vite-plugin could not be resolved from @geastack/core at ${coreRoot}`) const entry = resolvePackageExport(pkgDir, '.') if (!entry) throw new Error(`@geajs/vite-plugin has no resolvable entry at ${pkgDir}`) @@ -174,6 +176,43 @@ export async function loadGeaPlugin(coreRoot) { return geaPlugin } +// Only an explicit web config is loaded: legacy vite.config.* can target C++. +// Required runtime transforms remain authoritative and are installed once. +export async function webViteConfig(coreRoot, appDir, command, required) { + const { loadConfigFromFile, mergeConfig } = await loadVite(coreRoot) + const configFile = ['ts', 'mts', 'js', 'mjs', 'cts', 'cjs'] + .map((ext) => path.join(appDir, `vite.web.config.${ext}`)) + .find((file) => fs.existsSync(file)) + const loaded = configFile ? await loadConfigFromFile({ command, mode: command === 'serve' ? 'development' : 'production' }, configFile, appDir) : null + const user = loaded?.config || {} + async function plugins(items) { + const result = [] + for (const item of await Promise.all([items].flat(Infinity))) { + if (Array.isArray(item)) result.push(...await plugins(item)) + else if (item && item.name !== 'gea-plugin') result.push(item) + } + return result + } + const config = mergeConfig(user, required) + config.plugins = [...await plugins(user.plugins || []), ...required.plugins] + // Framework identities must precede user prefix aliases. + const userAliases = Array.isArray(user.resolve?.alias) ? user.resolve.alias + : Object.entries(user.resolve?.alias || {}).map(([find, replacement]) => ({ find, replacement })) + config.resolve.alias = [...required.resolve.alias, ...userAliases] + config.configFile = false + if (command === 'build') { + config.base ??= './' + config.build.lib = false + config.build.rollupOptions = { ...config.build.rollupOptions, input: path.join(appDir, 'index.html') } + } + return config +} + +export function appHtml(app) { + const file = path.join(app.appDir, 'index.html') + return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : harnessHtml({ title: app.appName, entry: app.entry }) +} + // --------------------------------------------------------------------------- // app discovery // --------------------------------------------------------------------------- @@ -276,7 +315,9 @@ const RUNTIME_BRIDGE_NAMES = new Set(['gea-embedded', '@geastack/core', '@geasta */ export function createRuntimeBridgePlugin({ coreRoot, appDir }) { const runtimePath = path.join(coreRoot, 'runtime.ts') - const geaCoreDir = findPackageDirFrom('@geajs/core', [appDir, coreRoot].filter(Boolean)) + const geaCoreDir = process.env.GEA_WEB_RUNTIME_DIR + ? path.resolve(process.env.GEA_WEB_RUNTIME_DIR) + : findPackageDirFrom('@geajs/core', [appDir, coreRoot].filter(Boolean)) const geaCoreEntry = geaCoreDir ? resolvePackageExport(geaCoreDir, '.', ['source', 'import', 'module', 'default']) : '' const q = (value) => JSON.stringify(value) return { @@ -318,7 +359,9 @@ export function buildAliases({ coreRoot, appDir }) { // @geajs/core: prefer the package's own "source" condition when the install // actually carries src/ (a workspace checkout), else its published dist. - const geaCoreDir = findPackageDirFrom('@geajs/core', [appDir, coreRoot].filter(Boolean)) + const geaCoreDir = process.env.GEA_WEB_RUNTIME_DIR + ? path.resolve(process.env.GEA_WEB_RUNTIME_DIR) + : findPackageDirFrom('@geajs/core', [appDir, coreRoot].filter(Boolean)) if (geaCoreDir) { const conditions = ['source', 'import', 'module', 'default'] for (const subpath of ['./jsx-dev-runtime', './jsx-runtime', './router', './ssr', './compiler-runtime']) { @@ -880,29 +923,27 @@ export const HOST_SHIM = `;(function () { try { Object.defineProperty(navigator, 'wifi', { value: wifiShim, configurable: true }) } catch (e) {} } } - // Camera: device-only. Stub it so camera apps mount in the browser (no live - // frames — the leaf just shows its CSS box) and the imperative control - // surface (AE / zoom / capture / record) is exercisable. isAvailable() = true so - // the app takes its normal "Live" path rather than the no-camera fallback. + // This host does not bridge browser media devices. Report unavailable so + // apps choose their fallback instead of displaying a fictitious live feed. if (typeof window.__gea_Camera === 'undefined') { window.__gea_Camera = { - width: 1280, height: 960, orientation: 0, facing: 'back', deviceCount: 1, - isAvailable: function () { return true }, - hasPermission: function () { return true }, - requestPermission: function () { return true }, - open: function () { return true }, + width: 1280, height: 960, orientation: 0, facing: 'back', deviceCount: 0, + isAvailable: function () { return false }, + hasPermission: function () { return false }, + requestPermission: function () { return false }, + open: function () { return false }, close: noop, - isOpen: function () { return true }, + isOpen: function () { return false }, draw: noop, capture: function () { return -1 }, captureMirrored: function () { return -1 }, - startRecording: function () { return true }, + startRecording: function () { return false }, stopRecording: function () { return 0 }, isRecording: function () { return false }, setFlash: noop, setZoom: noop, setMirror: noop, setExposure: noop, setWhiteBalance: noop, setFocus: noop, setTorch: noop, - deviceIdAt: function () { return 'web-camera' }, - deviceFacingAt: function () { return 'back' } + deviceIdAt: function () { return '' }, + deviceFacingAt: function () { return '' } } } // The "image" host: asset/image decoding AND the board filesystem diff --git a/targets/web/test/dev-web-hmr-guard.browser.test.mjs b/targets/web/test/dev-web-hmr-guard.browser.test.mjs index 1d8bd82..286e2d5 100644 --- a/targets/web/test/dev-web-hmr-guard.browser.test.mjs +++ b/targets/web/test/dev-web-hmr-guard.browser.test.mjs @@ -12,7 +12,7 @@ export class App extends ReactiveComponent { write('components/App.tsx', reactive('before')) const server = await start() const served = (await fetchText(`${server.url}/components/App.tsx`)).split('//# sourceMappingURL')[0] - assert.match(served, /if \(!__patched\) import\.meta\.hot\.invalidate\(\)/) + assert.match(served, /if \((?:__incompatible \|\| )?!__patched\) import\.meta\.hot\.invalidate\(\)/) const page = await newPage() const errors = [] page.on('pageerror', (error) => errors.push(error.message)) @@ -26,16 +26,54 @@ export class App extends ReactiveComponent { await page.locator('.count').click() await expect(page.locator('.count')).toHaveText('after: 2') - // Establish a fresh static mount before testing its edit/fallback path. + // A runtime-base change must reload, while subsequent static edits patch. const staticComponent = (label) => `export function App() { return

${label}

}` write('components/App.tsx', staticComponent('static-before')) await waitFor(async () => (await fetchText(`${server.url}/components/App.tsx`)).includes('static-before')) - await page.reload() await expect(page.locator('.static')).toHaveText('static-before') + assert.equal(await page.evaluate(() => window.hmrSentinel), undefined, 'incompatible edit did not reload') await page.evaluate(() => { window.hmrSentinel = 'before-fallback' }) write('components/App.tsx', staticComponent('static-after')) await expect(page.locator('.static')).toHaveText('static-after') - assert.equal(await page.evaluate(() => window.hmrSentinel), undefined, 'unpatchable static edit did not reload') + assert.equal(await page.evaluate(() => window.hmrSentinel), 'before-fallback', 'static edit reloaded the page') assert.deepEqual(errors, []) - console.log('dev-web updates reactive components and reloads static components') + console.log('dev-web hot updates reactive and static components') +}) + +await withWebFixture('nested-hmr', async ({ write, start, page: newPage }) => { + write('index.tsx', "import { mount } from '@geastack/core'; import { App } from './components/App'; mount(App)") + const parent = (label) => `import { ReactiveComponent } from '@geastack/core' + import { Child } from './Child' + export class App extends ReactiveComponent { + count = 0 + template() { return
} + }` + const child = (label) => `export function Child({ value }) { return ${label}: {value} }` + write('components/App.tsx', parent('parent')) + write('components/Child.tsx', child('child-before')) + const server = await start() + const page = await newPage() + await page.goto(server.url) + await expect(page.locator('.child')).toHaveText('child-before: 0') + await page.locator('.parent').click() + await page.evaluate(() => { window.sentinel = 'preserved'; window.oldButton = document.querySelector('.parent') }) + write('components/Child.tsx', child('child-after')) + await expect(page.locator('.child')).toHaveText('child-after: 1') + await expect(page.locator('.parent')).toHaveText('parent: 1') + write('components/App.tsx', parent('updated-parent')) + await expect(page.locator('.parent')).toHaveText('updated-parent: 1') + await expect(page.locator('.child')).toHaveText('child-after: 1') + await page.locator('.parent').click() + await expect(page.locator('.parent')).toHaveText('updated-parent: 2') + assert.equal(await page.evaluate(() => window.oldButton.textContent), 'parent: 1', 'detached bindings still subscribed') + write('components/Child.tsx', child('child-final')) + await expect(page.locator('.child')).toHaveText('child-final: 2') + assert.equal(await page.evaluate(() => window.sentinel), 'preserved') + write('components/Child.tsx', 'export function Child() { return ') + await expect(page.locator('vite-error-overlay')).toHaveCount(1) + write('components/Child.tsx', child('recovered')) + await expect(page.locator('.child')).toHaveText('recovered: 2') + await expect(page.locator('vite-error-overlay')).toHaveCount(0) + assert.equal(await page.evaluate(() => window.sentinel), 'preserved') + console.log('nested HMR preserves parent state, disposes old bindings, and recovers from syntax errors') }) diff --git a/targets/web/test/dom-emulator.browser.test.mjs b/targets/web/test/dom-emulator.browser.test.mjs new file mode 100644 index 0000000..b10fb3f --- /dev/null +++ b/targets/web/test/dom-emulator.browser.test.mjs @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict' +import { readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import { expect } from '@playwright/test' +import { withWebFixture } from './web-test-helpers.mjs' + +await withWebFixture('dom-emulator', async ({ write, start, page: newPage, build }) => { + write('index.html', '
') + write('entry.tsx', "import { mount } from '@geastack/core'; import { App } from '@app'; import './style.css'; mount(App)") + // The legacy config must never enter this web pipeline. + write('vite.config.mjs', "throw new Error('embedded config loaded')") + write('vite.web.config.mjs', `export default { + base: '/preview/', + resolve: { alias: { '@app': ${JSON.stringify('./components/App.tsx')} } }, + plugins: [{ name: 'gea-plugin', transform() { throw new Error('duplicate Gea transform') } }, { name: 'web-config-test', transformIndexHtml(html) { return html.replace('Custom HTML', 'Configured HTML') } }] + }`) + const component = (label) => `import { ReactiveComponent } from '@geastack/core' + export class App extends ReactiveComponent { + count = 0 + template() { return } + }` + write('components/App.tsx', component('before')) + write('style.css', '.probe { color: rgb(255, 0, 0); }') + const server = await start(['--emulator', '--width', '320', '--height', '480', '--dpr', '2']) + const page = await newPage() + const requests = [] + page.on('request', (req) => requests.push(req.url())) + await page.goto(`${server.url}/preview/__gea_emulator`) + const app = page.frameLocator('iframe') + await expect(app.locator('aside')).toHaveText('Configured HTML') + await expect(app.locator('.probe')).toHaveCSS('color', 'rgb(255, 0, 0)') + const frame = page.frames().find((frame) => frame !== page.mainFrame()) + assert.deepEqual(await frame.evaluate(() => [innerWidth, innerHeight, __gea_Display.getDevicePixelRatio()]), [320, 480, 2]) + assert.deepEqual(await frame.evaluate(() => [__gea_Camera.isAvailable(), __gea_Camera.open(), __gea_Camera.deviceCount]), [false, false, 0]) + await app.locator('.probe').click() + await frame.evaluate(() => { window.sentinel = 'same-app' }) + await page.evaluate(() => { window.sentinel = 'same-shell' }) + write('style.css', '.probe { color: rgb(0, 0, 255); }') + await expect(app.locator('.probe')).toHaveCSS('color', 'rgb(0, 0, 255)') + for (const label of ['after', 'again', 'final']) { + write('components/App.tsx', component(label)) + await expect(app.locator('.probe')).toHaveText(`${label}: 1`) + } + await app.locator('.probe').click() + await expect(app.locator('.probe')).toHaveText('final: 2') + assert.equal(await frame.evaluate(() => window.sentinel), 'same-app') + assert.equal(await page.evaluate(() => window.sentinel), 'same-shell') + await page.locator('#width').fill('400') + await page.locator('#width').dispatchEvent('change') + await page.locator('#zoom').fill('0.5') + await page.locator('#zoom').dispatchEvent('change') + await page.locator('#dpr').fill('3') + await page.locator('#dpr').dispatchEvent('change') + await expect.poll(() => frame.evaluate(() => [innerWidth, __gea_Display.width, __gea_Display.getDevicePixelRatio()])).toEqual([400, 400, 3]) + await expect(page.locator('#device')).toHaveCSS('width', '200px') + assert.equal(await frame.evaluate(() => window.sentinel), 'same-app') + assert.equal(requests.some((url) => /\.wasm(?:$|\?)/.test(url)), false) + const out = await build() + const html = readFileSync(join(out, 'index.html'), 'utf8') + assert.match(html, /Configured HTML/) + assert.match(html, /\/preview\/assets\//) + assert.match(html, /name="custom" content="preserved"/) + const assets = readdirSync(join(out, 'assets')) + assert.ok(assets.some((name) => name.endsWith('.css'))) + const output = html + assets.filter((name) => name.endsWith('.js')).map((name) => readFileSync(join(out, 'assets', name), 'utf8')).join('') + assert.doesNotMatch(output, /@vite\/client|__gea_emulator|Gea DOM emulator|\.wasm/) + console.log('DOM emulator: custom HTML/config, CSS and component HMR, viewport, production isolation passed') +}) diff --git a/targets/web/test/web-test-helpers.mjs b/targets/web/test/web-test-helpers.mjs index 97fb027..dc66e8e 100644 --- a/targets/web/test/web-test-helpers.mjs +++ b/targets/web/test/web-test-helpers.mjs @@ -75,9 +75,9 @@ export async function withWebFixture(name, run) { await run({ appDir, write(file, source) { writeFileSync(join(appDir, file), source) }, - async start() { + async start(args = []) { const port = await freePort() - const child = launch('dev-web.mjs', ['--app-dir', appDir, '--host', '127.0.0.1', '--port', String(port)]) + const child = launch('dev-web.mjs', ['--app-dir', appDir, '--host', '127.0.0.1', '--port', String(port), ...args]) children.push(child) const url = `http://127.0.0.1:${port}` try { From 7a09443e7bf5fdb8a0053c01c911131ad9677c04 Mon Sep 17 00:00:00 2001 From: Tamay Eser Uysal Date: Thu, 24 Sep 2026 13:39:40 +0200 Subject: [PATCH 2/3] Use DOM production build in target-script documentation --- docs/ARCHITECTURE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9eac9c9..0f7b66c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -59,7 +59,7 @@ For target-script work: ```sh targets/web/dev-web.mjs -targets/web/build-web.sh +targets/web/build-dom-web.mjs ``` Use a small representative app such as `watch`, then a heavier rendering app From 1713ea45336599880178a6889d6505e6e2bb8a11 Mon Sep 17 00:00:00 2001 From: Tamay Eser Uysal Date: Fri, 25 Sep 2026 00:39:35 +0200 Subject: [PATCH 3/3] Pace browser test edits to avoid dropped Linux watcher events --- .../test/dev-web-hmr-guard.browser.test.mjs | 20 +++++++++---------- .../web/test/dom-emulator.browser.test.mjs | 6 +++--- .../dom-web-dotenv-defines.browser.test.mjs | 6 +++--- targets/web/test/web-test-helpers.mjs | 8 ++++++++ 4 files changed, 24 insertions(+), 16 deletions(-) diff --git a/targets/web/test/dev-web-hmr-guard.browser.test.mjs b/targets/web/test/dev-web-hmr-guard.browser.test.mjs index 286e2d5..c5ab114 100644 --- a/targets/web/test/dev-web-hmr-guard.browser.test.mjs +++ b/targets/web/test/dev-web-hmr-guard.browser.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import { expect } from '@playwright/test' import { fetchText, waitFor, withWebFixture } from './web-test-helpers.mjs' -await withWebFixture('hmr-app', async ({ write, start, page: newPage }) => { +await withWebFixture('hmr-app', async ({ write, edit, start, page: newPage }) => { write('index.tsx', "import { mount } from '@geastack/core'\nimport { App } from './components/App'\nmount(App)\n") const reactive = (label) => `import { ReactiveComponent } from '@geastack/core' export class App extends ReactiveComponent { @@ -20,7 +20,7 @@ export class App extends ReactiveComponent { await page.locator('.count').click() await expect(page.locator('.count')).toHaveText('before: 1') await page.evaluate(() => { window.hmrSentinel = 'same-page' }) - write('components/App.tsx', reactive('after')) + await edit('components/App.tsx', reactive('after')) await expect(page.locator('.count')).toHaveText('after: 1') assert.equal(await page.evaluate(() => window.hmrSentinel), 'same-page', 'reactive edit reloaded the page') await page.locator('.count').click() @@ -28,19 +28,19 @@ export class App extends ReactiveComponent { // A runtime-base change must reload, while subsequent static edits patch. const staticComponent = (label) => `export function App() { return

${label}

}` - write('components/App.tsx', staticComponent('static-before')) + await edit('components/App.tsx', staticComponent('static-before')) await waitFor(async () => (await fetchText(`${server.url}/components/App.tsx`)).includes('static-before')) await expect(page.locator('.static')).toHaveText('static-before') assert.equal(await page.evaluate(() => window.hmrSentinel), undefined, 'incompatible edit did not reload') await page.evaluate(() => { window.hmrSentinel = 'before-fallback' }) - write('components/App.tsx', staticComponent('static-after')) + await edit('components/App.tsx', staticComponent('static-after')) await expect(page.locator('.static')).toHaveText('static-after') assert.equal(await page.evaluate(() => window.hmrSentinel), 'before-fallback', 'static edit reloaded the page') assert.deepEqual(errors, []) console.log('dev-web hot updates reactive and static components') }) -await withWebFixture('nested-hmr', async ({ write, start, page: newPage }) => { +await withWebFixture('nested-hmr', async ({ write, edit, start, page: newPage }) => { write('index.tsx', "import { mount } from '@geastack/core'; import { App } from './components/App'; mount(App)") const parent = (label) => `import { ReactiveComponent } from '@geastack/core' import { Child } from './Child' @@ -57,21 +57,21 @@ await withWebFixture('nested-hmr', async ({ write, start, page: newPage }) => { await expect(page.locator('.child')).toHaveText('child-before: 0') await page.locator('.parent').click() await page.evaluate(() => { window.sentinel = 'preserved'; window.oldButton = document.querySelector('.parent') }) - write('components/Child.tsx', child('child-after')) + await edit('components/Child.tsx', child('child-after')) await expect(page.locator('.child')).toHaveText('child-after: 1') await expect(page.locator('.parent')).toHaveText('parent: 1') - write('components/App.tsx', parent('updated-parent')) + await edit('components/App.tsx', parent('updated-parent')) await expect(page.locator('.parent')).toHaveText('updated-parent: 1') await expect(page.locator('.child')).toHaveText('child-after: 1') await page.locator('.parent').click() await expect(page.locator('.parent')).toHaveText('updated-parent: 2') assert.equal(await page.evaluate(() => window.oldButton.textContent), 'parent: 1', 'detached bindings still subscribed') - write('components/Child.tsx', child('child-final')) + await edit('components/Child.tsx', child('child-final')) await expect(page.locator('.child')).toHaveText('child-final: 2') assert.equal(await page.evaluate(() => window.sentinel), 'preserved') - write('components/Child.tsx', 'export function Child() { return ') + await edit('components/Child.tsx', 'export function Child() { return ') await expect(page.locator('vite-error-overlay')).toHaveCount(1) - write('components/Child.tsx', child('recovered')) + await edit('components/Child.tsx', child('recovered')) await expect(page.locator('.child')).toHaveText('recovered: 2') await expect(page.locator('vite-error-overlay')).toHaveCount(0) assert.equal(await page.evaluate(() => window.sentinel), 'preserved') diff --git a/targets/web/test/dom-emulator.browser.test.mjs b/targets/web/test/dom-emulator.browser.test.mjs index b10fb3f..9036d89 100644 --- a/targets/web/test/dom-emulator.browser.test.mjs +++ b/targets/web/test/dom-emulator.browser.test.mjs @@ -4,7 +4,7 @@ import { join } from 'node:path' import { expect } from '@playwright/test' import { withWebFixture } from './web-test-helpers.mjs' -await withWebFixture('dom-emulator', async ({ write, start, page: newPage, build }) => { +await withWebFixture('dom-emulator', async ({ write, edit, start, page: newPage, build }) => { write('index.html', '
') write('entry.tsx', "import { mount } from '@geastack/core'; import { App } from '@app'; import './style.css'; mount(App)") // The legacy config must never enter this web pipeline. @@ -35,10 +35,10 @@ await withWebFixture('dom-emulator', async ({ write, start, page: newPage, build await app.locator('.probe').click() await frame.evaluate(() => { window.sentinel = 'same-app' }) await page.evaluate(() => { window.sentinel = 'same-shell' }) - write('style.css', '.probe { color: rgb(0, 0, 255); }') + await edit('style.css', '.probe { color: rgb(0, 0, 255); }') await expect(app.locator('.probe')).toHaveCSS('color', 'rgb(0, 0, 255)') for (const label of ['after', 'again', 'final']) { - write('components/App.tsx', component(label)) + await edit('components/App.tsx', component(label)) await expect(app.locator('.probe')).toHaveText(`${label}: 1`) } await app.locator('.probe').click() diff --git a/targets/web/test/dom-web-dotenv-defines.browser.test.mjs b/targets/web/test/dom-web-dotenv-defines.browser.test.mjs index 2a55eb5..bc02aa0 100644 --- a/targets/web/test/dom-web-dotenv-defines.browser.test.mjs +++ b/targets/web/test/dom-web-dotenv-defines.browser.test.mjs @@ -4,7 +4,7 @@ import { join } from 'node:path' import { expect } from '@playwright/test' import { fetchText, waitFor, withWebFixture } from './web-test-helpers.mjs' -await withWebFixture('dotenv-app', async ({ write, start, build, page: newPage }) => { +await withWebFixture('dotenv-app', async ({ write, edit, start, build, page: newPage }) => { const env = (value) => `GEA_TEST_GREETING=${value}\nGEA_TEST_UNREFERENCED=unreferenced-secret\n` write('.env', env('before-review')) write('.env.example', 'GEA_TEST_GREETING=\nGEA_TEST_OPTIONAL=\n') @@ -38,10 +38,10 @@ export function App() { assert.ok(!envCode.includes('GEA_TEST_')) assert.ok(!envCode.includes('before-review')) - write('.env', env('after-review')) + await edit('.env', env('after-review')) await expect(page.locator('.greeting')).toHaveText(expected('after-review')) await expect(page.locator('.greeting')).toHaveAttribute('data-contract', 'undefined') - write('.env.example', 'GEA_TEST_GREETING=\nGEA_TEST_OPTIONAL=\nGEA_TEST_ADDED=\n') + await edit('.env.example', 'GEA_TEST_GREETING=\nGEA_TEST_OPTIONAL=\nGEA_TEST_ADDED=\n') await expect(page.locator('.greeting')).toHaveAttribute('data-contract', '') await waitFor(async () => (await fetchText(`${server.url}/values.ts`)).includes('after-review')) assert.ok(!(await fetchText(`${server.url}/components/App.tsx`)).includes('unreferenced-secret')) diff --git a/targets/web/test/web-test-helpers.mjs b/targets/web/test/web-test-helpers.mjs index dc66e8e..e3e0188 100644 --- a/targets/web/test/web-test-helpers.mjs +++ b/targets/web/test/web-test-helpers.mjs @@ -75,6 +75,14 @@ export async function withWebFixture(name, run) { await run({ appDir, write(file, source) { writeFileSync(join(appDir, file), source) }, + async edit(file, source) { + // Vite's watcher suppresses same-file change events for 50 ms. DOM + // updates and error overlays can appear sooner, so an immediate next + // save gets dropped on Linux. Keep live edits outside that window; + // synchronous write() is for fixture setup before the server starts. + await delay(100) + writeFileSync(join(appDir, file), source) + }, async start(args = []) { const port = await freePort() const child = launch('dev-web.mjs', ['--app-dir', appDir, '--host', '127.0.0.1', '--port', String(port), ...args])