mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
perf(dev): avoid pairing web rebuilds after test file changes (#29838)
* perf(dev): keep pairing web bundle after test file changes * fix(dev): detect web input replacement within one timestamp tick * test(dev): use real files for web replacement coverage --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -1,20 +1,31 @@
|
||||
import { readdirSync, statSync } from 'node:fs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const SNAPSHOT_FILENAME = '.orca-dev-web-inputs.json'
|
||||
const INPUT_PATHS = ['vite.web.config.ts', 'src/renderer', 'src/shared', 'src/preload/api-types.ts']
|
||||
|
||||
const TEST_SOURCE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/
|
||||
|
||||
function latestSourceMtimeMs(targetPath) {
|
||||
function collectInputs(targetPath, relativePath, files) {
|
||||
let stat
|
||||
try {
|
||||
stat = statSync(targetPath)
|
||||
stat = statSync(targetPath, { bigint: true })
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
const mtimeMs = Number(stat.mtimeNs) / 1e6
|
||||
if (!stat.isDirectory()) {
|
||||
return stat.mtimeMs
|
||||
files.push([
|
||||
relativePath,
|
||||
...[stat.mtimeNs, stat.ctimeNs, stat.dev, stat.ino, stat.size].map(String)
|
||||
])
|
||||
return mtimeMs
|
||||
}
|
||||
let latest = stat.mtimeMs
|
||||
for (const entry of readdirSync(targetPath, { withFileTypes: true })) {
|
||||
let latest = mtimeMs
|
||||
for (const entry of readdirSync(targetPath, { withFileTypes: true }).sort((a, b) =>
|
||||
a.name < b.name ? -1 : a.name > b.name ? 1 : 0
|
||||
)) {
|
||||
if (
|
||||
entry.name === 'node_modules' ||
|
||||
entry.name.startsWith('.') ||
|
||||
@@ -22,27 +33,63 @@ function latestSourceMtimeMs(targetPath) {
|
||||
) {
|
||||
continue
|
||||
}
|
||||
latest = Math.max(latest, latestSourceMtimeMs(join(targetPath, entry.name)))
|
||||
latest = Math.max(
|
||||
latest,
|
||||
collectInputs(join(targetPath, entry.name), join(relativePath, entry.name), files)
|
||||
)
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
export function isDevWebClientFresh(root) {
|
||||
let outputMtime
|
||||
export function captureDevWebClientInputs(root) {
|
||||
const files = []
|
||||
const latestMtime = Math.max(
|
||||
...INPUT_PATHS.map((input) => collectInputs(join(root, input), input, files))
|
||||
)
|
||||
return {
|
||||
signature: createHash('sha256').update(JSON.stringify(files)).digest('hex'),
|
||||
latestMtime
|
||||
}
|
||||
}
|
||||
|
||||
function outputIdentity(root) {
|
||||
const stat = statSync(join(root, 'out', 'web', 'web-index.html'))
|
||||
return [stat.mtimeMs, stat.size]
|
||||
}
|
||||
|
||||
export function recordDevWebClientInputs(root, inputs) {
|
||||
try {
|
||||
outputMtime = statSync(join(root, 'out', 'web', 'web-index.html')).mtimeMs
|
||||
writeFileSync(
|
||||
join(root, 'out', 'web', SNAPSHOT_FILENAME),
|
||||
JSON.stringify({ version: 1, output: outputIdentity(root), signature: inputs.signature })
|
||||
)
|
||||
} catch {
|
||||
// A missing cache must never prevent dev startup.
|
||||
}
|
||||
}
|
||||
|
||||
export function isDevWebClientFresh(root) {
|
||||
let output
|
||||
try {
|
||||
output = outputIdentity(root)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (outputMtime === 0) {
|
||||
if (output[0] === 0) {
|
||||
return false
|
||||
}
|
||||
// Directory timestamps still invalidate added/deleted sources conservatively.
|
||||
const sourceMtime = Math.max(
|
||||
latestSourceMtimeMs(join(root, 'vite.web.config.ts')),
|
||||
latestSourceMtimeMs(join(root, 'src', 'renderer')),
|
||||
latestSourceMtimeMs(join(root, 'src', 'shared')),
|
||||
latestSourceMtimeMs(join(root, 'src', 'preload', 'api-types.ts'))
|
||||
)
|
||||
return sourceMtime <= outputMtime
|
||||
const inputs = captureDevWebClientInputs(root)
|
||||
try {
|
||||
const previous = JSON.parse(readFileSync(join(root, 'out', 'web', SNAPSHOT_FILENAME), 'utf8'))
|
||||
if (previous.version === 1 && JSON.stringify(previous.output) === JSON.stringify(output)) {
|
||||
return previous.signature === inputs.signature
|
||||
}
|
||||
} catch {
|
||||
// Existing standalone and projected builds can be adopted without rebuilding.
|
||||
}
|
||||
if (inputs.latestMtime > output[0]) {
|
||||
return false
|
||||
}
|
||||
recordDevWebClientInputs(root, inputs)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs'
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
utimesSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { isDevWebClientFresh } from './dev-web-client-freshness.mjs'
|
||||
import {
|
||||
captureDevWebClientInputs,
|
||||
isDevWebClientFresh,
|
||||
recordDevWebClientInputs
|
||||
} from './dev-web-client-freshness.mjs'
|
||||
|
||||
const roots = []
|
||||
const OLD = new Date(Date.now() - 120_000)
|
||||
@@ -74,6 +86,7 @@ it.each([
|
||||
'vite.web.config.ts'
|
||||
])('rebuilds when %s changes', (file) => {
|
||||
const root = fixture()
|
||||
expect(isDevWebClientFresh(root)).toBe(true)
|
||||
utimesSync(join(root, file), EDITED, EDITED)
|
||||
expect(isDevWebClientFresh(root)).toBe(false)
|
||||
})
|
||||
@@ -89,3 +102,120 @@ it.each(['add', 'delete'])('rebuilds after a production source %s', (operation)
|
||||
utimesSync(dirname(file), EDITED, EDITED)
|
||||
expect(isDevWebClientFresh(root)).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['test.ts', 'spec.tsx', 'test.mjs', 'spec.cts'])(
|
||||
'keeps a tracked bundle fresh after adding and deleting a %s file',
|
||||
(suffix) => {
|
||||
const root = fixture()
|
||||
expect(isDevWebClientFresh(root)).toBe(true)
|
||||
const file = join(root, `src/renderer/src/new.${suffix}`)
|
||||
writeFileSync(file, '')
|
||||
utimesSync(dirname(file), EDITED, EDITED)
|
||||
expect(isDevWebClientFresh(root)).toBe(true)
|
||||
rmSync(file)
|
||||
expect(isDevWebClientFresh(root)).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it('ignores adding and removing a directory containing only tests', () => {
|
||||
const root = fixture()
|
||||
expect(isDevWebClientFresh(root)).toBe(true)
|
||||
const directory = join(root, 'src/shared/new-tests')
|
||||
mkdirSync(directory)
|
||||
writeFileSync(join(directory, 'value.test.ts'), '')
|
||||
expect(isDevWebClientFresh(root)).toBe(true)
|
||||
rmSync(directory, { recursive: true })
|
||||
expect(isDevWebClientFresh(root)).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['add', 'delete', 'rename'])(
|
||||
'invalidates a tracked bundle after a production source %s with old timestamps',
|
||||
(operation) => {
|
||||
const root = fixture()
|
||||
expect(isDevWebClientFresh(root)).toBe(true)
|
||||
if (operation !== 'add') {
|
||||
rmSync(join(root, 'src/renderer/src/app.tsx'))
|
||||
}
|
||||
if (operation !== 'delete') {
|
||||
const file = join(root, 'src/renderer/src/added.tsx')
|
||||
writeFileSync(file, '')
|
||||
utimesSync(file, OLD, OLD)
|
||||
}
|
||||
utimesSync(join(root, 'src/renderer/src'), OLD, OLD)
|
||||
expect(isDevWebClientFresh(root)).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it('invalidates a tracked bundle when a source is removed in a nested directory', () => {
|
||||
const root = fixture()
|
||||
const directory = join(root, 'src/shared/nested')
|
||||
mkdirSync(directory)
|
||||
writeFileSync(join(directory, 'value.ts'), '')
|
||||
utimesSync(join(directory, 'value.ts'), OLD, OLD)
|
||||
utimesSync(directory, OLD, OLD)
|
||||
utimesSync(dirname(directory), OLD, OLD)
|
||||
expect(isDevWebClientFresh(root)).toBe(true)
|
||||
rmSync(directory, { recursive: true })
|
||||
utimesSync(dirname(directory), OLD, OLD)
|
||||
expect(isDevWebClientFresh(root)).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['missing', 'corrupt', 'version', 'output'])(
|
||||
'falls back conservatively when the inventory is %s',
|
||||
(state) => {
|
||||
const root = fixture()
|
||||
expect(isDevWebClientFresh(root)).toBe(true)
|
||||
const marker = join(root, 'out/web/.orca-dev-web-inputs.json')
|
||||
if (state === 'missing') {
|
||||
rmSync(marker)
|
||||
}
|
||||
if (state === 'corrupt') {
|
||||
writeFileSync(marker, '{')
|
||||
}
|
||||
if (state === 'version') {
|
||||
writeFileSync(marker, JSON.stringify({ version: 0 }))
|
||||
}
|
||||
if (state === 'output') {
|
||||
utimesSync(join(root, 'out/web/web-index.html'), OLD, OLD)
|
||||
}
|
||||
rmSync(join(root, 'src/renderer/src/app.tsx'))
|
||||
utimesSync(join(root, 'src/renderer/src'), EDITED, EDITED)
|
||||
expect(isDevWebClientFresh(root)).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it('adopts a fresh replacement output without reusing its previous inventory', () => {
|
||||
const root = fixture()
|
||||
expect(isDevWebClientFresh(root)).toBe(true)
|
||||
writeFileSync(join(root, 'src/renderer/src/app.tsx'), 'updated')
|
||||
utimesSync(join(root, 'src/renderer/src/app.tsx'), BUILT, BUILT)
|
||||
utimesSync(join(root, 'out/web/web-index.html'), EDITED, EDITED)
|
||||
expect(isDevWebClientFresh(root)).toBe(true)
|
||||
})
|
||||
|
||||
it('retains the inputs from before a build so concurrent edits invalidate its output', () => {
|
||||
const root = fixture()
|
||||
const inputs = captureDevWebClientInputs(root)
|
||||
writeFileSync(join(root, 'src/shared/value.ts'), 'changed while building')
|
||||
utimesSync(join(root, 'out/web/web-index.html'), EDITED, EDITED)
|
||||
recordDevWebClientInputs(root, inputs)
|
||||
expect(isDevWebClientFresh(root)).toBe(false)
|
||||
})
|
||||
|
||||
it('detects same-size atomic replacement with a restored mtime', () => {
|
||||
const root = fixture()
|
||||
const file = join(root, 'src/shared/value.ts')
|
||||
const replacement = join(root, 'replacement.ts')
|
||||
expect(isDevWebClientFresh(root)).toBe(true)
|
||||
writeFileSync(replacement, '')
|
||||
utimesSync(replacement, OLD, OLD)
|
||||
const originalIdentity = statSync(file, { bigint: true })
|
||||
const replacementIdentity = statSync(replacement, { bigint: true })
|
||||
expect([replacementIdentity.dev, replacementIdentity.ino]).not.toEqual([
|
||||
originalIdentity.dev,
|
||||
originalIdentity.ino
|
||||
])
|
||||
renameSync(replacement, file)
|
||||
utimesSync(dirname(file), OLD, OLD)
|
||||
expect(isDevWebClientFresh(root)).toBe(false)
|
||||
})
|
||||
|
||||
@@ -24,7 +24,11 @@ import {
|
||||
selectStaleDevBundleDirs
|
||||
} from './dev-electron-bundle-cache.mjs'
|
||||
import { startDevStorageReclaim } from './dev-storage-reclaim.mjs'
|
||||
import { isDevWebClientFresh } from './dev-web-client-freshness.mjs'
|
||||
import {
|
||||
captureDevWebClientInputs,
|
||||
isDevWebClientFresh,
|
||||
recordDevWebClientInputs
|
||||
} from './dev-web-client-freshness.mjs'
|
||||
import { copyPrivateTree } from './space-sharing-copy.mjs'
|
||||
import {
|
||||
DEV_BUNDLE_ID,
|
||||
@@ -510,6 +514,7 @@ function prepareDevWebClient() {
|
||||
if (isDevWebClientFresh(repoRoot)) {
|
||||
return
|
||||
}
|
||||
const inputs = captureDevWebClientInputs(repoRoot)
|
||||
console.error('[orca-dev] Building web client for pairing...')
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
@@ -520,6 +525,7 @@ function prepareDevWebClient() {
|
||||
env: process.env
|
||||
}
|
||||
)
|
||||
recordDevWebClientInputs(repoRoot, inputs)
|
||||
}
|
||||
|
||||
// Why: every `pn dev` should be attachable from agent-browser/playwright-cli
|
||||
|
||||
Reference in New Issue
Block a user