Ctrl K
ComunidadGuías…

bldr/e2e/frontend-hmr.spec.ts

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import assert from 'node:assert/strict'
import { spawn, type ChildProcess } from 'node:child_process'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { createServer } from 'node:net'
import { dirname, join, relative, resolve } from 'node:path'
import { createInterface } from 'node:readline'
import { fileURLToPath } from 'node:url'
import { expect, test, type Page } from '@playwright/test'

// Keep ordinary E2E collection safe until the isolated CLI is explicitly supplied.
test.skip(
  !process.env.BLDR_FRONTEND_TEST_BINARY,
  'Opt-in: build the Bldr CLI, set BLDR_FRONTEND_TEST_BINARY, and run bun run playwright test --config bldr/e2e/frontend-hmr.config.ts',
)

/** Canonical contains the singleton imports provided by the document. */
type Canonical = {
  React: typeof import('react')
  jsx: typeof import('react/jsx-runtime').jsx
  WebDocument: typeof import('@aptre/bldr').WebDocument
  BldrContext: typeof import('@aptre/bldr-react').BldrContext
}

/** Mounted adds the runtime document and view received through Bldr context. */
type Mounted = Canonical & {
  webDocument: import('@aptre/bldr').WebDocument
  webView: import('@aptre/bldr').WebView
  sharedIdentity?: boolean
}

declare global {
  var __hmrCanonical: Canonical
  var __hmrMounted: Mounted
  var __hmrShared: Mounted
  var __hmrInitial: Mounted | undefined
  var __hmrObservation: Promise<void>
  var __frontendDocument: number | undefined
}

const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..')

/** identities records imports and runtime objects used by the mounted fixture. */
const identities = `import React from 'react'
import { jsx } from 'react/jsx-runtime'
import { WebDocument } from '@aptre/bldr'
import { BldrContext, useBldrContext } from '@aptre/bldr-react'
`
const appSource = `${identities}
import Shared from '@s4wave/web/hmr-fixture'
import SameShared from './Shared.tsx'
import './app.css'
export default function App() {
  // Retain the application's counter through compatible updates.
  const [count, setCount] = React.useState(0)

  // Retain the draft independently of the counter.
  const [text, setText] = React.useState('')

  // Read the mounted document and view from their owning context.
  const context = useBldrContext()

  // Record the imports and runtime objects for identity comparisons.
  globalThis.__hmrMounted = { React, jsx, WebDocument, BldrContext, ...context, sharedIdentity: Shared === SameShared }

  // Render editable content and the shared component in the mounted view.
  return <main>
    <h1>HMR before</h1>
    <button id="hmr-count" onClick={() => setCount(count + 1)}>Count {count}</button>
    <input aria-label="Draft" value={text} onChange={event => setText(event.target.value)} />
    <Shared />
  </main>
}
`
const sharedSource = `${identities}
export default function Shared() {
  const [count, setCount] = React.useState(0)
  const context = useBldrContext()
  globalThis.__hmrShared = { React, jsx, WebDocument, BldrContext, ...context }
  return <section><p>Shared before</p><button id="hmr-shared-count" onClick={() => setCount(count + 1)}>Shared count {count}</button></section>
}
`
const cssSource = '#hmr-count { color: rgb(255, 0, 0); }\n'
const startupSource = `${identities}
import { WebView } from '@aptre/bldr-react'
export default function Startup() {
  globalThis.__hmrCanonical = { React, jsx, WebDocument, BldrContext }
  return <WebView />
}
`

/** FrontendFixture owns one ordinary Bldr CLI and disposable project source. */
class FrontendFixture {
  readonly lines: string[] = []
  private native?: ChildProcess
  private closed?: Promise<void>
  origin = ''

  constructor(readonly directory: string) {}

