mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +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
215 lines
8.5 KiB
JavaScript
215 lines
8.5 KiB
JavaScript
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
|
import { createRequire } from 'node:module'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { buildMobileWebBundle } from './build-mobile-web-bundle.mjs'
|
|
|
|
const require = createRequire(import.meta.url)
|
|
const {
|
|
MOBILE_WEB_BUNDLE_DIR,
|
|
assertMobileWebBundleBuilt
|
|
} = require('./verify-packaged-mobile-web-bundle.cjs')
|
|
const electronBuilderConfig = require('../electron-builder.config.cjs')
|
|
const REPO_ROOT = join(import.meta.dirname, '..', '..')
|
|
|
|
async function withBundle(run) {
|
|
const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-guard-'))
|
|
const bundleDir = join(scratch, 'mobile-web')
|
|
try {
|
|
const { manifest } = await buildMobileWebBundle({ outDir: bundleDir })
|
|
await run({ bundleDir, manifest })
|
|
} finally {
|
|
await rm(scratch, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
async function rewriteManifest(bundleDir, mutate) {
|
|
const manifestPath = join(bundleDir, 'manifest.json')
|
|
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
mutate(manifest)
|
|
await writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf8')
|
|
}
|
|
|
|
describe('assertMobileWebBundleBuilt', () => {
|
|
beforeEach(() => {
|
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
})
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('accepts a freshly built bundle', async () => {
|
|
await withBundle(({ bundleDir, manifest }) => {
|
|
expect(() => assertMobileWebBundleBuilt(bundleDir)).not.toThrow()
|
|
expect(manifest.entrypoint).toBe('index.html')
|
|
expect(manifest.assets.length).toBeGreaterThanOrEqual(3)
|
|
expect(
|
|
new Set(manifest.assets.map((asset) => asset.contentType)).size
|
|
).toBeGreaterThanOrEqual(2)
|
|
})
|
|
})
|
|
|
|
it('fails on a file the manifest does not list, so no stale asset ships inside asar', async () => {
|
|
await withBundle(async ({ bundleDir }) => {
|
|
// An asset dropped from the manifest keeps its content-addressed name, so nothing ever
|
|
// overwrites it; without this check it packs unreachable and unverified.
|
|
await writeFile(join(bundleDir, 'assets', 'stale.js'), '// from an earlier build\n', 'utf8')
|
|
expect(() => assertMobileWebBundleBuilt(bundleDir)).toThrow(
|
|
/does not list: assets\/stale\.js/
|
|
)
|
|
})
|
|
})
|
|
|
|
it('accepts exactly the manifest, the entrypoint and the listed assets', async () => {
|
|
await withBundle(async ({ bundleDir, manifest }) => {
|
|
const onDisk = (await readdir(bundleDir, { recursive: true, withFileTypes: true }))
|
|
.filter((entry) => entry.isFile())
|
|
.map((entry) => join(entry.parentPath, entry.name).slice(bundleDir.length + 1))
|
|
expect(onDisk.toSorted()).toEqual(
|
|
['manifest.json', ...manifest.assets.map((asset) => asset.path)].toSorted()
|
|
)
|
|
})
|
|
})
|
|
|
|
it('fails when the manifest is missing', async () => {
|
|
const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-guard-'))
|
|
try {
|
|
expect(() => assertMobileWebBundleBuilt(scratch)).toThrow(/no bundle manifest/)
|
|
} finally {
|
|
await rm(scratch, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('fails when the manifest is not JSON', async () => {
|
|
await withBundle(async ({ bundleDir }) => {
|
|
await writeFile(join(bundleDir, 'manifest.json'), 'not json', 'utf8')
|
|
expect(() => assertMobileWebBundleBuilt(bundleDir)).toThrow(/not valid JSON/)
|
|
})
|
|
})
|
|
|
|
it('fails when an asset is tampered with on disk', async () => {
|
|
await withBundle(async ({ bundleDir, manifest }) => {
|
|
const asset = manifest.assets.find((entry) => entry.path.endsWith('.js'))
|
|
const bytes = await readFile(join(bundleDir, asset.path))
|
|
// Same length, different content: only the hash check can catch this.
|
|
bytes[bytes.length - 1] = bytes.at(-1) === 0x20 ? 0x09 : 0x20
|
|
await writeFile(join(bundleDir, asset.path), bytes)
|
|
expect(() => assertMobileWebBundleBuilt(bundleDir)).toThrow(/hashes to .* on disk/)
|
|
})
|
|
})
|
|
|
|
it('fails when an asset is truncated', async () => {
|
|
await withBundle(async ({ bundleDir, manifest }) => {
|
|
const asset = manifest.assets.find((entry) => entry.path.endsWith('.css'))
|
|
await writeFile(join(bundleDir, asset.path), 'truncated', 'utf8')
|
|
expect(() => assertMobileWebBundleBuilt(bundleDir)).toThrow(/bytes on disk, manifest says/)
|
|
})
|
|
})
|
|
|
|
it('fails when a listed asset was never written', async () => {
|
|
await withBundle(async ({ bundleDir, manifest }) => {
|
|
const asset = manifest.assets.find((entry) => entry.path.endsWith('.png'))
|
|
await rm(join(bundleDir, asset.path))
|
|
expect(() => assertMobileWebBundleBuilt(bundleDir)).toThrow(/which is missing from/)
|
|
})
|
|
})
|
|
|
|
it('fails when the manifest buildId no longer matches its asset list', async () => {
|
|
await withBundle(async ({ bundleDir }) => {
|
|
await rewriteManifest(bundleDir, (manifest) => {
|
|
manifest.buildId = 'f'.repeat(64)
|
|
})
|
|
expect(() => assertMobileWebBundleBuilt(bundleDir)).toThrow(/does not match its asset list/)
|
|
})
|
|
})
|
|
|
|
it('fails on an unknown schemaVersion', async () => {
|
|
await withBundle(async ({ bundleDir }) => {
|
|
await rewriteManifest(bundleDir, (manifest) => {
|
|
manifest.schemaVersion = 2
|
|
})
|
|
expect(() => assertMobileWebBundleBuilt(bundleDir)).toThrow(
|
|
/unsupported manifest schemaVersion/
|
|
)
|
|
})
|
|
})
|
|
|
|
it('fails when a required field is dropped', async () => {
|
|
await withBundle(async ({ bundleDir }) => {
|
|
await rewriteManifest(bundleDir, (manifest) => {
|
|
delete manifest.desktopVersion
|
|
})
|
|
expect(() => assertMobileWebBundleBuilt(bundleDir)).toThrow(/desktopVersion is missing/)
|
|
})
|
|
})
|
|
|
|
it('fails when totalBytes disagrees with the asset list', async () => {
|
|
await withBundle(async ({ bundleDir }) => {
|
|
await rewriteManifest(bundleDir, (manifest) => {
|
|
manifest.totalBytes += 1
|
|
})
|
|
expect(() => assertMobileWebBundleBuilt(bundleDir)).toThrow(/its assets sum to/)
|
|
})
|
|
})
|
|
|
|
it('refuses an asset path that escapes the bundle directory', async () => {
|
|
await withBundle(async ({ bundleDir }) => {
|
|
await rewriteManifest(bundleDir, (manifest) => {
|
|
manifest.assets[0].path = '../outside.js'
|
|
})
|
|
expect(() => assertMobileWebBundleBuilt(bundleDir)).toThrow(/not a safe relative path/)
|
|
})
|
|
})
|
|
|
|
it('refuses a manifest whose entrypoint is not one of its assets', async () => {
|
|
await withBundle(async ({ bundleDir }) => {
|
|
await rewriteManifest(bundleDir, (manifest) => {
|
|
manifest.entrypoint = 'index.html'
|
|
manifest.assets = manifest.assets.filter((asset) => asset.path !== 'index.html')
|
|
})
|
|
expect(() => assertMobileWebBundleBuilt(bundleDir)).toThrow(/is not one of its assets/)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('electron-builder packaging wiring', () => {
|
|
it('excludes the mobile-web source tree from app.asar', () => {
|
|
expect(electronBuilderConfig.files).toContain('!src/mobile-web{,/**/*}')
|
|
// The source tree lives under src/, which is excluded wholesale; the explicit entry above
|
|
// only survives as a marker, so assert the broad rule is still what does the work.
|
|
expect(electronBuilderConfig.files).toContain('!src{,/**/*}')
|
|
})
|
|
|
|
it('does not exclude the built bundle, so out/mobile-web ships like out/web', () => {
|
|
const excludesBuiltBundle = electronBuilderConfig.files.some(
|
|
(entry) => typeof entry === 'string' && entry.startsWith('!out/mobile-web')
|
|
)
|
|
expect(excludesBuiltBundle).toBe(false)
|
|
})
|
|
|
|
it('runs the bundle guard in beforePack', () => {
|
|
expect(String(electronBuilderConfig.beforePack)).toContain('assertMobileWebBundleBuilt')
|
|
})
|
|
|
|
it('defaults the bundle root to out/mobile-web when electron-builder calls it', () => {
|
|
expect(MOBILE_WEB_BUNDLE_DIR).toBe(join(REPO_ROOT, 'out', 'mobile-web'))
|
|
// electron-builder passes the context alone, so the default is what ships.
|
|
expect(electronBuilderConfig.beforePack.length).toBe(1)
|
|
})
|
|
|
|
it('verifies the bundle root it is given, not the repo one', async () => {
|
|
// The seam exists so unit tests need no built out/; it would be worthless if the root were
|
|
// accepted and then ignored.
|
|
await withBundle(async ({ bundleDir }) => {
|
|
await rm(join(bundleDir, 'manifest.json'))
|
|
expect(() =>
|
|
electronBuilderConfig.beforePack(
|
|
{ electronPlatformName: process.platform, arch: process.arch === 'arm64' ? 3 : 1 },
|
|
bundleDir
|
|
)
|
|
).toThrow(/no bundle manifest/)
|
|
})
|
|
})
|
|
})
|