mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
* feat(mobile-web): add the Phase A bootstrap web source A peer of src/ so the root workspace owns it and mobile's separate lockfile stays out of packaging. Four assets across four content types, enough to exercise multi-asset manifest handling rather than assume it. The page reads buildId from manifest.json at runtime: buildId hashes the asset list that index.html belongs to, so injecting it into a hashed asset would make that asset's hash depend on itself. Registered as a fourth typecheck project; without it the entry would be the only TypeScript in a release path that tsc never sees. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(build): build and verify the mobile web bundle from the root workspace Root esbuild over mobile-web/ into out/mobile-web/, content-addressed as assets/<sha256>.<ext> with index.html the only stable name. buildId is the sha256 of the canonical serialization of the sorted asset list, so it is a pure function of content and usable as a cache key with no further reasoning. The verifier builds twice into scratch dirs and compares: a timestamp, an absolute path, or an unstable ordering fails the build when someone introduces it, not the first time a phone gets a spurious cache miss. It also enforces the Phase A budget of 16 assets and 256 KiB, separate from the permanent contract ceiling. build:release does not call build:desktop, so build:mobile-web is wired into build:desktop, build:release, and build:release:parallel. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(packaging): fail the release when the mobile web bundle is missing or stale electron-builder only warns about a missing input, so without a beforePack guard a release ships an app that advertises the bundle capability and then errors on every request. The hash check, not the existence check, is what catches a half-written or stale out/. The source tree is excluded from app.asar; out/mobile-web ships inside it under the existing out rules, exactly as out/web does. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile-web): narrow the manifest with `in` instead of a cast The changed-code casting gate rejects assertions, and `in` narrows the same untrusted JSON without one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile-web): move the bundle source under src/ so the root guard passes .github/scripts/check-root-directory-entries.mjs blocks any new top-level entry by name, so mobile-web/ could not live at the root. The source is excluded from app.asar by the existing '!src{,/**/*}' rule; the explicit '!src/mobile-web{,/**/*}' entry stays as a marker. out/mobile-web is unaffected and still ships under the out rules like out/web. No tsconfig includes src/**, so node, web, cli, and relay do not pick the tree up; it is registered as a knip entry so audit:dead-code does not call it unused. buildId is unchanged at 9d78435e: the builder hashes content, not paths. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(build): resolve the entry-script guard through pathToFileURL `file://${process.argv[1]}` never equals import.meta.url on Windows, where that url is file:///C:/... So the builder exited 0 having written nothing and the Windows packaging job failed later, at the guard, with no clue why. Every other script in config/scripts already uses pathToFileURL; this one now does too, via an exported predicate a posix runner can exercise with a win32 path. The verify script had no entry guard at all, so importing its budget constants ran the whole verification — including its process.exit — inside the test worker. It is now a function behind the same guard. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(ci): build the mobile web bundle in the PR package job That job assembles packaging inputs step by step instead of calling build:release, so the new beforePack guard hard-failed it. The census test added here is the oracle: it walks every workflow job that invokes electron-builder without --prepackaged (which short-circuits doPack before beforePack) and requires a bundle-producing script in the same job. It goes red on exactly pr.yml's package job when this step is removed. Ten jobs covered; the other nine already ran build:release, build:release:parallel, or build:desktop. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile-web): pin source line endings, because CRLF changes the buildId Every text byte under src/mobile-web is hashed into an asset digest and from there into buildId, so a CRLF checkout produces a different bundle id for the same commit: 91af2897 instead of 9d78435e. That would make a Windows-built desktop disagree with a mac-built one about which bundle a phone has cached. .gitattributes pins eol=lf for the text sources and -text for the PNG, matching the four trees already pinned for byte-hashing. The verify script asserts no source file carries a CR, so the build fails if the pin ever stops applying rather than silently shipping a second bundle identity. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(build): read the test's own path from import.meta.filename oxlint unicorn/prefer-import-meta-properties. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(test): census packaging jobs over raw workflow text, not re-serialized YAML yaml.stringify folds long lines, and in dev-channel-win-build.yml's build-win the fold landed between `electron-builder` and `--config`, so a real packaging job was invisible to the census: 11 jobs exist, the test saw 10. Slice each job's raw source by its parsed boundaries instead, and pin the inventory so a new packaging workflow has to be added here on purpose. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(build): assert the script chain the packaging census trusts The census only checks that a packaging job invokes one of ten build scripts; that those scripts still reach build:mobile-web was asserted nowhere, so a dropped link would leave every job looking covered while packaging failed at beforePack. Resolve each script for real, and pin pr.yml's hand-rolled step, since that job never calls build:release. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(build): realpath the entry path before the direct-invocation compare Node resolves symlinks in import.meta.url but not in argv[1], so `node /tmp/...` against a /private/tmp realpath compared two different strings: the builder and the verifier exited 0 having written and checked nothing. Same silent-success shape as the Windows file:// bug, so the fix sits next to it, with both seams injectable. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile-web): format bootstrap.css with oxfmt It was the only tracked CSS failing oxfmt --check. The buildId is unchanged at 9d78435e8bb73c3341f833c20aaefbd7bfdfc414b68dadf87c1689d86728fe33, because esbuild's CSS minifier normalises the whitespace this touches before the asset is hashed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(packaging): reject bundle files the manifest does not list The guard only walked the manifest, so a dropped assets/stale.js passed: assets are content-addressed, nothing ever overwrites a stale copy, and it would ship inside asar unreachable and unverified. Require every file under out/mobile-web to be the manifest or a listed asset. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(packaging): give beforePack an explicit mobile web bundle root The bundle guard read the repo's out/mobile-web unconditionally, so the two arch-aware packaging tests that call the real beforePack went red in the unit-test job, which never runs build:mobile-web. beforePack now takes the bundle root as a second parameter defaulting to out/mobile-web, which is what electron-builder gets, and those tests build a real bundle into a temp dir instead. The guard is neither skipped nor made tolerant of a missing bundle. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(packaging): census sees script-wrapped packers; dev verify reuses the guard The workflow census only matched a literal `electron-builder --config` line, so daemon-relocation-spike's `pnpm run build:unpack` (which packs and runs beforePack) was invisible to it. Jobs now count when any `pnpm run <script>` they invoke chains to electron-builder without --prepackaged; the spike joins the pinned list (12 jobs). verify-mobile-web-bundle.mjs re-implemented a weaker subset of the packaging guard (no safe-path check, no buildId recompute). It now calls assertMobileWebBundleBuilt, so a manifest edited after the build fails at `pnpm build:mobile-web` exactly as at beforePack. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
262 lines
9.9 KiB
JavaScript
262 lines
9.9 KiB
JavaScript
import { execFileSync } from 'node:child_process'
|
|
import { createHash } from 'node:crypto'
|
|
import { mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
buildMobileWebBundle,
|
|
computeMobileWebBundleBuildId,
|
|
isDirectInvocation,
|
|
serializeMobileWebBundleAssets
|
|
} from './build-mobile-web-bundle.mjs'
|
|
import {
|
|
MOBILE_WEB_BUNDLE_PHASE_A_MAX_ASSETS,
|
|
MOBILE_WEB_BUNDLE_PHASE_A_MAX_TOTAL_BYTES,
|
|
assertNoCarriageReturnsInSource
|
|
} from './verify-mobile-web-bundle.mjs'
|
|
|
|
async function buildIntoScratch() {
|
|
const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-build-'))
|
|
const bundleDir = join(scratch, 'mobile-web')
|
|
const { manifest } = await buildMobileWebBundle({ outDir: bundleDir })
|
|
return { scratch, bundleDir, manifest }
|
|
}
|
|
|
|
describe('buildMobileWebBundle', () => {
|
|
it('emits a content-addressed bundle whose only stable name is the entrypoint', async () => {
|
|
const { scratch, bundleDir, manifest } = await buildIntoScratch()
|
|
try {
|
|
const root = await readdir(bundleDir)
|
|
expect(root.sort()).toEqual(['assets', 'index.html', 'manifest.json'])
|
|
for (const name of await readdir(join(bundleDir, 'assets'))) {
|
|
const [digest, extension] = name.split('.')
|
|
expect(digest).toMatch(/^[0-9a-f]{64}$/)
|
|
const bytes = await readFile(join(bundleDir, 'assets', name))
|
|
expect(createHash('sha256').update(bytes).digest('hex')).toBe(digest)
|
|
expect(extension).toMatch(/^(js|css|png)$/)
|
|
}
|
|
const html = await readFile(join(bundleDir, 'index.html'), 'utf8')
|
|
for (const asset of manifest.assets) {
|
|
if (asset.path !== 'index.html') {
|
|
expect(html).toContain(asset.path)
|
|
}
|
|
}
|
|
expect(html).not.toContain('__ORCA_')
|
|
} finally {
|
|
await rm(scratch, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('carries every manifest field the Phase A contract names', async () => {
|
|
const { scratch, manifest } = await buildIntoScratch()
|
|
try {
|
|
expect(Object.keys(manifest)).toEqual([
|
|
'schemaVersion',
|
|
'buildId',
|
|
'desktopVersion',
|
|
'minCompatibleRuntimeProtocolVersion',
|
|
'runtimeProtocolVersion',
|
|
'entrypoint',
|
|
'totalBytes',
|
|
'assets'
|
|
])
|
|
expect(manifest.schemaVersion).toBe(1)
|
|
expect(manifest.entrypoint).toBe('index.html')
|
|
const packageJson = JSON.parse(
|
|
await readFile(new URL('../../package.json', import.meta.url), 'utf8')
|
|
)
|
|
expect(manifest.desktopVersion).toBe(packageJson.version)
|
|
const protocolSource = await readFile(
|
|
new URL('../../src/shared/protocol-version.ts', import.meta.url),
|
|
'utf8'
|
|
)
|
|
expect(protocolSource).toContain(
|
|
`export const RUNTIME_PROTOCOL_VERSION = ${String(manifest.runtimeProtocolVersion)}`
|
|
)
|
|
expect(protocolSource).toContain(
|
|
`export const MIN_COMPATIBLE_RUNTIME_SERVER_VERSION = ${String(manifest.minCompatibleRuntimeProtocolVersion)}`
|
|
)
|
|
expect(manifest.totalBytes).toBe(
|
|
manifest.assets.reduce((total, asset) => total + asset.byteLength, 0)
|
|
)
|
|
} finally {
|
|
await rm(scratch, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('produces the same buildId from two independent builds', async () => {
|
|
const first = await buildIntoScratch()
|
|
const second = await buildIntoScratch()
|
|
try {
|
|
expect(second.manifest.buildId).toBe(first.manifest.buildId)
|
|
expect(second.manifest).toEqual(first.manifest)
|
|
} finally {
|
|
await rm(first.scratch, { recursive: true, force: true })
|
|
await rm(second.scratch, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('embeds no absolute path from the machine that built it', async () => {
|
|
const { scratch, bundleDir } = await buildIntoScratch()
|
|
try {
|
|
const names = [
|
|
'index.html',
|
|
'manifest.json',
|
|
...(await readdir(join(bundleDir, 'assets'))).map((name) => join('assets', name))
|
|
]
|
|
for (const name of names) {
|
|
const text = (await readFile(join(bundleDir, name))).toString('latin1')
|
|
expect(text).not.toContain(scratch)
|
|
expect(text).not.toContain(process.cwd())
|
|
}
|
|
} finally {
|
|
await rm(scratch, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('stays inside the Phase A budget', async () => {
|
|
const { scratch, manifest } = await buildIntoScratch()
|
|
try {
|
|
expect(manifest.assets.length).toBeLessThanOrEqual(MOBILE_WEB_BUNDLE_PHASE_A_MAX_ASSETS)
|
|
expect(manifest.totalBytes).toBeLessThanOrEqual(MOBILE_WEB_BUNDLE_PHASE_A_MAX_TOTAL_BYTES)
|
|
} finally {
|
|
await rm(scratch, { recursive: true, force: true })
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('computeMobileWebBundleBuildId', () => {
|
|
const assets = [
|
|
{ path: 'index.html', sha256: 'a'.repeat(64), byteLength: 3, contentType: 'text/html' },
|
|
{ path: 'assets/b.js', sha256: 'b'.repeat(64), byteLength: 5, contentType: 'text/javascript' }
|
|
]
|
|
|
|
it('sorts by path, so input order cannot change the id', () => {
|
|
expect(computeMobileWebBundleBuildId(assets.toReversed())).toBe(
|
|
computeMobileWebBundleBuildId(assets)
|
|
)
|
|
})
|
|
|
|
it('serializes a fixed key order regardless of the input object key order', () => {
|
|
const reordered = assets.map(({ contentType, byteLength, sha256, path }) => ({
|
|
contentType,
|
|
byteLength,
|
|
sha256,
|
|
path
|
|
}))
|
|
expect(serializeMobileWebBundleAssets(reordered)).toBe(serializeMobileWebBundleAssets(assets))
|
|
})
|
|
|
|
it('changes when any hashed field changes', () => {
|
|
const baseline = computeMobileWebBundleBuildId(assets)
|
|
for (const field of ['sha256', 'byteLength', 'contentType', 'path']) {
|
|
const mutated = assets.map((asset, index) =>
|
|
index === 0 ? { ...asset, [field]: field === 'byteLength' ? 4 : `${asset[field]}x` } : asset
|
|
)
|
|
expect(computeMobileWebBundleBuildId(mutated)).not.toBe(baseline)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('isDirectInvocation', () => {
|
|
const thisFile = import.meta.filename
|
|
|
|
it('matches the path this module was loaded from', () => {
|
|
expect(isDirectInvocation(import.meta.url, thisFile)).toBe(true)
|
|
})
|
|
|
|
it('does not match a different script', () => {
|
|
expect(isDirectInvocation(import.meta.url, join(thisFile, '..', 'other.mjs'))).toBe(false)
|
|
})
|
|
|
|
it('tolerates an absent argv[1]', () => {
|
|
expect(isDirectInvocation(import.meta.url, undefined)).toBe(false)
|
|
expect(isDirectInvocation(import.meta.url, '')).toBe(false)
|
|
})
|
|
|
|
// Why an injected converter: a win32 path cannot be exercised through node:url's pathToFileURL
|
|
// on a posix runner, and CI is ubuntu.
|
|
const toWin32FileUrl = (windowsPath) => new URL(`file:///${windowsPath.replaceAll('\\', '/')}`)
|
|
|
|
it('matches a Windows entry path, which the file:// template form never does', () => {
|
|
const scriptPath = 'C:\\orca\\config\\scripts\\build-mobile-web-bundle.mjs'
|
|
const moduleUrl = 'file:///C:/orca/config/scripts/build-mobile-web-bundle.mjs'
|
|
const keepAsIs = (path) => path
|
|
expect(
|
|
isDirectInvocation(moduleUrl, scriptPath, {
|
|
toFileUrl: toWin32FileUrl,
|
|
realpath: keepAsIs
|
|
})
|
|
).toBe(true)
|
|
// The regression this guards: `file://${argv[1]}` yields file://C:\orca\... on Windows,
|
|
// so the builder exited 0 having written nothing and packaging failed downstream.
|
|
expect(`file://${scriptPath}`).not.toBe(moduleUrl)
|
|
})
|
|
|
|
it('is not written with the file:// template form', async () => {
|
|
const source = await readFile(new URL('./build-mobile-web-bundle.mjs', import.meta.url), 'utf8')
|
|
expect(source).not.toMatch(/file:\/\/\$\{process\.argv\[1\]\}/)
|
|
expect(source).toContain('pathToFileURL')
|
|
})
|
|
})
|
|
|
|
describe('mobile web source line endings', () => {
|
|
it('accepts the committed source tree', async () => {
|
|
await expect(assertNoCarriageReturnsInSource()).resolves.toBeUndefined()
|
|
})
|
|
|
|
it('rejects a CRLF source file, because CRLF changes every asset hash and the buildId', async () => {
|
|
const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-eol-'))
|
|
try {
|
|
await writeFile(join(scratch, 'bootstrap.ts'), 'const a = 1\r\nconst b = 2\r\n', 'utf8')
|
|
await expect(assertNoCarriageReturnsInSource(scratch)).rejects.toThrow(
|
|
/CRLF in mobile web source/
|
|
)
|
|
} finally {
|
|
await rm(scratch, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('pins eol=lf for every committed text source and -text for the binary', () => {
|
|
const files = execFileSync('git', ['ls-files', 'src/mobile-web'], { encoding: 'utf8' })
|
|
.split('\n')
|
|
.filter(Boolean)
|
|
expect(files.length).toBeGreaterThanOrEqual(4)
|
|
for (const file of files) {
|
|
const attributes = execFileSync('git', ['check-attr', 'text', 'eol', '--', file], {
|
|
encoding: 'utf8'
|
|
})
|
|
if (file.endsWith('.png')) {
|
|
expect(attributes).toContain('text: unset')
|
|
} else {
|
|
expect(attributes).toContain('eol: lf')
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('running the builder through a symlink', () => {
|
|
// Node resolves symlinks in import.meta.url but not in argv[1]. Before the guard realpath'd the
|
|
// entry path, `node /tmp/<link>` compared /tmp against /private/tmp and the builder exited 0
|
|
// having written nothing — a green packaging job with no bundle in it.
|
|
it('still recognises the entry module', async () => {
|
|
const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-link-'))
|
|
try {
|
|
const builderUrl = new URL('./build-mobile-web-bundle.mjs', import.meta.url).href
|
|
const real = join(scratch, 'entry.mjs')
|
|
await writeFile(
|
|
real,
|
|
`import { isDirectInvocation } from ${JSON.stringify(builderUrl)}\n` +
|
|
'process.stdout.write(String(isDirectInvocation(import.meta.url, process.argv[1])))\n',
|
|
'utf8'
|
|
)
|
|
const link = join(scratch, 'entry-link.mjs')
|
|
await symlink(real, link)
|
|
expect(execFileSync(process.execPath, [link], { encoding: 'utf8' })).toBe('true')
|
|
} finally {
|
|
await rm(scratch, { recursive: true, force: true })
|
|
}
|
|
})
|
|
})
|