  /** start waits for the CLI's listener announcement and collects its output. */
  async start(): Promise<void> {
    // Create the same manifest-loaded application used by the existing smoke.
    const source = relative(root, this.directory).replaceAll('\\', '/')
    const config = `import config from '../../vite.config.ts'
import { resolve } from 'node:path'
export default {
  ...config,
  resolve: { ...config.resolve, alias: [
    { find: '@s4wave/web/hmr-fixture', replacement: resolve(${JSON.stringify(this.directory)}, 'Shared.tsx') },
    ...config.resolve.alias,
  ] },
  server: { watch: { ignored: ['**/state/**', '**/.bldr*/**', '**/vendor/**', '**/*.log'] } },
}
`
    await Promise.all([
      writeFile(join(this.directory, 'App.tsx'), appSource),
      writeFile(join(this.directory, 'Shared.tsx'), sharedSource),
      writeFile(join(this.directory, 'Startup.tsx'), startupSource),
      writeFile(join(this.directory, 'app.css'), cssSource),
      writeFile(join(this.directory, 'vite.config.ts'), config),
      writeFile(
        join(this.directory, 'bldr.star'),
        `
project(id="frontend-hmr-check", start=start_config(plugins=["web", "hmr-app"], loadWebStartup=${JSON.stringify(source + '/Startup.tsx')}))
manifest("web", builder="bldr/web/plugin/compiler", config=web_plugin_compiler_config())
manifest("hmr-app", builder="bldr/plugin/compiler/js", config=js_plugin_config(webPluginId="web", viteConfigPaths=[${JSON.stringify(source + '/vite.config.ts')}], viteDisableProjectConfig=True, modules=[js_module("JS_MODULE_KIND_FRONTEND", ${JSON.stringify(source + '/App.tsx')}, entrypoint=True, webViewParentId={"empty": True})]))
build("web", manifests=["web", "hmr-app"], targets=["browser"])
`,
      ),
    ])

    // Reserve an isolated listener address before spawning the task's CLI.
    const reservation = createServer()
    await new Promise<void>((ready) =>
      reservation.listen(0, '127.0.0.1', ready),
    )
    const address = reservation.address()
    assert(address && typeof address !== 'string')
    this.origin = `http://127.0.0.1:${address.port}`
    await new Promise<void>((closed) => reservation.close(() => closed()))
    const binary = process.env.BLDR_FRONTEND_TEST_BINARY
    assert(
      binary,
      'Build the CLI and set BLDR_FRONTEND_TEST_BINARY before running this test',
    )

    // Observe native output, including real bundle builds and publications.
    const native = (this.native = spawn(
      resolve(binary),
      [
        `--bldr-src-path=${root}`,
        `--state-path=${join(this.directory, 'state')}`,
        `--config=${join(this.directory, 'bldr.yaml')}`,
        '--log-level=debug',
        '--no-tui',
        '--minify-entrypoint=false',
        'start',
        'web',
        `--listen=127.0.0.1:${address.port}`,
      ],
      { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] },
    ))
    this.closed = new Promise((done) => native.once('close', () => done()))
    const output = createInterface({ input: native.stdout! })
    const errors = createInterface({ input: native.stderr! })
    const ready = new Promise<void>((done, reject) => {
      // Fail readiness if the fixture process cannot start or exits early.
      native.once('error', reject)
      native.once('exit', (code, signal) =>
        reject(new Error(`Bldr exited before readiness: ${code}/${signal}`)),
      )
      const receive = (line: string) => {
        this.lines.push(line)
        if (line.includes('listening on:')) done()
      }
      output.on('line', receive)
      errors.on('line', receive)
    })
    await ready
  }

  /** operations reads actual builder invocations and manifest commits. */
  operations() {
    return {
      viteBundles: this.lines.filter((line) =>
        /building vite bundle\b/.test(line),
      ).length,
      esbuildBundles: this.lines.filter((line) =>
        /building esbuild bundle\b/.test(line),
      ).length,
      webPkgBundles: this.lines.filter((line) =>
        line.includes('building web pkg bundle with vite'),
      ).length,
      rolldownBundles: this.lines.filter(
        (line) =>
          line.includes('running command:') && line.includes('/run-build.mjs'),
      ).length,
      publications: this.lines.filter((line) =>
        line.includes('committing manifest to world'),
      ).length,
    }
  }

  /** close joins the fixture CLI and persists diagnostics before deleting data. */
  async close(reportPath: string): Promise<void> {
    // End only the process started by this fixture; its pipe owns its compilers.
    if (this.native?.exitCode === null && this.native.signalCode === null) {
      this.native.kill('SIGINT')
    }

    // Join closed stdio even when the process already exited or failed to spawn.
    await this.closed

    // Keep native evidence outside the disposable project and remove its state.
    await writeFile(reportPath, this.lines.join('\n') + '\n')
    await rm(this.directory, { recursive: true, force: true })
  }
}

