Files
orca/config/scripts/run-node-server-tests.mjs
T
Jinwoo Hong d3dbd9683f Update Orca's skills with Orca's own installer instead of npx skills (#31688)
* Add Orca's own bundled-skill planner and installer (not wired in yet)

One planner decides what installing Orca's bundled skills does to each
target folder; execute re-plans under per-path locks and runs only a plan
whose digest still matches; a shared summary derives badge, wording and
exit code. A copy counts as Orca's untouched one only when its SKILL.md
bytes match a version some build shipped (resources/skills/released-digests.json,
generated from git history and checked add-only).

Nothing calls this yet; the npx path is unchanged.

* Report left-alone folders as warnings and keep backups in the home

A leave is no longer a failure: the outcome line says what happened plus
"N left alone: <reason>", and the exit code is 1 only for a failed or
changed op. Backups for every scope go to <home>/.agents/.orca-skill-backups
on the executing host, so a project Replace never writes into the repo;
moving there falls back to copy, verify, then remove across filesystems.

* Update Orca's skills with Orca's own installer instead of npx skills

The app's Update skills dialog, badge, nudge and status pills now read the
bundled-skill plan and summary, and Update executes the reviewed plan's
digest. `orca skills install|update` runs the same planner in-process with
--dry-run, --force, --json and per-summary exit codes. Removes both npx
spawn sites, the freshness inventory modules and the npx round-trip CI.

* Update only where the skill already is; one provider-root rule for app and CLI

installedOnly now drops every location that holds nothing (keeping the shared slot
only when an existing agent link points at it), so Update never adds a skill for
another agent. The app and CLI resolve the Claude skills folder through the same
rule in localBundledSkillInput; a test asserts both plan identically.

* Compact Update skills dialog: one row per skill, locations named by agent

summarizeBundledSkillPlan now returns each skill's locations, labelled by
agent (or "Shared folder") with a ~-collapsed path; the dialog shows one row
per skill that needs something, collapses up-to-date skills into one line, and
lists every location behind "Show all locations". The result reads "Updated N
skills · M left alone". Under installedOnly a Replace with no shared copy
replaces in place instead of adding one.

* fix(skills): keep location paths readable by putting the state under the path

* fix(skills): hold the last plan in state, not a ref mutated during render

Split the update dialog's rows, headline and footer into components, move the Update label
next to the other plan copy, and use z.strictObject for the request schemas.

* Escape non-ASCII characters in bundled-skill regex and wording

The orcad bundle rejects a literal non-ASCII character in a regex.

* fix(cli): escape non-ASCII apostrophes in the skills install help text

* test(orcad): check the bundled CLI plans a skill install without npx

Drop the deleted roundtrip workflow's group from the closed-PR cancellation fixture.

* Make bundled-skill test fixtures link folders the way the product does on Windows

Directory links in fixtures are junctions on Windows, which need no
Developer Mode; the one file-link fixture is skipped there.

* Resolve legacy skill agent keys canonically

* Reject stale skill replacements when auxiliary content changes

* Keep ordinary bundled skill classification shallow

* Leave unreadable skill replacements alone without aborting plans

* Pass force-all through shallow planning and describe unreadable copies

* Clarify skill update statuses and folder details

* Keep selected skill replacements in place with one status

* Report completed skill replacements with their backup confirmation

* Clarify available skill updates and skipped copies

* Keep skill update results in preview order

* Show each skill skip reason once

* Count skipped skills instead of skipped folders

* Make installer input and link tests work on Windows

* Bound bundled skill classification reads and parse names linearly

* Keep workspace skill roots inside the resolved workspace

* Compare workspace skill roots with native path semantics

* Restrict bundled skill backups to their owner on POSIX

* Keep recovered skills needing attention until they are updated

* Count completed skill updates in the status tooltip

* Sync the runtime localization catalog
2026-10-22 22:35:14 -04:00

154 lines
5.4 KiB
JavaScript

// Runs the headless-server suites under the pinned Node (design D4/D4a), not the host's.
import { join, resolve } from 'node:path'
import { randomUUID } from 'node:crypto'
import { globSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import {
ORCAD_CLI_ENTRY_FILENAME,
ORCAD_SERVER_ENTRY_FILENAME,
ORCAD_VERSION_FILENAME
} from '../../src/shared/orcad-artifacts.ts'
import { NODE_RUNTIME_PIN } from '../../src/shared/node-runtime-pin.ts'
import {
ORCAD_PROFILE_PREFLIGHT_FLAG,
parseOrcadProfilePreflight
} from '../../src/shared/orcad-profile-preflight.ts'
import { packagedNodeRuntimePath } from './build-orcad-node.mjs'
import { ensurePinnedNodeExecutable } from './pinned-node-downloads.mjs'
import { currentTarget } from './server-build-target.mjs'
import { UNIT_INCLUDE } from './ci-unit-files.mjs'
import { runProcessSync } from '@orca/process-host'
import { describeProcessFailure } from './process-failure-message.mjs'
import {
CROSS_RUNTIME_TEST_PATHS,
nodeServerTestPaths,
REQUIRED_TEST_INPUTS_ENV
} from './node-server-test-paths.mjs'
const root = resolve(import.meta.dirname, '../..')
const target = currentTarget()
const artifact = process.argv.includes('--artifact')
// Lanes that provide the pinned Bun and the last Bun orcad slot (design D7 upgrade and rollback).
const crossRuntime = process.argv.includes('--cross-runtime')
const testArgs = process.argv
.slice(2)
.filter((arg) => arg !== '--artifact' && arg !== '--cross-runtime')
const packageDir = join(root, 'out', 'orcad')
const runtimePath = artifact
? packagedNodeRuntimePath(packageDir, target)
: await ensurePinnedNodeExecutable({ target })
// Tests that launch the packaged runtime themselves find it here. A lane's named inputs are
// required: their tests fail on a missing input instead of passing as a skip.
const env = {
...process.env,
ORCA_BACKGROUND_LAUNCH: '1',
ORCA_PINNED_NODE: runtimePath,
[REQUIRED_TEST_INPUTS_ENV]: [artifact && 'artifact', crossRuntime && 'cross-runtime']
.filter(Boolean)
.join(',')
}
function run(program, args) {
const result = runProcessSync({
program,
args,
cwd: root,
env,
stdio: 'inherit',
timeoutMs: null
})
if (result.code !== 0) {
process.exit(result.code ?? 1)
}
}
if (artifact) {
const nonce = randomUUID()
const result = runProcessSync({
program: runtimePath,
args: [join(packageDir, ORCAD_SERVER_ENTRY_FILENAME), ORCAD_PROFILE_PREFLIGHT_FLAG, nonce],
cwd: root,
env,
timeoutMs: 90_000
})
if (result.code !== 0 || result.timedOut || result.outputTruncated) {
throw new Error(`Bundled runtime readiness failed: ${describeProcessFailure(result)}`)
}
const response = parseOrcadProfilePreflight(
result.stdout,
nonce,
{ runtime: 'node', runtimeVersion: NODE_RUNTIME_PIN.version },
readFileSync(join(packageDir, ORCAD_VERSION_FILENAME), 'utf8').trim()
)
process.stdout.write(`${JSON.stringify({ target, ...response })}\n`)
assertBundledCliPlansSkillInstall()
}
run(runtimePath, [
join(root, 'node_modules/vitest/vitest.mjs'),
'run',
'--config',
'config/vitest.config.ts',
...(testArgs.length > 0 ? testArgs : defaultTestArgs())
])
function defaultTestArgs() {
// Why: vitest 5 drops CLI --exclude for inline projects, so the selectors' substring matches
// are resolved to files here and filtered before vitest sees them.
// Electron probes run in desktop jobs; headless compatibility containers have no display.
const selectors = nodeServerTestPaths({ artifact, crossRuntime })
const skipped = new Set(crossRuntime ? [] : CROSS_RUNTIME_TEST_PATHS)
return globSync(UNIT_INCLUDE, { cwd: root })
.map((path) => path.replaceAll('\\', '/'))
.filter((path) => selectors.some((selector) => path.includes(selector)))
.filter((path) => !path.endsWith('.electron.test.ts') && !skipped.has(path))
.sort()
}
/** The server's own CLI must plan an install of Orca's bundled skills, offline and npx-free. */
function assertBundledCliPlansSkillInstall() {
// Why the real path: the planner reports real paths, and macOS tmpdir is a /var symlink.
const home = realpathSync(mkdtempSync(join(tmpdir(), 'orcad-skills-home-')))
const { ORCA_CLI_CWD: _forwarded, ...hostEnv } = env
try {
const cli = runProcessSync({
program: runtimePath,
args: [
join(packageDir, ORCAD_CLI_ENTRY_FILENAME),
'skills',
'install',
'--skill',
'orca-cli',
'--agent',
'universal',
'--dry-run',
'--json'
],
cwd: home,
env: { ...hostEnv, HOME: home, USERPROFILE: home },
timeoutMs: 90_000
})
const failure = describeProcessFailure(cli)
if (cli.code !== 0 || cli.timedOut || cli.outputTruncated) {
throw new Error(`Bundled CLI skills dry-run failed: ${failure}`)
}
const result = JSON.parse(cli.stdout)
const ops = result.plan?.skills?.find((skill) => skill.name === 'orca-cli')?.ops ?? []
if (
result.executed !== false ||
!/^[0-9a-f]{64}$/.test(result.planDigest) ||
ops.length === 0 ||
!ops.every((op) => op.path.startsWith(home))
) {
throw new Error(`Bundled CLI did not plan orca-cli into ${home}: ${cli.stdout}`)
}
if (readdirSync(home).length > 0) {
throw new Error(
`Bundled CLI skills dry-run wrote into ${home}: ${readdirSync(home).join(', ')}`
)
}
} finally {
rmSync(home, { recursive: true, force: true })
}
}