orca/tests/e2e/cross-version-wire/release-checkout.ts

233 lines
7.8 KiB
TypeScript

import { execFileSync } from 'node:child_process'
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
renameSync,
rmSync,
writeFileSync
} from 'node:fs'
import { dirname, join, relative, resolve } from 'node:path'
export const REPO_ROOT = resolve(import.meta.dirname, '..', '..', '..')
const CACHE_ROOT = join(REPO_ROOT, 'tests', 'e2e', '.cross-version-checkouts')
// Bump when extraction or the alias rewrite changes so cached trees are rebuilt.
const CHECKOUT_FORMAT = 1
// Why: the wire endpoints only need the runtime RPC host, the renderer client, and
// the shared codec. Skipping cli/relay keeps a cold CI extraction a few seconds.
const ARCHIVE_PATHS = ['src/main', 'src/shared', 'src/preload', 'src/renderer', 'src/types']
const BASELINE_REF_ENV = 'ORCA_CROSS_VERSION_BASELINE_REF'
export type ReleaseCheckout = {
/** The ref as requested, e.g. `v1.4.169`. */
ref: string
/** Resolved commit the tree was extracted from. */
commit: string
/** Directory name under the cache root; also the dynamic-import path segment. */
label: string
/** Absolute path to the extracted checkout root (contains `src/`). */
root: string
}
function git(args: string[]): string {
return execFileSync('git', args, {
cwd: REPO_ROOT,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
}).trim()
}
function compareReleaseTags(a: string, b: string): number {
const parts = (tag: string): number[] =>
tag
.replace(/^v/, '')
.split('.')
.map((part) => Number.parseInt(part, 10))
.map((value) => (Number.isFinite(value) ? value : 0))
const left = parts(a)
const right = parts(b)
for (let index = 0; index < Math.max(left.length, right.length); index++) {
const diff = (left[index] ?? 0) - (right[index] ?? 0)
if (diff !== 0) {
return diff
}
}
return 0
}
/**
* The version point the harness pairs current code against. An explicit
* {@link BASELINE_REF_ENV} wins; otherwise the newest non-prerelease `v*` tag.
*
* Throws rather than skipping: a cross-version lane that quietly runs nothing is
* the exact failure this harness exists to prevent.
*/
export function resolveBaselineReleaseRef(): string {
const override = process.env[BASELINE_REF_ENV]?.trim()
if (override) {
return override
}
let tags: string[]
try {
tags = git(['tag', '--list', 'v[0-9]*']).split('\n').filter(Boolean)
} catch (error) {
throw new Error(
`Cross-version harness could not list git tags in ${REPO_ROOT}: ${String(error)}. ` +
`Run it inside a git checkout, or pin a ref with ${BASELINE_REF_ENV}.`
)
}
const releases = tags.filter((tag) => !tag.includes('-')).sort(compareReleaseTags)
const latest = releases.at(-1)
if (!latest) {
throw new Error(
`Cross-version harness found no release tags matching v[0-9]* (saw ${tags.length} tag(s) total). ` +
'CI checkouts default to a shallow clone with no tags: use `actions/checkout` with `fetch-depth: 0`, ' +
`or pin a ref with ${BASELINE_REF_ENV}.`
)
}
return latest
}
function resolveCommit(ref: string): string {
try {
return git(['rev-parse', `${ref}^{commit}`])
} catch (error) {
throw new Error(
`Cross-version harness could not resolve ref "${ref}" to a commit: ${String(error)}. ` +
'The ref must exist locally; a shallow CI clone needs `fetch-depth: 0`.'
)
}
}
function isRewritableSource(name: string): boolean {
return name.endsWith('.ts') || name.endsWith('.tsx')
}
function isTestSource(name: string): boolean {
return /\.(test|bench|spec)\.(ts|tsx)$/.test(name)
}
const ALIAS_SPECIFIER =
/(\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*)(['"])@(renderer)?\/([^'"]+)\2/g
/**
* The extracted tree is imported directly, so `@/…` must resolve inside that tree.
* Vite's alias is global and points at the working tree, which would silently run
* current renderer code inside the "old" client. Rewrite to relative paths instead.
*/
function rewriteRendererAliases(file: string, rendererRoot: string): boolean {
const source = readFileSync(file, 'utf8')
if (!source.includes("'@/") && !source.includes('"@/') && !source.includes('@renderer/')) {
return false
}
const rewritten = source.replace(
ALIAS_SPECIFIER,
(_match, keyword: string, quote: string, _renderer: string | undefined, target: string) => {
const absolute = join(rendererRoot, target)
let relativePath = relative(dirname(file), absolute).split('\\').join('/')
if (!relativePath.startsWith('.')) {
relativePath = `./${relativePath}`
}
return `${keyword}${quote}${relativePath}${quote}`
}
)
if (rewritten === source) {
return false
}
writeFileSync(file, rewritten)
return true
}
function prepareExtractedTree(root: string): { rewritten: number; pruned: number } {
const rendererRoot = join(root, 'src', 'renderer', 'src')
let rewritten = 0
let pruned = 0
const walk = (directory: string): void => {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const full = join(directory, entry.name)
if (entry.isDirectory()) {
walk(full)
continue
}
if (!entry.isFile()) {
continue
}
// Why: the old tree is imported, never collected. Dropping its tests keeps the
// cache small and keeps stale specs out of every repo-wide tool's file walk.
if (isTestSource(entry.name)) {
rmSync(full)
pruned++
continue
}
if (isRewritableSource(entry.name) && rewriteRendererAliases(full, rendererRoot)) {
rewritten++
}
}
}
walk(join(root, 'src'))
return { rewritten, pruned }
}
type CheckoutStamp = { commit: string; format: number }
function readStamp(root: string): CheckoutStamp | null {
try {
return JSON.parse(readFileSync(join(root, 'checkout-stamp.json'), 'utf8')) as CheckoutStamp
} catch {
return null
}
}
/**
* Extract `src/` at `ref` into a cached, gitignored checkout the test can import.
* Cached by resolved commit, so a moved tag or a bumped rewrite format re-extracts.
*/
export function materializeReleaseCheckout(ref: string): ReleaseCheckout {
const commit = resolveCommit(ref)
const label = ref.replace(/[^A-Za-z0-9._-]/g, '_')
const root = join(CACHE_ROOT, label)
const stamp = readStamp(root)
if (stamp?.commit === commit && stamp.format === CHECKOUT_FORMAT) {
return { ref, commit, label, root }
}
mkdirSync(CACHE_ROOT, { recursive: true })
const staging = join(CACHE_ROOT, `.staging-${label}-${process.pid}`)
rmSync(staging, { recursive: true, force: true })
mkdirSync(staging, { recursive: true })
try {
// `git archive | tar -x` keeps the extraction independent of the working tree,
// so an injected violation in the working tree cannot leak into the old side.
execFileSync(
'sh',
['-c', `git archive ${commit} ${ARCHIVE_PATHS.join(' ')} | tar -x -C "${staging}"`],
{ cwd: REPO_ROOT, stdio: ['ignore', 'ignore', 'pipe'] }
)
prepareExtractedTree(staging)
writeFileSync(
join(staging, 'checkout-stamp.json'),
`${JSON.stringify({ commit, format: CHECKOUT_FORMAT } satisfies CheckoutStamp, null, 2)}\n`
)
rmSync(root, { recursive: true, force: true })
renameSync(staging, root)
} catch (error) {
rmSync(staging, { recursive: true, force: true })
if (readStamp(root)?.commit === commit) {
return { ref, commit, label, root }
}
throw new Error(`Cross-version harness failed to extract ${ref} (${commit}): ${String(error)}`)
}
if (!existsSync(join(root, 'src', 'shared', 'terminal-stream-protocol.ts'))) {
throw new Error(
`Cross-version checkout for ${ref} is missing the terminal stream protocol; ` +
'the wire surface moved and the harness needs updating.'
)
}
return { ref, commit, label, root }
}