/** observeEdit measures a source write through a DOM mutation, without polling. */
async function observeEdit(
  page: Page,
  path: string,
  source: string,
  expected: { selector: string; text?: string; color?: string },
): Promise<number> {
  // Subscribe before the write, so even a fast compiler update is observed.
  await page.evaluate((expected) => {
    const matches = () => {
      const element = document.querySelector(expected.selector)
      return (
        !!element &&
        (expected.text === undefined ||
          element.textContent === expected.text) &&
        (expected.color === undefined ||
          getComputedStyle(element).color === expected.color)
      )
    }
    globalThis.__hmrObservation = new Promise<void>((done) => {
      const observer = new MutationObserver(() => {
        if (!matches()) return
        observer.disconnect()
        done()
      })
      observer.observe(document, {
        subtree: true,
        childList: true,
        characterData: true,
        attributes: true,
      })
    })
  }, expected)

  // Report edit-to-observed-update; startup and compositor paint are separate.
  const started = performance.now()
  await writeFile(path, source)
  await page.evaluate(() => globalThis.__hmrObservation)
  return Math.round(performance.now() - started)
}

/** snapshot verifies import identities and reads Bldr's real backend generations. */
async function snapshot(page: Page) {
  return page.evaluate(async () => {
    // Compare editable consumers with the canonical imports used by startup.
    const mounted = globalThis.__hmrMounted
    const shared = globalThis.__hmrShared
    const canonical = globalThis.__hmrCanonical
    const keys = ['React', 'jsx', 'WebDocument', 'BldrContext'] as const
    const identities =
      keys.every(
        (key) =>
          mounted[key] === canonical[key] && shared[key] === canonical[key],
      ) &&
      mounted.sharedIdentity &&
      shared.webDocument === mounted.webDocument &&
      shared.webView === mounted.webView &&
      mounted.webDocument instanceof canonical.WebDocument

    // Save runtime objects once, then compare after each compatible update.
    const initial = (globalThis.__hmrInitial ??= mounted)
    const status = await mounted.webDocument.buildWebDocumentStatusSnapshot()
    return {
      identities,
      canonicalRetained: keys.every((key) => initial[key] === mounted[key]),
      documentRetained: initial.webDocument === mounted.webDocument,
      viewRetained: initial.webView === mounted.webView,
      backends: status
        .webWorkers!.map((worker) => ({
          id: worker.id,
          generation: worker.generation,
          ready: worker.ready,
        }))
        .sort((a, b) => (a.id ?? '').localeCompare(b.id ?? '')),
      documentID: globalThis.__frontendDocument,
    }
  })
}

// TIER: opt-in. Run with frontend-hmr.config.ts and a freshly built CLI.
test('manifest frontend preserves state and identities across compatible edits', async ({
  page,
}, testInfo) => {
  // Allocate isolated project data and collect real runtime transport evidence.
  await mkdir(join(root, '.tmp'), { recursive: true })
  const fixture = new FrontendFixture(
    await mkdtemp(join(root, '.tmp/frontend-hmr-')),
  )

  // Capture browser errors, backend creation, and frontend transport traffic.
  const pageErrors: string[] = []
  const modules: string[] = []
  const responses: {
    url: string
    fromServiceWorker: boolean
    status: number
  }[] = []
  const sockets: string[] = []
  let workersCreated = 0
  page.on('pageerror', (error) => pageErrors.push(error.message))
  page.on('worker', () => workersCreated++)

  // Record browser transport endpoints without accessing private compiler URLs.
  page.on('websocket', (socket) => sockets.push(socket.url()))
  page.on('request', (request) => {
    if (request.url().includes('/b/fe/')) modules.push(request.url())
  })
  page.on('response', (response) => {
    if (response.url().includes('/b/fe/')) {
      responses.push({
        url: response.url(),
        fromServiceWorker: response.fromServiceWorker(),
        status: response.status(),
      })
    }
  })

  // Keep the document marker independent of application module replacement.
  await page.addInitScript(() => {
    globalThis.__frontendDocument = Math.random()
  })

  // Measure startup independently of the warm source edits.
  const startup = performance.now()
  const edits: {
    file: string
    before: string
    after: string
    editToObservedUpdateMs: number
    operations: ReturnType<FrontendFixture['operations']>
  }[] = []
  let initialOperations: ReturnType<FrontendFixture['operations']>
  try {
    await fixture.start()
    await page.goto(fixture.origin, { waitUntil: 'domcontentloaded' })
    await page.locator('#hmr-count').waitFor({ timeout: 60_000 })
    const startupMs = Math.round(performance.now() - startup)
    await page.locator('#hmr-count').click({ clickCount: 2 })
    await page.locator('#hmr-shared-count').click({ clickCount: 3 })
    await page.getByRole('textbox', { name: 'Draft' }).fill('Retained draft')
    const initial = await snapshot(page)
    const initialWorkers = workersCreated
    initialOperations = fixture.operations()
    expect(initial.identities).toBe(true)
    expect(initial.backends.length).toBeGreaterThan(0)
    expect(initial.backends.every((backend) => backend.ready)).toBe(true)
    expect(initialOperations.publications).toBeGreaterThan(0)
    expect(initialOperations.webPkgBundles).toBeGreaterThan(0)
    expect(initialOperations.rolldownBundles).toBeGreaterThan(0)
    expect(
      await page
        .locator('#hmr-count')
        .evaluate((element) => getComputedStyle(element).color),
    ).toBe('rgb(255, 0, 0)')

    // Edit CSS, the mounted application, and its shared UI through the same graph.
    const cases = [
      {
        file: 'app.css',
        before: cssSource,
        after: '#hmr-count { color: rgb(0, 0, 255); }\n',
        expected: { selector: '#hmr-count', color: 'rgb(0, 0, 255)' },
      },
      {
        file: 'App.tsx',
        before: appSource,
        after: appSource.replace('HMR before', 'HMR after'),
        expected: { selector: 'h1', text: 'HMR after' },
      },
      {
        file: 'Shared.tsx',
        before: sharedSource,
        after: sharedSource.replace('Shared before', 'Shared after'),
        expected: { selector: 'section p', text: 'Shared after' },
      },
    ]
    for (const edit of cases) {
      // Observe the accepted render and compare the real operation counters.
      const elapsed = await observeEdit(
        page,
        join(fixture.directory, edit.file),
        edit.after,
        edit.expected,
      )
      const operations = fixture.operations()
      edits.push({
        file: edit.file,
        before: edit.before,
        after: edit.after,
        editToObservedUpdateMs: elapsed,
        operations: {
          viteBundles: operations.viteBundles - initialOperations.viteBundles,
          esbuildBundles:
            operations.esbuildBundles - initialOperations.esbuildBundles,
          webPkgBundles:
            operations.webPkgBundles - initialOperations.webPkgBundles,
          rolldownBundles:
            operations.rolldownBundles - initialOperations.rolldownBundles,
          publications:
            operations.publications - initialOperations.publications,
        },
      })
      expect(operations).toEqual(initialOperations)
      expect(await snapshot(page)).toEqual(initial)
      expect(workersCreated).toBe(initialWorkers)
      expect(await page.locator('#hmr-count').textContent()).toBe('Count 2')
      expect(await page.locator('#hmr-shared-count').textContent()).toBe(
        'Shared count 3',
      )
      expect(
        await page.getByRole('textbox', { name: 'Draft' }).inputValue(),
      ).toBe('Retained draft')
    }

    // Prove that browser modules use Bldr Fetch and the devtool socket.
    expect(
      await page.evaluate(() => !!navigator.serviceWorker.controller),
    ).toBe(true)
    expect(modules.some((url) => /App\.tsx\?t=/.test(url))).toBe(true)
    expect(modules.some((url) => /Shared\.tsx\?t=/.test(url))).toBe(true)
    expect(modules.every((url) => new URL(url).origin === fixture.origin)).toBe(
      true,
    )
    expect(responses.length).toBeGreaterThan(0)
    expect(
      responses.every(
        (response) => response.fromServiceWorker && response.status === 200,
      ),
    ).toBe(true)
    expect(sockets.length).toBeGreaterThan(0)
    expect(
      sockets.every(
        (url) => new URL(url).host === new URL(fixture.origin).host,
      ),
    ).toBe(true)
    expect(pageErrors).toEqual([])

    // Retain source identities, startup, updates, and calibrated operation counts.
    const evidence = {
      browser: testInfo.project.name,
      startupMs,
      initial,
      initialWorkers,
      initialOperations,
      edits,
      modules,
      responses,
      sockets,
    }
    console.log(testInfo.project.name, {
      startupMs,
      edits: edits.map((edit) => ({
        file: edit.file,
        editToObservedUpdateMs: edit.editToObservedUpdateMs,
        operations: edit.operations,
      })),
    })
    await writeFile(
      testInfo.outputPath('observations.json'),
      JSON.stringify(evidence, null, 2) + '\n',
    )
  } finally {
    await page.close()
    await fixture.close(testInfo.outputPath('native.log'))
  }

  // Include drained output in the assertion against late builds or publications.
  expect(fixture.operations()).toEqual(initialOperations)
})