diff --git a/.github/workflows/boxel-cli-publish.yml b/.github/workflows/boxel-cli-publish.yml
index 45feda51141..ec95ae13b14 100644
--- a/.github/workflows/boxel-cli-publish.yml
+++ b/.github/workflows/boxel-cli-publish.yml
@@ -66,6 +66,11 @@ jobs:
|| (inputs.confirm == '' && github.ref == 'refs/heads/main')
)
runs-on: ubuntu-latest
+ outputs:
+ # Whether this run actually published a new version to npm, so the
+ # downstream verify job can skip when the push path no-op'd (no PR,
+ # or no npm-affecting bump) instead of re-testing a stale `unstable`.
+ published: ${{ steps.publish.outputs.published }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -277,6 +282,7 @@ jobs:
'
- name: Publish to npm
+ id: publish
if: github.event_name == 'workflow_dispatch' || (steps.pr.outputs.skip != 'true' && steps.release.outputs.npmBump != 'none')
working-directory: packages/boxel-cli
env:
@@ -313,6 +319,7 @@ jobs:
# Use --no-git-checks because the workflow's commit + tag is already
# pushed; pnpm's pre-publish git-state check would otherwise complain.
pnpm publish --tag unstable --access public --provenance --no-git-checks
+ echo "published=true" >> "$GITHUB_OUTPUT"
- name: Create GitHub Release (prerelease)
if: github.event_name == 'push' && steps.pr.outputs.skip != 'true' && steps.release.outputs.npmBump != 'none' && steps.commit.outputs.tag != ''
@@ -511,3 +518,48 @@ jobs:
--title "@cardstack/boxel-cli v${VERSION}" \
--notes "Promoted from unstable. Published to npm under dist-tag \`latest\`: ${NPM_URL}"
fi
+
+ # ---------------------------------------------------------------------------
+ # Post-publish verification: install the just-published artifact from the
+ # registry (as an end user would, with npm's hoisted node_modules) and run
+ # the server-less CLI smoke — `boxel parse` over card-code fixtures plus the
+ # help/version/arg-validation checks. This is the exact layout where `boxel
+ # parse`'s glint type-check silently resolves nothing; the in-process tests
+ # never installed the package, so they couldn't catch it.
+ #
+ # Deliberately server-less: the full realm-touching suite already ran
+ # pre-merge against the identical bits (the `pnpm pack` tarball in ci.yaml's
+ # "Boxel CLI Tests"), so standing up Matrix + Postgres again on every publish
+ # would re-test the same behavior. What's genuinely new post-publish is the
+ # registry artifact + packaging + propagation — which parse (workspace mode,
+ # no realm) exercises directly.
+ verify-unstable:
+ name: Verify published unstable install
+ needs: unstable
+ if: needs.unstable.outputs.published == 'true'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ ref: main
+ - uses: ./.github/actions/init
+ - name: Smoke-test the published `unstable` artifact from npm
+ working-directory: packages/boxel-cli
+ run: NODE_NO_WARNINGS=1 node scripts/run-cli-suite.ts --source published --version unstable -- pnpm run test:cli:smoke
+
+ verify-stable:
+ name: Verify published stable install
+ needs: stable
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ ref: main
+ - uses: ./.github/actions/init
+ - name: Smoke-test the published `latest` artifact from npm
+ working-directory: packages/boxel-cli
+ run: NODE_NO_WARNINGS=1 node scripts/run-cli-suite.ts --source published --version latest -- pnpm run test:cli:smoke
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 6e7f0548465..cbf3a7e23b7 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -980,8 +980,17 @@ jobs:
# would race the tests ahead of the base realm finishing its
# initial index.
timeout 600 bash -c 'until curl -sk -o /dev/null -w "%{http_code}" https://localhost:4200/ | grep -qx 200 && curl -sk -o /dev/null -w "%{http_code}" -H "Accept: application/vnd.api+json" https://localhost:4201/base/_readiness-check | grep -qx 200; do sleep 2; done'
- - name: Run integration tests
- run: pnpm test:integration
+ # Run the integration suite against the CLI as an end user installs
+ # it: `pnpm pack` the build from the step above, `npm install` the
+ # tarball into a throwaway dir outside the monorepo (npm hoisting,
+ # not pnpm's nested layout), and drive that binary via BOXEL_CLI_BIN.
+ # This is the layout where `boxel parse`'s glint type-check silently
+ # resolves nothing — which the in-process, function-call tests could
+ # never exercise because they import command functions from src. The
+ # `test:cli:tarball` script execs `pnpm test:integration` under the
+ # installed binary, so the test-PG provisioning is unchanged.
+ - name: Run integration tests against the packed npm install
+ run: pnpm test:cli:tarball
working-directory: packages/boxel-cli
- name: Print server logs
if: ${{ !cancelled() }}
diff --git a/packages/boxel-cli/.eslintignore b/packages/boxel-cli/.eslintignore
index c0d947097bc..1b4f1ea35a9 100644
--- a/packages/boxel-cli/.eslintignore
+++ b/packages/boxel-cli/.eslintignore
@@ -6,3 +6,10 @@ bundled-test-harness/
bundled-realms/
*.tgz
*.log
+
+# Card-code fixtures for `boxel parse`. These are intentionally card
+# authoring code (inline , field decorators) and one file with
+# a deliberate type error — meant to be type-checked by glint via the
+# CLI, not linted as CLI source. eslint's TS parser chokes on the
+# template tags and the repo's no-decorators rule fires on `@field`.
+tests/fixtures/parse/
diff --git a/packages/boxel-cli/package.json b/packages/boxel-cli/package.json
index 33d1d9eaf0e..4250a19554d 100644
--- a/packages/boxel-cli/package.json
+++ b/packages/boxel-cli/package.json
@@ -94,6 +94,8 @@
"test:unit": "vitest run --exclude 'tests/integration/**'",
"test:unit-exclude-smoke": "vitest run --exclude 'tests/integration/**' --exclude 'tests/smoke.test.ts'",
"test:integration": "./tests/scripts/run-integration-with-test-pg.sh",
+ "test:cli:tarball": "NODE_NO_WARNINGS=1 node scripts/run-cli-suite.ts --source tarball -- pnpm test:integration",
+ "test:cli:smoke": "vitest run tests/integration/parse.test.ts tests/smoke.test.ts",
"test:watch": "vitest",
"version:patch": "npm version patch",
"version:minor": "npm version minor",
diff --git a/packages/boxel-cli/scripts/run-cli-suite.ts b/packages/boxel-cli/scripts/run-cli-suite.ts
new file mode 100644
index 00000000000..7c0c7e6e0ee
--- /dev/null
+++ b/packages/boxel-cli/scripts/run-cli-suite.ts
@@ -0,0 +1,197 @@
+/**
+ * Run the boxel-cli test suite against the CLI *as an end user installs
+ * it* — not against `src/`. This is the piece the in-process,
+ * function-call tests could never cover: a `boxel parse` failure that
+ * only surfaces under npm's hoisted `node_modules` layout can ship
+ * despite a fully green suite.
+ *
+ * It installs the CLI into a throwaway directory *outside* the monorepo,
+ * points `BOXEL_CLI_BIN` at the installed JS entry, and execs a test
+ * command that inherits that env. `tests/helpers/run-boxel.ts` reads
+ * `BOXEL_CLI_BIN`, so every `runBoxel(...)` call in the suite drives the
+ * installed binary. The suite itself is identical across contexts — only
+ * the thing executed changes.
+ *
+ * node scripts/run-cli-suite.ts --source tarball --
+ * node scripts/run-cli-suite.ts --source published --version 0.5.0-unstable.4 --
+ *
+ * Sources:
+ * --source tarball `pnpm pack` the current build, `npm install` the
+ * tarball. pnpm (not npm) packs because it rewrites
+ * `catalog:` / `workspace:*` specifiers to real
+ * versions exactly as `pnpm publish` would; npm
+ * would leave them literal and the install would
+ * fail. The tarball's own deps then install under
+ * npm's hoisting — the layout that breaks parse.
+ * Requires a prior `pnpm build` so dist/ and
+ * bundled-* exist to pack.
+ * --source published `npm install @cardstack/boxel-cli@` from
+ * the registry, polling for propagation. Verifies
+ * the actual shipped artifact post-release.
+ *
+ * Everything after `--` is the test command run with `BOXEL_CLI_BIN` set
+ * (e.g. `pnpm test:integration`, or `vitest run tests/integration/parse.test.ts`).
+ */
+import { execFileSync, spawnSync } from 'node:child_process';
+import {
+ existsSync,
+ mkdtempSync,
+ readdirSync,
+ rmSync,
+ writeFileSync,
+} from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join, resolve } from 'node:path';
+
+const PKG_ROOT = resolve(import.meta.dirname, '..');
+const PKG_NAME = '@cardstack/boxel-cli';
+
+interface Args {
+ source: 'tarball' | 'published';
+ version: string;
+ testCommand: string[];
+}
+
+function parseArgs(argv: string[]): Args {
+ let source: string | undefined;
+ let version = 'latest';
+ let testCommand: string[] = [];
+ for (let i = 0; i < argv.length; i++) {
+ let arg = argv[i];
+ if (arg === '--') {
+ testCommand = argv.slice(i + 1);
+ break;
+ } else if (arg === '--source') {
+ source = argv[++i];
+ } else if (arg === '--version') {
+ version = argv[++i];
+ } else {
+ throw new Error(`Unknown argument: ${arg}`);
+ }
+ }
+ if (source !== 'tarball' && source !== 'published') {
+ throw new Error(
+ `--source must be 'tarball' or 'published' (got ${source})`,
+ );
+ }
+ if (testCommand.length === 0) {
+ throw new Error('No test command given. Pass it after `--`.');
+ }
+ return { source, version, testCommand };
+}
+
+/**
+ * `pnpm pack` the current package into `destDir` and return the tarball
+ * path. pnpm resolves `catalog:` / `workspace:*` specifiers in the
+ * packed package.json, matching what `pnpm publish` ships.
+ */
+function packTarball(destDir: string): string {
+ execFileSync('pnpm', ['pack', '--pack-destination', destDir], {
+ cwd: PKG_ROOT,
+ stdio: 'inherit',
+ });
+ let tgz = readdirSync(destDir).find((f) => f.endsWith('.tgz'));
+ if (!tgz) {
+ throw new Error(`pnpm pack produced no .tgz in ${destDir}`);
+ }
+ return join(destDir, tgz);
+}
+
+/**
+ * Poll `npm view` until `version` (a concrete version or a dist-tag) is
+ * resolvable, absorbing post-publish registry propagation delay.
+ */
+function waitForPublishedVersion(version: string): void {
+ let deadline = Date.now() + 180_000;
+ let attempt = 0;
+ for (;;) {
+ let result = spawnSync(
+ 'npm',
+ ['view', `${PKG_NAME}@${version}`, 'version'],
+ { encoding: 'utf8' },
+ );
+ if (result.status === 0 && result.stdout.trim()) {
+ console.log(`Resolved ${PKG_NAME}@${version} → ${result.stdout.trim()}`);
+ return;
+ }
+ if (Date.now() > deadline) {
+ throw new Error(
+ `${PKG_NAME}@${version} not resolvable after 180s. Last npm error:\n${result.stderr}`,
+ );
+ }
+ let delay = Math.min(15_000, 2_000 * ++attempt);
+ console.log(
+ `Waiting for ${PKG_NAME}@${version} to propagate (${attempt})…`,
+ );
+ execFileSync('sleep', [String(delay / 1000)]);
+ }
+}
+
+/**
+ * Create a clean install dir outside the monorepo and `npm install` the
+ * given spec (a tarball path or a registry spec). npm — not pnpm — so
+ * the CLI's dependencies land in the hoisted layout a real user gets.
+ */
+function npmInstall(spec: string): { installDir: string; entry: string } {
+ let installDir = mkdtempSync(join(tmpdir(), 'boxel-cli-suite-'));
+ // A minimal package.json so `npm install` treats this as a project
+ // root and hoists deps into `installDir/node_modules`.
+ writeFileSync(
+ join(installDir, 'package.json'),
+ JSON.stringify(
+ { name: 'boxel-cli-suite-host', version: '0.0.0', private: true },
+ null,
+ 2,
+ ) + '\n',
+ );
+ execFileSync(
+ 'npm',
+ ['install', spec, '--no-audit', '--no-fund', '--loglevel', 'error'],
+ { cwd: installDir, stdio: 'inherit' },
+ );
+ let entry = join(installDir, 'node_modules', PKG_NAME, 'dist', 'index.js');
+ if (!existsSync(entry)) {
+ throw new Error(`Installed CLI entry not found at ${entry}`);
+ }
+ return { installDir, entry };
+}
+
+function main(): void {
+ let { source, version, testCommand } = parseArgs(process.argv.slice(2));
+
+ let workDir = mkdtempSync(join(tmpdir(), 'boxel-cli-pack-'));
+ let cleanupDirs = [workDir];
+ try {
+ let spec: string;
+ if (source === 'tarball') {
+ spec = packTarball(workDir);
+ } else {
+ waitForPublishedVersion(version);
+ spec = `${PKG_NAME}@${version}`;
+ }
+
+ let { installDir, entry } = npmInstall(spec);
+ cleanupDirs.push(installDir);
+ console.log(
+ `\nBOXEL_CLI_BIN=${entry}\nRunning: ${testCommand.join(' ')}\n`,
+ );
+
+ let [cmd, ...cmdArgs] = testCommand;
+ let result = spawnSync(cmd, cmdArgs, {
+ cwd: PKG_ROOT,
+ stdio: 'inherit',
+ env: { ...process.env, BOXEL_CLI_BIN: entry },
+ });
+ process.exitCode = result.status ?? 1;
+ } finally {
+ for (let dir of cleanupDirs) {
+ try {
+ rmSync(dir, { recursive: true, force: true });
+ } catch {
+ // best-effort cleanup
+ }
+ }
+ }
+}
+
+main();
diff --git a/packages/boxel-cli/tests/commands/parse-glimmer-types.test.ts b/packages/boxel-cli/tests/commands/parse-glimmer-types.test.ts
deleted file mode 100644
index 79196a248f8..00000000000
--- a/packages/boxel-cli/tests/commands/parse-glimmer-types.test.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-import * as fs from 'fs';
-import * as os from 'os';
-import { join, resolve } from 'path';
-import { afterAll, beforeAll, describe, expect, it } from 'vitest';
-
-import { parseRealm } from '../../src/commands/parse.ts';
-
-// `boxel parse` resolves third-party imports in card code against
-// boxel-cli's own node_modules when running from the published layout
-// (bundled-types present). Cards that define plain Glimmer components
-// import `@glimmer/component` / `@glimmer/tracking`, so those packages
-// must be runtime dependencies of boxel-cli (CS-11509).
-//
-// This exercises the published-layout resolution path: CI runs
-// `pnpm build` (which produces `bundled-types/`) before the unit
-// tests. Without that build, parse falls back to the monorepo layout
-// and resolves against host's node_modules — which would mask a
-// missing boxel-cli dependency — so the test skips instead.
-const bundledTypesPresent = fs.existsSync(
- resolve(__dirname, '../../bundled-types/base'),
-);
-
-describe.skipIf(!bundledTypesPresent)(
- 'boxel parse — @glimmer imports in card code',
- () => {
- let workspace: string;
-
- beforeAll(() => {
- workspace = fs.mkdtempSync(join(os.tmpdir(), 'boxel-parse-glimmer-'));
- fs.writeFileSync(
- join(workspace, 'counter.gts'),
- `import Component from '@glimmer/component';
-import { tracked } from '@glimmer/tracking';
-import {
- CardDef,
- Component as CardComponent,
- field,
- contains,
-} from '@cardstack/base/card-api';
-import NumberField from '@cardstack/base/number';
-
-class CounterWidget extends Component<{ Args: { start?: number } }> {
- @tracked count = this.args.start ?? 0;
- {{this.count}}
-}
-
-export class Counter extends CardDef {
- static displayName = 'Counter';
- @field start = contains(NumberField);
- static isolated = class Isolated extends CardComponent {
-
- };
-}
-`,
- );
- });
-
- afterAll(() => {
- fs.rmSync(workspace, { recursive: true, force: true });
- });
-
- it(
- 'type-checks a card defining a plain Glimmer component',
- async () => {
- let result = await parseRealm(undefined, { workspace });
- expect(result.errors).toEqual([]);
- expect(result.status).toBe('passed');
- expect(result.filesChecked).toBeGreaterThanOrEqual(1);
- },
- { timeout: 180_000 },
- );
- },
-);
diff --git a/packages/boxel-cli/tests/fixtures/parse/boxel-host-tools/saver.gts b/packages/boxel-cli/tests/fixtures/parse/boxel-host-tools/saver.gts
new file mode 100644
index 00000000000..0c8e91c859d
--- /dev/null
+++ b/packages/boxel-cli/tests/fixtures/parse/boxel-host-tools/saver.gts
@@ -0,0 +1,18 @@
+import {
+ CardDef,
+ Component,
+} from 'https://cardstack.com/base/card-api';
+import SaveCardCommand from '@cardstack/boxel-host/tools/save-card';
+
+// Host tools moved from `@cardstack/boxel-host/commands/*` to
+// `@cardstack/boxel-host/tools/*`. parse aliases the old path but not
+// the new one, so current tool imports fail to resolve.
+export class Saver extends CardDef {
+ static displayName = 'Saver';
+ static isolated = class extends Component {
+ get command(): typeof SaveCardCommand {
+ return SaveCardCommand;
+ }
+ {{if this.command 'ready'}}
+ };
+}
diff --git a/packages/boxel-cli/tests/fixtures/parse/deliberate-type-error/broken.gts b/packages/boxel-cli/tests/fixtures/parse/deliberate-type-error/broken.gts
new file mode 100644
index 00000000000..2c8f23633a5
--- /dev/null
+++ b/packages/boxel-cli/tests/fixtures/parse/deliberate-type-error/broken.gts
@@ -0,0 +1,18 @@
+import {
+ CardDef,
+ Component,
+} from 'https://cardstack.com/base/card-api';
+
+// A genuine type error. parse must surface a real TS diagnostic here —
+// not the environmental "exited with errors but produced no TS
+// diagnostics" message that means glint resolved nothing and checked
+// nothing. This is the fixture that proves glint actually ran.
+export class Broken extends CardDef {
+ static displayName = 'Broken';
+ static isolated = class extends Component {
+ get count(): number {
+ return 'not a number';
+ }
+ {{this.count}}
+ };
+}
diff --git a/packages/boxel-cli/tests/fixtures/parse/helpers-and-fields/event.gts b/packages/boxel-cli/tests/fixtures/parse/helpers-and-fields/event.gts
new file mode 100644
index 00000000000..e2c2913022f
--- /dev/null
+++ b/packages/boxel-cli/tests/fixtures/parse/helpers-and-fields/event.gts
@@ -0,0 +1,27 @@
+import {
+ CardDef,
+ Component,
+ field,
+ contains,
+} from 'https://cardstack.com/base/card-api';
+import DatetimeField from 'https://cardstack.com/base/datetime';
+import NumberField from 'https://cardstack.com/base/number';
+import TextAreaField from 'https://cardstack.com/base/text-area';
+import { formatDateTime } from '@cardstack/boxel-ui/helpers';
+
+// Exercises the common template shapes: the positional
+// `formatDateTime` helper call, and direct interpolation of
+// `contains(NumberField)` / `contains(TextAreaField)` values.
+export class Event extends CardDef {
+ static displayName = 'Event';
+ @field when = contains(DatetimeField);
+ @field capacity = contains(NumberField);
+ @field notes = contains(TextAreaField);
+ static isolated = class extends Component {
+
+
+ {{@model.capacity}}
+ {{@model.notes}}
+
+ };
+}
diff --git a/packages/boxel-cli/tests/fixtures/parse/plain-glimmer/counter.gts b/packages/boxel-cli/tests/fixtures/parse/plain-glimmer/counter.gts
new file mode 100644
index 00000000000..717f368af58
--- /dev/null
+++ b/packages/boxel-cli/tests/fixtures/parse/plain-glimmer/counter.gts
@@ -0,0 +1,22 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import {
+ CardDef,
+ Component as CardComponent,
+ field,
+ contains,
+} from '@cardstack/base/card-api';
+import NumberField from '@cardstack/base/number';
+
+class CounterWidget extends Component<{ Args: { start?: number } }> {
+ @tracked count = this.args.start ?? 0;
+ {{this.count}}
+}
+
+export class Counter extends CardDef {
+ static displayName = 'Counter';
+ @field start = contains(NumberField);
+ static isolated = class Isolated extends CardComponent {
+
+ };
+}
diff --git a/packages/boxel-cli/tests/fixtures/parse/runtime-common/home.gts b/packages/boxel-cli/tests/fixtures/parse/runtime-common/home.gts
new file mode 100644
index 00000000000..b2d15a4ede3
--- /dev/null
+++ b/packages/boxel-cli/tests/fixtures/parse/runtime-common/home.gts
@@ -0,0 +1,20 @@
+import { CardDef, field, contains } from 'https://cardstack.com/base/card-api';
+import StringField from 'https://cardstack.com/base/string';
+import { realmURL, type Query } from '@cardstack/runtime-common';
+
+// The standard home-card pattern reaches for `@cardstack/runtime-common`
+// (the `realmURL` Symbol, the `Query` type) to build realm-scoped
+// queries. parse needs a path alias for the bare specifier or every such
+// card fails to resolve the module.
+export class Home extends CardDef {
+ static displayName = 'Home';
+ @field label = contains(StringField);
+
+ get realmHref(): string | undefined {
+ return this[realmURL]?.href;
+ }
+
+ get everythingQuery(): Query {
+ return { filter: { not: { eq: { id: null } } } };
+ }
+}
diff --git a/packages/boxel-cli/tests/fixtures/parse/tracked-format-class/toggle.gts b/packages/boxel-cli/tests/fixtures/parse/tracked-format-class/toggle.gts
new file mode 100644
index 00000000000..373643f0765
--- /dev/null
+++ b/packages/boxel-cli/tests/fixtures/parse/tracked-format-class/toggle.gts
@@ -0,0 +1,28 @@
+import { tracked } from '@glimmer/tracking';
+import { on } from '@ember/modifier';
+import {
+ CardDef,
+ Component as CardComponent,
+ field,
+ contains,
+} from '@cardstack/base/card-api';
+import StringField from '@cardstack/base/string';
+
+// The universal interactive-card pattern: `@tracked` local state
+// declared directly inside the `static isolated = class …` format-class
+// expression. Fires on basically every interactive card, so parse must
+// accept it.
+export class Toggle extends CardDef {
+ static displayName = 'Toggle';
+ @field label = contains(StringField);
+ static isolated = class Isolated extends CardComponent {
+ @tracked open = false;
+ toggle = () => {
+ this.open = !this.open;
+ };
+
+
+ {{#if this.open}}open
{{/if}}
+
+ };
+}
diff --git a/packages/boxel-cli/tests/helpers/integration.ts b/packages/boxel-cli/tests/helpers/integration.ts
index dfc1c209088..e74e260efeb 100644
--- a/packages/boxel-cli/tests/helpers/integration.ts
+++ b/packages/boxel-cli/tests/helpers/integration.ts
@@ -2,6 +2,7 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { ProfileManager } from '../../src/lib/profile-manager.ts';
+import { runBoxel } from './run-boxel.ts';
import {
prepareTestDB,
createTestPgAdapter,
@@ -222,6 +223,43 @@ export function createTestProfileDir(): {
};
}
+/**
+ * A throwaway HOME for driving the CLI as a subprocess. The returned
+ * `profileManager` is scoped to `/.boxel-cli` — the exact path the
+ * subprocess reads when spawned with `HOME=` (`ProfileManager`'s
+ * default config dir is `os.homedir()/.boxel-cli`, and `os.homedir()`
+ * honors `$HOME`). Seed it test-side with `setupTestProfile` /
+ * `setupJwtTestProfile` (both persist to disk via `saveConfig`), then
+ * pass `home` to `runBoxel` so the CLI authenticates without a Matrix
+ * round-trip. After a command mutates the profile on disk (e.g. `realm
+ * create` stores a realm token), call `reloadProfile(home)` to read the
+ * fresh state back — the seeded `profileManager`'s in-memory copy is
+ * stale once the subprocess has written.
+ */
+export function createTestHome(): {
+ home: string;
+ cleanup: () => void;
+ profileManager: ProfileManager;
+} {
+ let home = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-cli-home-'));
+ let profileManager = new ProfileManager(path.join(home, '.boxel-cli'));
+ return {
+ home,
+ cleanup: () => fs.rmSync(home, { recursive: true, force: true }),
+ profileManager,
+ };
+}
+
+/**
+ * Read the profile a subprocess left on disk under `/.boxel-cli`.
+ * Returns a fresh `ProfileManager` whose in-memory config reflects the
+ * current file, for inspecting state the CLI wrote (realm tokens,
+ * active profile, …).
+ */
+export function reloadProfile(home: string): ProfileManager {
+ return new ProfileManager(path.join(home, '.boxel-cli'));
+}
+
/**
* Register the cli-test user in Synapse. Re-registering an existing user
* produces a benign 4xx that callers can ignore. Most tests get this via
@@ -303,3 +341,33 @@ export function uniqueRealmName(): string {
let rand = Math.random().toString(36).slice(2, 6);
return `cli-test-${ts}-${rand}`;
}
+
+/**
+ * Create a realm through the CLI binary — `boxel realm create
+ * ` — rather than the in-process `createRealm`, and return its
+ * URL. The command stores a realm token keyed by realm URL in the
+ * profile on disk, so we read the URL back from there (matching how the
+ * in-process tests derived it from the in-memory profile).
+ *
+ * Requires a profile already seeded on disk under `/.boxel-cli`
+ * (via `setupTestProfile` / `setupJwtTestProfile` on a `ProfileManager`
+ * scoped to that home — see `createTestHome`).
+ */
+export async function createTestRealmViaCli(
+ home: string,
+ name: string = uniqueRealmName(),
+): Promise<{ realmUrl: string; name: string }> {
+ let res = await runBoxel(['realm', 'create', name, `Test ${name}`], { home });
+ if (!res.ok) {
+ throw new Error(
+ `\`realm create\` failed (exit ${res.exitCode}):\n${res.stderr}`,
+ );
+ }
+ let realmTokens =
+ reloadProfile(home).getActiveProfile()?.profile.realmTokens ?? {};
+ let entry = Object.entries(realmTokens).find(([url]) => url.includes(name));
+ if (!entry) {
+ throw new Error(`No realm JWT stored for ${name} after \`realm create\``);
+ }
+ return { realmUrl: entry[0], name };
+}
diff --git a/packages/boxel-cli/tests/helpers/run-boxel.ts b/packages/boxel-cli/tests/helpers/run-boxel.ts
new file mode 100644
index 00000000000..c9ad70fa0d0
--- /dev/null
+++ b/packages/boxel-cli/tests/helpers/run-boxel.ts
@@ -0,0 +1,144 @@
+import { spawn } from 'node:child_process';
+import { existsSync } from 'node:fs';
+import { resolve } from 'node:path';
+
+/**
+ * Drive the `boxel` CLI as a subprocess — its real external interface
+ * (argv + env + stdin → stdout/stderr/exit code), the same surface a
+ * user or the software factory hits.
+ *
+ * The one thing that varies between contexts is *which* binary runs,
+ * chosen by `BOXEL_CLI_BIN`:
+ *
+ * - **unset** → the local `dist/index.js` build. The default for
+ * `pnpm test:integration` during dev (run `pnpm build` first).
+ * - **set** → an absolute path to an installed CLI's JS entry. The
+ * context runners (`scripts/run-cli-suite.ts`) point this at a
+ * freshly `npm install`ed CLI — a packed tarball on PRs, or the
+ * published version post-release — so the identical suite exercises
+ * the npm-hoisted `node_modules` layout a real install produces.
+ * That layout is exactly what in-process function-call tests could
+ * never reach — the one where `boxel parse`'s glint type-check
+ * silently resolves nothing under npm hoisting, passing a check that
+ * never actually ran.
+ *
+ * The install is always invoked through the current `node` rather than
+ * the `.bin/boxel` shim so we don't depend on the shebang or the
+ * executable bit surviving extraction — `node ` resolves the
+ * package's own `node_modules` identically to the shim.
+ */
+function resolveCliInvocation(): { command: string; baseArgs: string[] } {
+ let bin = process.env.BOXEL_CLI_BIN;
+ if (bin) {
+ if (!existsSync(bin)) {
+ throw new Error(
+ `BOXEL_CLI_BIN points at ${bin}, which does not exist. The context runner should install the CLI before running the suite.`,
+ );
+ }
+ return { command: process.execPath, baseArgs: [bin] };
+ }
+ let dist = resolve(import.meta.dirname, '../../dist/index.js');
+ if (!existsSync(dist)) {
+ throw new Error(
+ `boxel-cli dist not found at ${dist}. Run \`pnpm build\` (or set BOXEL_CLI_BIN to an installed boxel binary) before running the CLI suite.`,
+ );
+ }
+ return { command: process.execPath, baseArgs: [dist] };
+}
+
+export interface RunBoxelOptions {
+ /** Working directory for the command (e.g. a parse workspace). */
+ cwd?: string;
+ /**
+ * Home directory the CLI reads its profile from. The subprocess sees
+ * `HOME` (POSIX) and `USERPROFILE` (Windows) set to this, so a profile
+ * seeded on disk at `/.boxel-cli/profiles.json` authenticates it
+ * without a Matrix round-trip. See `seedJwtProfileOnDisk`.
+ */
+ home?: string;
+ /** Extra env vars, merged last (override everything else). */
+ env?: NodeJS.ProcessEnv;
+ /** Text piped to the command's stdin. */
+ input?: string;
+ /** Kill the command after this many ms (default 60s). */
+ timeout?: number;
+}
+
+export interface BoxelResult {
+ stdout: string;
+ stderr: string;
+ exitCode: number | null;
+ /** True when the command exited 0. */
+ ok: boolean;
+ /**
+ * Parse stdout as JSON (for commands run with `--json`). Throws with
+ * the captured stdout/stderr attached when stdout isn't valid JSON, so
+ * a failing command surfaces its error instead of an opaque parse
+ * throw.
+ */
+ json(): T;
+}
+
+/**
+ * Strip `BOXEL_*` from the inherited env so a developer's shell (e.g.
+ * one exporting `BOXEL_ENVIRONMENT` for mise tasks) can't change how the
+ * CLI-under-test behaves — CI has no such vars, and the suite must match
+ * CI. Tests opt specific vars back in via `options.env`.
+ */
+function sanitizedParentEnv(): NodeJS.ProcessEnv {
+ return Object.fromEntries(
+ Object.entries(process.env).filter(([key]) => !key.startsWith('BOXEL_')),
+ );
+}
+
+export function runBoxel(
+ args: string[],
+ options: RunBoxelOptions = {},
+): Promise {
+ let { command, baseArgs } = resolveCliInvocation();
+ let env: NodeJS.ProcessEnv = {
+ ...sanitizedParentEnv(),
+ ...(options.home ? { HOME: options.home, USERPROFILE: options.home } : {}),
+ ...options.env,
+ };
+
+ return new Promise((resolvePromise, reject) => {
+ let child = spawn(command, [...baseArgs, ...args], {
+ cwd: options.cwd,
+ env,
+ stdio: ['pipe', 'pipe', 'pipe'],
+ timeout: options.timeout ?? 60_000,
+ });
+
+ let stdout = '';
+ let stderr = '';
+ child.stdout.on('data', (chunk) => (stdout += chunk));
+ child.stderr.on('data', (chunk) => (stderr += chunk));
+
+ child.on('error', reject);
+ child.on('close', (code) => {
+ resolvePromise({
+ stdout,
+ stderr,
+ exitCode: code,
+ ok: code === 0,
+ json(): T {
+ try {
+ return JSON.parse(stdout) as T;
+ } catch (err) {
+ throw new Error(
+ `Expected JSON on stdout but parse failed (${
+ err instanceof Error ? err.message : String(err)
+ }).\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`,
+ );
+ }
+ },
+ });
+ });
+
+ if (options.input !== undefined) {
+ child.stdin.write(options.input);
+ }
+ child.stdin.end();
+ });
+}
diff --git a/packages/boxel-cli/tests/integration/file-delete.test.ts b/packages/boxel-cli/tests/integration/file-delete.test.ts
index 7a8eeedff48..11171f35e00 100644
--- a/packages/boxel-cli/tests/integration/file-delete.test.ts
+++ b/packages/boxel-cli/tests/integration/file-delete.test.ts
@@ -3,22 +3,32 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
-import { deleteFile } from '../../src/commands/file/delete.ts';
-import { BoxelCLIClient } from '../../src/lib/boxel-cli-client.ts';
import { ProfileManager } from '../../src/lib/profile-manager.ts';
import {
startTestRealmServer,
stopTestRealmServer,
- createTestProfileDir,
+ createTestHome,
+ reloadProfile,
setupTestProfile,
TEST_REALM_SERVER_URL,
} from '../helpers/integration.ts';
+import { runBoxel } from '../helpers/run-boxel.ts';
-let profileManager: ProfileManager;
-let client: BoxelCLIClient;
+// `boxel file delete --realm ` DELETEs a realm-relative path.
+// We drive the installed binary and verify the effect by reading the file
+// back from the realm in-process with the profile the CLI wrote to disk.
+
+let home: string;
let cleanupProfile: () => void;
let realmUrl: string;
+async function readBack(relPath: string): Promise {
+ return reloadProfile(home).authedRealmFetch(`${realmUrl}${relPath}`, {
+ method: 'GET',
+ headers: { Accept: 'application/vnd.card+source' },
+ });
+}
+
beforeAll(async () => {
await startTestRealmServer({
fileSystem: {
@@ -49,11 +59,11 @@ beforeAll(async () => {
},
});
realmUrl = `${TEST_REALM_SERVER_URL}/test/`;
- let testProfile = createTestProfileDir();
- profileManager = testProfile.profileManager;
- cleanupProfile = testProfile.cleanup;
- await setupTestProfile(profileManager);
- client = new BoxelCLIClient(profileManager);
+
+ let testHome = createTestHome();
+ home = testHome.home;
+ cleanupProfile = testHome.cleanup;
+ await setupTestProfile(testHome.profileManager);
});
afterAll(async () => {
@@ -64,34 +74,41 @@ afterAll(async () => {
describe('file delete (integration)', () => {
it('deletes a file and confirms it no longer exists via read', async () => {
// Verify the file exists first
- let before = await client.read(realmUrl, 'delete-me.json');
+ let before = await readBack('delete-me.json');
expect(before.ok, 'file should exist before delete').toBe(true);
// Delete it
- let result = await deleteFile(realmUrl, 'delete-me.json', {
- profileManager,
- });
- expect(result.ok).toBe(true);
+ let res = await runBoxel(
+ ['file', 'delete', 'delete-me.json', '--realm', realmUrl],
+ { home },
+ );
+ expect(res.ok, res.stderr).toBe(true);
// Verify it's gone
- let after = await client.read(realmUrl, 'delete-me.json');
+ let after = await readBack('delete-me.json');
expect(after.ok).toBe(false);
expect(after.status).toBe(404);
});
it('other files remain after deleting one', async () => {
- let result = await client.read(realmUrl, 'keep-this.json');
+ let result = await readBack('keep-this.json');
expect(result.ok, 'unrelated file should still exist').toBe(true);
});
it('returns error result when no active profile', async () => {
- let emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-empty-'));
- let emptyManager = new ProfileManager(emptyDir);
- let result = await deleteFile(realmUrl, 'keep-this.json', {
- profileManager: emptyManager,
- });
- expect(result.ok).toBe(false);
- expect(result.error).toContain('No active profile');
- fs.rmSync(emptyDir, { recursive: true, force: true });
+ let emptyHome = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-empty-'));
+ // Materialize an empty profile store so the CLI reaches the
+ // no-active-profile guard rather than any first-run bootstrapping.
+ new ProfileManager(path.join(emptyHome, '.boxel-cli'));
+ try {
+ let res = await runBoxel(
+ ['file', 'delete', 'keep-this.json', '--realm', realmUrl],
+ { home: emptyHome },
+ );
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toContain('No active profile');
+ } finally {
+ fs.rmSync(emptyHome, { recursive: true, force: true });
+ }
});
});
diff --git a/packages/boxel-cli/tests/integration/file-lint.test.ts b/packages/boxel-cli/tests/integration/file-lint.test.ts
index 788875b9280..f991edf153a 100644
--- a/packages/boxel-cli/tests/integration/file-lint.test.ts
+++ b/packages/boxel-cli/tests/integration/file-lint.test.ts
@@ -3,44 +3,72 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
-import { lint } from '../../src/commands/file/lint.ts';
-import { write } from '../../src/commands/file/write.ts';
-import { createRealm } from '../../src/commands/realm/create.ts';
import { ProfileManager } from '../../src/lib/profile-manager.ts';
import {
startTestRealmServer,
stopTestRealmServer,
- createTestProfileDir,
+ createTestHome,
+ reloadProfile,
setupTestProfile,
- uniqueRealmName,
+ createTestRealmViaCli,
} from '../helpers/integration.ts';
+import { runBoxel } from '../helpers/run-boxel.ts';
-let profileManager: ProfileManager;
+// `boxel file lint --realm ` sources code either from a local
+// `--file` or by fetching from the realm, POSTs it to the realm's
+// `_lint` endpoint, and (with --json) prints `{ ok, fixed, output, messages }`
+// on stdout. With --fix it writes the auto-fixed output back to the source.
+// We drive the installed binary and verify realm state in-process.
+
+interface LintJson {
+ ok: boolean;
+ error?: string;
+ fixed?: boolean;
+ output?: string;
+ messages?: {
+ ruleId: string | null;
+ severity: 1 | 2;
+ message: string;
+ line: number;
+ column: number;
+ }[];
+}
+
+let home: string;
let cleanupProfile: () => void;
let realmUrl: string;
+let verifyPm: ProfileManager;
-async function createTestRealm(): Promise {
- let name = uniqueRealmName();
- await createRealm(name, `Test ${name}`, { profileManager });
-
- let realmTokens =
- profileManager.getActiveProfile()!.profile.realmTokens ?? {};
- let entry = Object.entries(realmTokens).find(([url]) => url.includes(name));
- if (!entry) {
- throw new Error(`No realm JWT stored for ${name}`);
+/**
+ * Lint `source` (staged in a throwaway local file) with `--json`, returning
+ * the parsed lint result. `--file` is how the CLI accepts arbitrary source
+ * that doesn't already live in the realm.
+ */
+async function lintSource(source: string, filename: string): Promise {
+ let dir = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-lint-'));
+ let file = path.join(dir, filename);
+ fs.writeFileSync(file, source, 'utf-8');
+ try {
+ let res = await runBoxel(
+ ['file', 'lint', filename, '--realm', realmUrl, '--file', file, '--json'],
+ { home },
+ );
+ return res.json();
+ } finally {
+ fs.rmSync(dir, { recursive: true, force: true });
}
- return entry[0];
}
beforeAll(async () => {
await startTestRealmServer();
- let testProfile = createTestProfileDir();
- profileManager = testProfile.profileManager;
- cleanupProfile = testProfile.cleanup;
- await setupTestProfile(profileManager);
+ let testHome = createTestHome();
+ home = testHome.home;
+ cleanupProfile = testHome.cleanup;
+ await setupTestProfile(testHome.profileManager);
+ verifyPm = reloadProfile(home);
- realmUrl = await createTestRealm();
+ ({ realmUrl } = await createTestRealmViaCli(home));
});
afterAll(async () => {
@@ -51,7 +79,7 @@ afterAll(async () => {
describe('file lint (integration)', () => {
it('lints source via the realm _lint endpoint and returns a result', async () => {
let source = 'export const x = 1;\n';
- let result = await lint(realmUrl, source, 'test.gts', { profileManager });
+ let result = await lintSource(source, 'test.gts');
expect(result.ok).toBe(true);
expect(result).toHaveProperty('messages');
@@ -65,7 +93,7 @@ export class MyCard extends CardDef {
@field name = contains(StringField);
}
`;
- let result = await lint(realmUrl, source, 'test.gts', { profileManager });
+ let result = await lintSource(source, 'test.gts');
expect(result.ok).toBe(true);
expect(result.fixed).toBe(true);
@@ -82,7 +110,7 @@ export class MyCard extends CardDef {
@field name = contains(StringField);
}
`;
- let result = await lint(realmUrl, source, 'test.gts', { profileManager });
+ let result = await lintSource(source, 'test.gts');
expect(result.ok).toBe(true);
expect(result.fixed).toBe(true);
@@ -105,7 +133,7 @@ export class MyCard extends CardDef {
`;
- let result = await lint(realmUrl, source, 'test.gts', { profileManager });
+ let result = await lintSource(source, 'test.gts');
expect(result.ok).toBe(true);
expect(result.messages).toBeDefined();
@@ -123,24 +151,26 @@ export class MyCard extends CardDef {
@field name = contains(StringField);
}
`;
- let tmpFile = path.join(
- fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-fix-')),
- 'test-card.gts',
- );
+ let tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-fix-'));
+ let tmpFile = path.join(tmpDir, 'test-card.gts');
fs.writeFileSync(tmpFile, unfixedSource, 'utf-8');
try {
- let source = fs.readFileSync(tmpFile, 'utf-8');
- let result = await lint(realmUrl, source, 'test-card.gts', {
- profileManager,
- });
-
- expect(result.ok).toBe(true);
- expect(result.fixed).toBe(true);
- expect(result.output).toBeDefined();
-
- // Simulate what the CLI --fix does for local files
- fs.writeFileSync(tmpFile, result.output!, 'utf-8');
+ // --fix rewrites the local file in place with the auto-fixed output.
+ let res = await runBoxel(
+ [
+ 'file',
+ 'lint',
+ 'test-card.gts',
+ '--realm',
+ realmUrl,
+ '--file',
+ tmpFile,
+ '--fix',
+ ],
+ { home },
+ );
+ expect(res.ok, res.stderr).toBe(true);
let fixedContent = fs.readFileSync(tmpFile, 'utf-8');
expect(fixedContent).toContain('import StringField from');
@@ -148,20 +178,30 @@ export class MyCard extends CardDef {
' @field name = contains(StringField);',
);
- // Lint the fixed content again — should have no more fixable changes
- let secondPass = await lint(realmUrl, fixedContent, 'test-card.gts', {
- profileManager,
- });
- expect(secondPass.ok).toBe(true);
- expect(secondPass.fixed).toBe(false);
+ // Lint the fixed content again — should have no more fixable changes.
+ let secondPass = await runBoxel(
+ [
+ 'file',
+ 'lint',
+ 'test-card.gts',
+ '--realm',
+ realmUrl,
+ '--file',
+ tmpFile,
+ '--json',
+ ],
+ { home },
+ );
+ expect(secondPass.ok, secondPass.stderr).toBe(true);
+ expect(secondPass.json().fixed).toBe(false);
} finally {
- fs.rmSync(path.dirname(tmpFile), { recursive: true, force: true });
+ fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});
describe('--fix without --file (realm file)', () => {
- it('writes fixed output back to the realm via write()', async () => {
+ it('writes fixed output back to the realm', async () => {
let unfixedSource = `import{CardDef}from '@cardstack/base/card-api';
export class MyCard extends CardDef {
@field name = contains(StringField);
@@ -169,29 +209,28 @@ export class MyCard extends CardDef {
`;
let filePath = 'fix-test-card.gts';
- // Upload unfixed source to the realm
- let uploadResult = await write(realmUrl, filePath, unfixedSource, {
- profileManager,
- });
- expect(uploadResult.ok).toBe(true);
-
- // Lint it
- let result = await lint(realmUrl, unfixedSource, filePath, {
- profileManager,
+ // Upload unfixed source to the realm (setup stays in-process).
+ let uploadUrl = new URL(filePath, realmUrl).href;
+ let upload = await verifyPm.authedRealmFetch(uploadUrl, {
+ method: 'POST',
+ headers: {
+ Accept: 'application/vnd.card+source',
+ 'Content-Type': 'application/vnd.card+source',
+ },
+ body: unfixedSource,
});
- expect(result.ok).toBe(true);
- expect(result.fixed).toBe(true);
- expect(result.output).toBeDefined();
+ expect(upload.ok, `upload failed: ${upload.status}`).toBe(true);
- // Simulate what the CLI --fix does for realm files
- let writeResult = await write(realmUrl, filePath, result.output!, {
- profileManager,
- });
- expect(writeResult.ok).toBe(true);
+ // --fix without --file reads the file from the realm, lints it, and
+ // writes the auto-fixed output back to the realm.
+ let res = await runBoxel(
+ ['file', 'lint', filePath, '--realm', realmUrl, '--fix'],
+ { home },
+ );
+ expect(res.ok, res.stderr).toBe(true);
- // Read the file back from the realm and verify it's fixed
- let readUrl = new URL(filePath, realmUrl).href;
- let response = await profileManager.authedRealmFetch(readUrl, {
+ // Read the file back from the realm and verify it's fixed.
+ let response = await verifyPm.authedRealmFetch(uploadUrl, {
method: 'GET',
headers: { Accept: 'application/vnd.card+source' },
});
@@ -203,17 +242,31 @@ export class MyCard extends CardDef {
});
it('returns error result when no active profile', async () => {
- let emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-empty-'));
- let emptyManager = new ProfileManager(emptyDir);
+ let emptyHome = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-empty-'));
+ new ProfileManager(path.join(emptyHome, '.boxel-cli'));
+ let srcDir = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-lint-src-'));
+ let srcFile = path.join(srcDir, 'test.gts');
+ fs.writeFileSync(srcFile, 'let x = 1;', 'utf-8');
try {
- let result = await lint(realmUrl, 'let x = 1;', 'test.gts', {
- profileManager: emptyManager,
- });
- expect(result.ok).toBe(false);
- expect(result.error).toContain('No active profile');
+ let res = await runBoxel(
+ [
+ 'file',
+ 'lint',
+ 'test.gts',
+ '--realm',
+ realmUrl,
+ '--file',
+ srcFile,
+ '--json',
+ ],
+ { home: emptyHome },
+ );
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toContain('No active profile');
} finally {
- fs.rmSync(emptyDir, { recursive: true, force: true });
+ fs.rmSync(emptyHome, { recursive: true, force: true });
+ fs.rmSync(srcDir, { recursive: true, force: true });
}
});
});
diff --git a/packages/boxel-cli/tests/integration/file-list.test.ts b/packages/boxel-cli/tests/integration/file-list.test.ts
index 33a557dc3e5..6aec9fab9e1 100644
--- a/packages/boxel-cli/tests/integration/file-list.test.ts
+++ b/packages/boxel-cli/tests/integration/file-list.test.ts
@@ -3,17 +3,25 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
-import { listFiles } from '../../src/commands/file/list.ts';
import { ProfileManager } from '../../src/lib/profile-manager.ts';
import {
startTestRealmServer,
stopTestRealmServer,
- createTestProfileDir,
+ createTestHome,
setupTestProfile,
TEST_REALM_SERVER_URL,
} from '../helpers/integration.ts';
+import { runBoxel } from '../helpers/run-boxel.ts';
-let profileManager: ProfileManager;
+// `boxel file list --realm --json` prints `{ filenames, error? }` on
+// stdout. We drive the installed binary and parse its JSON payload.
+
+interface ListJson {
+ filenames: string[];
+ error?: string;
+}
+
+let home: string;
let cleanupProfile: () => void;
let realmUrl: string;
@@ -28,10 +36,10 @@ beforeAll(async () => {
realmUrl = `${TEST_REALM_SERVER_URL}/test/`;
- let testProfile = createTestProfileDir();
- profileManager = testProfile.profileManager;
- cleanupProfile = testProfile.cleanup;
- await setupTestProfile(profileManager);
+ let testHome = createTestHome();
+ home = testHome.home;
+ cleanupProfile = testHome.cleanup;
+ await setupTestProfile(testHome.profileManager);
});
afterAll(async () => {
@@ -41,8 +49,12 @@ afterAll(async () => {
describe('file list (integration)', () => {
it('returns sorted filenames including seeded files', async () => {
- let result = await listFiles(realmUrl, { profileManager });
+ let res = await runBoxel(['file', 'list', '--realm', realmUrl, '--json'], {
+ home,
+ });
+ expect(res.ok, res.stderr).toBe(true);
+ let result = res.json();
expect(result.error).toBeUndefined();
expect(result.filenames).toContain('hello.gts');
expect(result.filenames).toContain('world.json');
@@ -52,19 +64,32 @@ describe('file list (integration)', () => {
});
it('returns error for an unreachable realm', async () => {
- let result = await listFiles('http://127.0.0.1:1/', { profileManager });
+ let res = await runBoxel(
+ ['file', 'list', '--realm', 'http://127.0.0.1:1/', '--json'],
+ { home },
+ );
+ expect(res.exitCode).toBe(1);
+
+ let result = res.json();
expect(result.filenames).toEqual([]);
expect(result.error).toBeDefined();
});
it('returns error result when no active profile', async () => {
- let emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-empty-'));
- let emptyManager = new ProfileManager(emptyDir);
-
- let result = await listFiles(realmUrl, { profileManager: emptyManager });
- expect(result.filenames).toEqual([]);
- expect(result.error).toContain('No active profile');
+ let emptyHome = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-empty-'));
+ new ProfileManager(path.join(emptyHome, '.boxel-cli'));
+ try {
+ let res = await runBoxel(
+ ['file', 'list', '--realm', realmUrl, '--json'],
+ { home: emptyHome },
+ );
+ expect(res.exitCode).toBe(1);
- fs.rmSync(emptyDir, { recursive: true, force: true });
+ let result = res.json();
+ expect(result.filenames).toEqual([]);
+ expect(result.error).toContain('No active profile');
+ } finally {
+ fs.rmSync(emptyHome, { recursive: true, force: true });
+ }
});
});
diff --git a/packages/boxel-cli/tests/integration/file-read.test.ts b/packages/boxel-cli/tests/integration/file-read.test.ts
index 9f641ffcd95..f31e4138b70 100644
--- a/packages/boxel-cli/tests/integration/file-read.test.ts
+++ b/packages/boxel-cli/tests/integration/file-read.test.ts
@@ -3,18 +3,31 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
-import { read } from '../../src/commands/file/read.ts';
import { ProfileManager } from '../../src/lib/profile-manager.ts';
import {
startTestRealmServer,
stopTestRealmServer,
- createTestProfileDir,
+ createTestHome,
+ reloadProfile,
setupTestProfile,
TEST_REALM_SERVER_URL,
} from '../helpers/integration.ts';
+import { runBoxel } from '../helpers/run-boxel.ts';
import { TINY_PNG_BYTES } from '../helpers/binary-fixtures.ts';
-let profileManager: ProfileManager;
+// `boxel file read [--realm ] --json` prints
+// `{ ok, status, error?, content?, bytesBase64? }` on stdout. We drive the
+// installed binary and parse its JSON payload.
+
+interface ReadJson {
+ ok: boolean;
+ status?: number;
+ content?: string;
+ bytesBase64?: string;
+ error?: string;
+}
+
+let home: string;
let cleanupProfile: () => void;
let realmUrl: string;
@@ -58,10 +71,10 @@ beforeAll(async () => {
realmUrl = `${TEST_REALM_SERVER_URL}/test/`;
- let testProfile = createTestProfileDir();
- profileManager = testProfile.profileManager;
- cleanupProfile = testProfile.cleanup;
- await setupTestProfile(profileManager);
+ let testHome = createTestHome();
+ home = testHome.home;
+ cleanupProfile = testHome.cleanup;
+ await setupTestProfile(testHome.profileManager);
});
afterAll(async () => {
@@ -71,7 +84,11 @@ afterAll(async () => {
describe('file read (integration)', () => {
it('reads a .json file and returns raw text content', async () => {
- let result = await read(realmUrl, 'test-card.json', { profileManager });
+ let res = await runBoxel(
+ ['file', 'read', 'test-card.json', '--realm', realmUrl, '--json'],
+ { home },
+ );
+ let result = res.json();
expect(result.ok, `read failed: ${JSON.stringify(result)}`).toBe(true);
expect(result.status).toBe(200);
@@ -83,9 +100,11 @@ describe('file read (integration)', () => {
});
it('reads a .gts file and returns raw text content', async () => {
- let result = await read(realmUrl, 'file-read-check.gts', {
- profileManager,
- });
+ let res = await runBoxel(
+ ['file', 'read', 'file-read-check.gts', '--realm', realmUrl, '--json'],
+ { home },
+ );
+ let result = res.json();
expect(result.ok, `read failed: ${JSON.stringify(result)}`).toBe(true);
expect(result.status).toBe(200);
@@ -96,9 +115,18 @@ describe('file read (integration)', () => {
it('reads a file via a non-URL @cardstack/ realm identifier', async () => {
// `@cardstack//` resolves against the active profile's
// realm-server URL, so `@cardstack/test/` names the test realm.
- let result = await read('@cardstack/test/', 'test-card.json', {
- profileManager,
- });
+ let res = await runBoxel(
+ [
+ 'file',
+ 'read',
+ 'test-card.json',
+ '--realm',
+ '@cardstack/test/',
+ '--json',
+ ],
+ { home },
+ );
+ let result = res.json();
expect(result.ok, `read failed: ${JSON.stringify(result)}`).toBe(true);
expect(result.status).toBe(200);
@@ -107,53 +135,73 @@ describe('file read (integration)', () => {
});
it('returns a not-ok result for an unsupported realm identifier scope', async () => {
- let result = await read('@unknown-scope/test/', 'test-card.json', {
- profileManager,
- });
+ let res = await runBoxel(
+ [
+ 'file',
+ 'read',
+ 'test-card.json',
+ '--realm',
+ '@unknown-scope/test/',
+ '--json',
+ ],
+ { home },
+ );
+ expect(res.exitCode).toBe(1);
+ let result = res.json();
expect(result.ok).toBe(false);
expect(result.error).toContain('only @cardstack//');
});
it('returns a not-ok result with 404 status for a nonexistent file', async () => {
- let result = await read(realmUrl, 'does-not-exist.json', {
- profileManager,
- });
+ let res = await runBoxel(
+ ['file', 'read', 'does-not-exist.json', '--realm', realmUrl, '--json'],
+ { home },
+ );
+ expect(res.exitCode).toBe(1);
+ let result = res.json();
expect(result.ok).toBe(false);
expect(result.status).toBe(404);
expect(result.error).toContain('404');
});
it('reads a binary PNG byte-identically (returns bytes, not content)', async () => {
- // Seed via direct octet-stream POST — startTestRealmServer's
- // fileSystem option only accepts strings.
+ // Seed via direct octet-stream POST (setup stays in-process) —
+ // startTestRealmServer's fileSystem option only accepts strings.
let pngUrl = `${realmUrl}image.png`;
- let seed = await profileManager.authedRealmFetch(pngUrl, {
+ let seed = await reloadProfile(home).authedRealmFetch(pngUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: TINY_PNG_BYTES,
});
expect(seed.ok, `seed POST failed: ${seed.status}`).toBe(true);
- let result = await read(realmUrl, 'image.png', { profileManager });
+ let res = await runBoxel(
+ ['file', 'read', 'image.png', '--realm', realmUrl, '--json'],
+ { home },
+ );
+ let result = res.json();
expect(result.ok).toBe(true);
expect(result.status).toBe(200);
expect(result.content).toBeUndefined();
- expect(result.bytes).toBeDefined();
- expect(Buffer.from(result.bytes!).equals(Buffer.from(TINY_PNG_BYTES))).toBe(
- true,
- );
+ expect(result.bytesBase64).toBeDefined();
+ let bytes = Buffer.from(result.bytesBase64!, 'base64');
+ expect(bytes.equals(Buffer.from(TINY_PNG_BYTES))).toBe(true);
});
it('returns error result when no active profile', async () => {
- let emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-empty-'));
- let emptyManager = new ProfileManager(emptyDir);
-
- let result = await read(realmUrl, 'test-card.json', {
- profileManager: emptyManager,
- });
- expect(result.ok).toBe(false);
- expect(result.error).toContain('No active profile');
-
- fs.rmSync(emptyDir, { recursive: true, force: true });
+ let emptyHome = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-empty-'));
+ new ProfileManager(path.join(emptyHome, '.boxel-cli'));
+ try {
+ let res = await runBoxel(
+ ['file', 'read', 'test-card.json', '--realm', realmUrl, '--json'],
+ { home: emptyHome },
+ );
+ expect(res.exitCode).toBe(1);
+ let result = res.json();
+ expect(result.ok).toBe(false);
+ expect(result.error).toContain('No active profile');
+ } finally {
+ fs.rmSync(emptyHome, { recursive: true, force: true });
+ }
});
});
diff --git a/packages/boxel-cli/tests/integration/file-seed-auth.test.ts b/packages/boxel-cli/tests/integration/file-seed-auth.test.ts
index 86edef8157b..2defb5e8efd 100644
--- a/packages/boxel-cli/tests/integration/file-seed-auth.test.ts
+++ b/packages/boxel-cli/tests/integration/file-seed-auth.test.ts
@@ -1,18 +1,19 @@
import '../helpers/setup-realm-server.ts';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
-import { read } from '../../src/commands/file/read.ts';
-import { write } from '../../src/commands/file/write.ts';
import {
startTestRealmServer,
stopTestRealmServer,
- createTestProfileDir,
+ createTestHome,
realmSecretSeed,
TEST_REALM_SERVER_URL,
} from '../helpers/integration.ts';
+import { runBoxel } from '../helpers/run-boxel.ts';
// The test realm grants `'*': ['read','write']`, so any token validly signed
// with `realmSecretSeed` may read and write — which is what the seed-auth path
-// produces (a locally-minted JWT, no Matrix login).
+// produces (a locally-minted JWT, no Matrix login). The CLI takes the seed via
+// `--realm-secret-seed` reading `BOXEL_REALM_SECRET_SEED` from the env
+// (run-boxel strips inherited BOXEL_* vars, so we opt it back in explicitly).
let realmUrl: string;
beforeAll(async () => {
@@ -28,37 +29,57 @@ afterAll(async () => {
describe('file read/write with seed-based auth (integration)', () => {
it('writes then reads a file authenticating via the seed (no Matrix profile)', async () => {
- // Empty profile: no Matrix login exists, so success proves the seed path.
- let { profileManager, cleanup } = createTestProfileDir();
+ // Empty profile home: no Matrix login exists, so success proves the seed
+ // path.
+ let { home, cleanup } = createTestHome();
try {
- let writeResult = await write(
- realmUrl,
- 'seed-roundtrip.gts',
- 'export const seeded = 42;\n',
- { realmSecretSeed, profileManager },
+ let writeRes = await runBoxel(
+ [
+ 'file',
+ 'write',
+ 'seed-roundtrip.gts',
+ '--realm',
+ realmUrl,
+ '--realm-secret-seed',
+ ],
+ {
+ home,
+ input: 'export const seeded = 42;\n',
+ env: { BOXEL_REALM_SECRET_SEED: realmSecretSeed },
+ },
);
- expect(writeResult.error).toBeUndefined();
- expect(writeResult.ok).toBe(true);
+ expect(writeRes.ok, writeRes.stderr).toBe(true);
- let readResult = await read(realmUrl, 'seed-roundtrip.gts', {
- realmSecretSeed,
- profileManager,
- });
- expect(readResult.ok).toBe(true);
- expect(readResult.content).toContain('seeded = 42');
+ let readRes = await runBoxel(
+ [
+ 'file',
+ 'read',
+ 'seed-roundtrip.gts',
+ '--realm',
+ realmUrl,
+ '--realm-secret-seed',
+ '--json',
+ ],
+ { home, env: { BOXEL_REALM_SECRET_SEED: realmSecretSeed } },
+ );
+ expect(readRes.ok, readRes.stderr).toBe(true);
+ let result = readRes.json<{ ok: boolean; content?: string }>();
+ expect(result.ok).toBe(true);
+ expect(result.content).toContain('seeded = 42');
} finally {
cleanup();
}
});
it('fails cleanly with "No active profile" when neither a seed nor a profile is configured', async () => {
- let { profileManager, cleanup } = createTestProfileDir();
+ let { home, cleanup } = createTestHome();
try {
- let result = await read(realmUrl, 'seed-existing.gts', {
- profileManager,
- });
- expect(result.ok).toBe(false);
- expect(result.error).toContain('No active profile');
+ let res = await runBoxel(
+ ['file', 'read', 'seed-existing.gts', '--realm', realmUrl],
+ { home },
+ );
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toContain('No active profile');
} finally {
cleanup();
}
diff --git a/packages/boxel-cli/tests/integration/file-touch.test.ts b/packages/boxel-cli/tests/integration/file-touch.test.ts
index 4ea8ce352cc..c6f42cbac0d 100644
--- a/packages/boxel-cli/tests/integration/file-touch.test.ts
+++ b/packages/boxel-cli/tests/integration/file-touch.test.ts
@@ -3,19 +3,35 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
-import { touchFiles } from '../../src/commands/file/touch.ts';
import { ProfileManager } from '../../src/lib/profile-manager.ts';
import {
startTestRealmServer,
stopTestRealmServer,
- createTestProfileDir,
+ createTestHome,
+ reloadProfile,
setupTestProfile,
TEST_REALM_SERVER_URL,
} from '../helpers/integration.ts';
+import { runBoxel } from '../helpers/run-boxel.ts';
-let profileManager: ProfileManager;
+// `boxel file touch [paths...] --realm [--all] [--dry-run] --json`
+// prints `{ ok, touched, skipped, error? }` on stdout. We drive the
+// installed binary and verify realm state in-process.
+
+interface TouchJson {
+ ok: boolean;
+ touched: string[];
+ skipped: { path: string; reason: string }[];
+ error?: string;
+}
+
+let home: string;
let cleanupProfile: () => void;
let realmUrl: string;
+// A ProfileManager reflecting the profile the CLI reads from disk, used for
+// in-process realm reads/writes during setup and verification. `touch` never
+// mutates the profile (the realm pre-exists), so a single instance is fine.
+let verifyPm: ProfileManager;
const SOURCE_GTS = `import {
CardDef,
@@ -79,10 +95,11 @@ beforeAll(async () => {
realmUrl = `${TEST_REALM_SERVER_URL}/test/`;
- let testProfile = createTestProfileDir();
- profileManager = testProfile.profileManager;
- cleanupProfile = testProfile.cleanup;
- await setupTestProfile(profileManager);
+ let testHome = createTestHome();
+ home = testHome.home;
+ cleanupProfile = testHome.cleanup;
+ await setupTestProfile(testHome.profileManager);
+ verifyPm = reloadProfile(home);
});
afterAll(async () => {
@@ -91,7 +108,7 @@ afterAll(async () => {
});
async function readMtimes(): Promise> {
- let response = await profileManager.authedRealmFetch(`${realmUrl}_mtimes`, {
+ let response = await verifyPm.authedRealmFetch(`${realmUrl}_mtimes`, {
method: 'GET',
headers: { Accept: 'application/vnd.api+json' },
});
@@ -105,13 +122,10 @@ async function readMtimes(): Promise> {
}
async function readFileContent(relPath: string): Promise {
- let response = await profileManager.authedRealmFetch(
- `${realmUrl}${relPath}`,
- {
- method: 'GET',
- headers: { Accept: 'application/vnd.card+source' },
- },
- );
+ let response = await verifyPm.authedRealmFetch(`${realmUrl}${relPath}`, {
+ method: 'GET',
+ headers: { Accept: 'application/vnd.card+source' },
+ });
return response.text();
}
@@ -119,13 +133,20 @@ async function writeFileContent(
relPath: string,
content: string,
): Promise {
- await profileManager.authedRealmFetch(`${realmUrl}${relPath}`, {
+ await verifyPm.authedRealmFetch(`${realmUrl}${relPath}`, {
method: 'POST',
headers: { 'Content-Type': 'application/vnd.card+source' },
body: content,
});
}
+async function touch(args: string[], home_ = home): Promise {
+ let res = await runBoxel(['file', 'touch', ...args, '--json'], {
+ home: home_,
+ });
+ return res.json();
+}
+
describe('file touch (integration)', () => {
it('touching a .json file updates its mtime on the realm', async () => {
let target = 'cards/one.json';
@@ -136,7 +157,7 @@ describe('file touch (integration)', () => {
// reliably observe a change.
await new Promise((r) => setTimeout(r, 1100));
- let result = await touchFiles(realmUrl, [target], { profileManager });
+ let result = await touch([target, '--realm', realmUrl]);
expect(result.ok, JSON.stringify(result)).toBe(true);
expect(result.touched).toEqual([target]);
expect(result.skipped).toEqual([]);
@@ -148,7 +169,7 @@ describe('file touch (integration)', () => {
it('touching a .json file persists `_touched` in `meta`', async () => {
let target = 'cards/three.json';
let before = Date.now();
- let result = await touchFiles(realmUrl, [target], { profileManager });
+ let result = await touch([target, '--realm', realmUrl]);
expect(result.ok, JSON.stringify(result)).toBe(true);
let content = await readFileContent(target);
@@ -168,7 +189,7 @@ describe('file touch (integration)', () => {
// reliably observe a change.
await new Promise((r) => setTimeout(r, 1100));
- let result = await touchFiles(realmUrl, [target], { profileManager });
+ let result = await touch([target, '--realm', realmUrl]);
expect(result.ok, JSON.stringify(result)).toBe(true);
expect(result.touched).toEqual([target]);
@@ -181,14 +202,14 @@ describe('file touch (integration)', () => {
let initial = await readFileContent(target);
let initiallyHasComment = initial.includes('// touched for re-index');
- let firstTouch = await touchFiles(realmUrl, [target], { profileManager });
+ let firstTouch = await touch([target, '--realm', realmUrl]);
expect(firstTouch.ok, JSON.stringify(firstTouch)).toBe(true);
let afterFirst = await readFileContent(target);
expect(afterFirst.includes('// touched for re-index')).toBe(
!initiallyHasComment,
);
- let secondTouch = await touchFiles(realmUrl, [target], { profileManager });
+ let secondTouch = await touch([target, '--realm', realmUrl]);
expect(secondTouch.ok, JSON.stringify(secondTouch)).toBe(true);
let afterSecond = await readFileContent(target);
expect(afterSecond.includes('// touched for re-index')).toBe(
@@ -197,10 +218,7 @@ describe('file touch (integration)', () => {
});
it('--all enumerates and touches every .json and .gts in the realm', async () => {
- let result = await touchFiles(realmUrl, [], {
- all: true,
- profileManager,
- });
+ let result = await touch(['--realm', realmUrl, '--all']);
expect(result.ok, JSON.stringify(result)).toBe(true);
expect(result.touched).toContain('touch-check.gts');
expect(result.touched).toContain('cards/one.json');
@@ -219,7 +237,7 @@ describe('file touch (integration)', () => {
expect(original).toContain(`'// touched for re-index'`);
expect(countMarker(original)).toBe(1);
- let firstTouch = await touchFiles(realmUrl, [target], { profileManager });
+ let firstTouch = await touch([target, '--realm', realmUrl]);
expect(firstTouch.ok, JSON.stringify(firstTouch)).toBe(true);
let afterFirst = await readFileContent(target);
@@ -229,7 +247,7 @@ describe('file touch (integration)', () => {
`static markerHint = '// touched for re-index';`,
);
- let secondTouch = await touchFiles(realmUrl, [target], { profileManager });
+ let secondTouch = await touch([target, '--realm', realmUrl]);
expect(secondTouch.ok, JSON.stringify(secondTouch)).toBe(true);
let afterSecond = await readFileContent(target);
@@ -244,10 +262,7 @@ describe('file touch (integration)', () => {
let target = 'cards/two.json';
let before = (await readMtimes())[`${realmUrl}${target}`];
- let result = await touchFiles(realmUrl, [target], {
- dryRun: true,
- profileManager,
- });
+ let result = await touch([target, '--realm', realmUrl, '--dry-run']);
expect(result.ok).toBe(true);
expect(result.touched).toEqual([target]);
@@ -257,9 +272,12 @@ describe('file touch (integration)', () => {
});
it('skips files with unsupported extensions', async () => {
- let result = await touchFiles(realmUrl, ['cards/note.txt'], {
- profileManager,
- });
+ let res = await runBoxel(
+ ['file', 'touch', 'cards/note.txt', '--realm', realmUrl, '--json'],
+ { home },
+ );
+ expect(res.exitCode).toBe(1);
+ let result = res.json();
expect(result.ok).toBe(false);
expect(result.touched).toEqual([]);
expect(result.skipped).toEqual([
@@ -268,9 +286,19 @@ describe('file touch (integration)', () => {
});
it('skips paths that 404 on the realm', async () => {
- let result = await touchFiles(realmUrl, ['cards/does-not-exist.json'], {
- profileManager,
- });
+ let res = await runBoxel(
+ [
+ 'file',
+ 'touch',
+ 'cards/does-not-exist.json',
+ '--realm',
+ realmUrl,
+ '--json',
+ ],
+ { home },
+ );
+ expect(res.exitCode).toBe(1);
+ let result = res.json();
expect(result.ok).toBe(false);
expect(result.touched).toEqual([]);
expect(result.skipped).toHaveLength(1);
@@ -279,10 +307,20 @@ describe('file touch (integration)', () => {
});
it('--dry-run skips paths that would 404 instead of reporting them as touched', async () => {
- let result = await touchFiles(realmUrl, ['cards/missing.json'], {
- dryRun: true,
- profileManager,
- });
+ let res = await runBoxel(
+ [
+ 'file',
+ 'touch',
+ 'cards/missing.json',
+ '--realm',
+ realmUrl,
+ '--dry-run',
+ '--json',
+ ],
+ { home },
+ );
+ expect(res.exitCode).toBe(1);
+ let result = res.json();
expect(result.ok).toBe(false);
expect(result.touched).toEqual([]);
expect(result.skipped).toHaveLength(1);
@@ -291,30 +329,48 @@ describe('file touch (integration)', () => {
});
it('returns error when no paths and no --all', async () => {
- let result = await touchFiles(realmUrl, [], { profileManager });
+ let res = await runBoxel(['file', 'touch', '--realm', realmUrl, '--json'], {
+ home,
+ });
+ expect(res.exitCode).toBe(1);
+ let result = res.json();
expect(result.ok).toBe(false);
expect(result.error).toContain('No file paths provided');
});
it('returns error when paths combined with --all', async () => {
- let result = await touchFiles(realmUrl, ['cards/one.json'], {
- all: true,
- profileManager,
- });
+ let res = await runBoxel(
+ [
+ 'file',
+ 'touch',
+ 'cards/one.json',
+ '--realm',
+ realmUrl,
+ '--all',
+ '--json',
+ ],
+ { home },
+ );
+ expect(res.exitCode).toBe(1);
+ let result = res.json();
expect(result.ok).toBe(false);
expect(result.error).toContain('--all');
});
it('returns error result when no active profile', async () => {
- let emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-empty-'));
- let emptyManager = new ProfileManager(emptyDir);
-
- let result = await touchFiles(realmUrl, ['cards/one.json'], {
- profileManager: emptyManager,
- });
- expect(result.ok).toBe(false);
- expect(result.error).toContain('No active profile');
-
- fs.rmSync(emptyDir, { recursive: true, force: true });
+ let emptyHome = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-empty-'));
+ new ProfileManager(path.join(emptyHome, '.boxel-cli'));
+ try {
+ let res = await runBoxel(
+ ['file', 'touch', 'cards/one.json', '--realm', realmUrl, '--json'],
+ { home: emptyHome },
+ );
+ expect(res.exitCode).toBe(1);
+ let result = res.json();
+ expect(result.ok).toBe(false);
+ expect(result.error).toContain('No active profile');
+ } finally {
+ fs.rmSync(emptyHome, { recursive: true, force: true });
+ }
});
});
diff --git a/packages/boxel-cli/tests/integration/file-write.test.ts b/packages/boxel-cli/tests/integration/file-write.test.ts
index 3b211e8ec61..43a65f7f87a 100644
--- a/packages/boxel-cli/tests/integration/file-write.test.ts
+++ b/packages/boxel-cli/tests/integration/file-write.test.ts
@@ -3,44 +3,44 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
-import { write } from '../../src/commands/file/write.ts';
-import { createRealm } from '../../src/commands/realm/create.ts';
import { ProfileManager } from '../../src/lib/profile-manager.ts';
import {
startTestRealmServer,
stopTestRealmServer,
- createTestProfileDir,
+ createTestHome,
+ reloadProfile,
setupTestProfile,
- uniqueRealmName,
+ createTestRealmViaCli,
} from '../helpers/integration.ts';
+import { runBoxel } from '../helpers/run-boxel.ts';
import { TINY_PNG_BYTES, TINY_PDF_BYTES } from '../helpers/binary-fixtures.ts';
-let profileManager: ProfileManager;
+// `boxel file write --realm ` reads content from STDIN (text)
+// or `--file ` (binary). We drive the installed binary and verify
+// the result by reading the file back from the realm with the profile the
+// CLI wrote to disk — the action goes through the CLI, the assertion is a
+// plain in-process fetch.
+
+let home: string;
let cleanupProfile: () => void;
let realmUrl: string;
-async function createTestRealm(): Promise {
- let name = uniqueRealmName();
- await createRealm(name, `Test ${name}`, { profileManager });
-
- let realmTokens =
- profileManager.getActiveProfile()!.profile.realmTokens ?? {};
- let entry = Object.entries(realmTokens).find(([url]) => url.includes(name));
- if (!entry) {
- throw new Error(`No realm JWT stored for ${name}`);
- }
- return entry[0];
+async function readBack(relPath: string): Promise {
+ return reloadProfile(home).authedRealmFetch(`${realmUrl}${relPath}`, {
+ method: 'GET',
+ headers: { Accept: 'application/vnd.card+source' },
+ });
}
beforeAll(async () => {
await startTestRealmServer();
- let testProfile = createTestProfileDir();
- profileManager = testProfile.profileManager;
- cleanupProfile = testProfile.cleanup;
- await setupTestProfile(profileManager);
+ let testHome = createTestHome();
+ home = testHome.home;
+ cleanupProfile = testHome.cleanup;
+ await setupTestProfile(testHome.profileManager);
- realmUrl = await createTestRealm();
+ ({ realmUrl } = await createTestRealmViaCli(home));
});
afterAll(async () => {
@@ -51,16 +51,13 @@ afterAll(async () => {
describe('file write (integration)', () => {
it('writes a .gts file and can read it back from the realm', async () => {
let source = 'export const hello = "world";';
- let writeResult = await write(realmUrl, 'roundtrip.gts', source, {
- profileManager,
- });
- expect(writeResult.ok).toBe(true);
-
- // Verify by reading back via the realm
- let response = await profileManager.authedRealmFetch(
- `${realmUrl}roundtrip.gts`,
- { method: 'GET', headers: { Accept: 'application/vnd.card+source' } },
+ let res = await runBoxel(
+ ['file', 'write', 'roundtrip.gts', '--realm', realmUrl],
+ { home, input: source },
);
+ expect(res.ok, res.stderr).toBe(true);
+
+ let response = await readBack('roundtrip.gts');
expect(response.ok).toBe(true);
let content = await response.text();
expect(content).toContain('hello');
@@ -79,61 +76,71 @@ describe('file write (integration)', () => {
},
},
});
- let writeResult = await write(realmUrl, 'WrittenCard/1.json', card, {
- profileManager,
- });
- expect(writeResult.ok).toBe(true);
-
- let response = await profileManager.authedRealmFetch(
- `${realmUrl}WrittenCard/1.json`,
- { method: 'GET', headers: { Accept: 'application/vnd.card+source' } },
+ let res = await runBoxel(
+ ['file', 'write', 'WrittenCard/1.json', '--realm', realmUrl],
+ { home, input: card },
);
+ expect(res.ok, res.stderr).toBe(true);
+
+ let response = await readBack('WrittenCard/1.json');
expect(response.ok).toBe(true);
let doc = await response.json();
expect((doc as any).data.attributes.title).toBe('Written Card');
});
it('writes a PNG byte-identically and reads it back', async () => {
- let writeResult = await write(realmUrl, 'image.png', TINY_PNG_BYTES, {
- profileManager,
- });
- expect(writeResult.ok, `write failed: ${writeResult.error}`).toBe(true);
+ // Binary content can't ride argv/stdin faithfully, so stage it in a
+ // local file and pass `--file`, the CLI's binary path.
+ let src = path.join(os.tmpdir(), `boxel-write-${Date.now()}.png`);
+ fs.writeFileSync(src, Buffer.from(TINY_PNG_BYTES));
+ try {
+ let res = await runBoxel(
+ ['file', 'write', 'image.png', '--realm', realmUrl, '--file', src],
+ { home },
+ );
+ expect(res.ok, res.stderr).toBe(true);
+ } finally {
+ fs.rmSync(src, { force: true });
+ }
- let response = await profileManager.authedRealmFetch(
- `${realmUrl}image.png`,
- { method: 'GET', headers: { Accept: 'application/vnd.card+source' } },
- );
+ let response = await readBack('image.png');
expect(response.ok).toBe(true);
let remote = Buffer.from(await response.arrayBuffer());
expect(remote.equals(Buffer.from(TINY_PNG_BYTES))).toBe(true);
});
it('writes a PDF byte-identically', async () => {
- let writeResult = await write(realmUrl, 'doc.pdf', TINY_PDF_BYTES, {
- profileManager,
- });
- expect(writeResult.ok).toBe(true);
+ let src = path.join(os.tmpdir(), `boxel-write-${Date.now()}.pdf`);
+ fs.writeFileSync(src, Buffer.from(TINY_PDF_BYTES));
+ try {
+ let res = await runBoxel(
+ ['file', 'write', 'doc.pdf', '--realm', realmUrl, '--file', src],
+ { home },
+ );
+ expect(res.ok, res.stderr).toBe(true);
+ } finally {
+ fs.rmSync(src, { force: true });
+ }
- let response = await profileManager.authedRealmFetch(`${realmUrl}doc.pdf`, {
- method: 'GET',
- headers: { Accept: 'application/vnd.card+source' },
- });
+ let response = await readBack('doc.pdf');
let remote = Buffer.from(await response.arrayBuffer());
expect(remote.equals(Buffer.from(TINY_PDF_BYTES))).toBe(true);
});
- it('returns error result when no active profile', async () => {
- let emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-empty-'));
- let emptyManager = new ProfileManager(emptyDir);
-
+ it('exits non-zero with a clear error when there is no active profile', async () => {
+ let emptyHome = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-empty-'));
+ // Materialize an empty profile store so the CLI reaches the
+ // no-active-profile guard rather than any first-run bootstrapping.
+ new ProfileManager(path.join(emptyHome, '.boxel-cli'));
try {
- let result = await write(realmUrl, 'test.gts', 'content', {
- profileManager: emptyManager,
- });
- expect(result.ok).toBe(false);
- expect(result.error).toContain('No active profile');
+ let res = await runBoxel(
+ ['file', 'write', 'test.gts', '--realm', realmUrl],
+ { home: emptyHome, input: 'content' },
+ );
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toContain('No active profile');
} finally {
- fs.rmSync(emptyDir, { recursive: true, force: true });
+ fs.rmSync(emptyHome, { recursive: true, force: true });
}
});
});
diff --git a/packages/boxel-cli/tests/integration/parse.test.ts b/packages/boxel-cli/tests/integration/parse.test.ts
new file mode 100644
index 00000000000..76c5f3c0a33
--- /dev/null
+++ b/packages/boxel-cli/tests/integration/parse.test.ts
@@ -0,0 +1,84 @@
+import { resolve } from 'node:path';
+import { describe, it, expect } from 'vitest';
+
+import { runBoxel } from '../helpers/run-boxel.ts';
+import type { ParseRealmResult } from '../../src/commands/parse.ts';
+
+// Drives `boxel parse --json` as a subprocess against the CLI binary
+// selected by BOXEL_CLI_BIN (see tests/helpers/run-boxel.ts). Under the
+// tarball / published contexts this runs the npm-hoisted install — the
+// layout where `boxel parse`'s glint type-check silently resolves
+// nothing, which no in-process, function-call test could reach.
+//
+// Each fixture is a plain realm-workspace directory of card code. parse
+// defaults to type-checking the current working directory, so we point
+// the subprocess `cwd` at the fixture — no copying, parse writes nothing
+// to it.
+
+const FIXTURES_DIR = resolve(import.meta.dirname, '../fixtures/parse');
+
+// Message the CLI emits when ember-tsc exits non-zero but produced zero
+// TS diagnostics — i.e. glint resolved nothing and checked nothing. A
+// "pass" that never actually type-checked. No fixture may surface this.
+const NOTHING_CHECKED = 'produced no TS diagnostics';
+
+async function parseFixture(name: string): Promise {
+ let res = await runBoxel(['parse', '--json'], {
+ cwd: resolve(FIXTURES_DIR, name),
+ });
+ return res.json();
+}
+
+// Cards that must type-check clean once parse works against an npm
+// install. Each targets a resolution surface the CLI's bundled types /
+// tsconfig aliases have to cover.
+const CLEAN_FIXTURES: { name: string; covers: string }[] = [
+ {
+ name: 'plain-glimmer',
+ covers: '@glimmer/component + @tracked + contains(NumberField)',
+ },
+ {
+ name: 'tracked-format-class',
+ covers: '@tracked inside a static isolated format class',
+ },
+ { name: 'runtime-common', covers: 'bare @cardstack/runtime-common import' },
+ { name: 'boxel-host-tools', covers: '@cardstack/boxel-host/tools/* import' },
+ {
+ name: 'helpers-and-fields',
+ covers: 'positional formatDateTime + field interpolation',
+ },
+];
+
+describe('boxel parse (against the installed CLI)', () => {
+ describe.each(CLEAN_FIXTURES)('$name — $covers', ({ name }) => {
+ it(
+ 'type-checks clean',
+ async () => {
+ let result = await parseFixture(name);
+ // Surface the actual diagnostics on failure instead of a bare
+ // count mismatch.
+ expect(result.errors).toEqual([]);
+ expect(result.status).toBe('passed');
+ expect(result.filesChecked).toBeGreaterThanOrEqual(1);
+ },
+ { timeout: 180_000 },
+ );
+ });
+
+ it(
+ 'surfaces a real diagnostic for a genuine type error (proves glint ran)',
+ async () => {
+ let result = await parseFixture('deliberate-type-error');
+ expect(result.status).toBe('failed');
+ expect(result.errorCount).toBeGreaterThanOrEqual(1);
+
+ let messages = result.errors.map((e) => e.message).join('\n');
+ // A real TS2322 from a genuine type mismatch…
+ expect(messages).toMatch(/not assignable to type 'number'/);
+ // …and specifically NOT the environmental "nothing got checked"
+ // message that masks a broken type-resolution setup as errors.
+ expect(messages).not.toContain(NOTHING_CHECKED);
+ },
+ { timeout: 180_000 },
+ );
+});
diff --git a/packages/boxel-cli/tests/integration/profile-add.test.ts b/packages/boxel-cli/tests/integration/profile-add.test.ts
index eb10263281e..0843af59b93 100644
--- a/packages/boxel-cli/tests/integration/profile-add.test.ts
+++ b/packages/boxel-cli/tests/integration/profile-add.test.ts
@@ -1,8 +1,7 @@
import '../helpers/setup-realm-server.ts';
-import { execFileSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
-import { resolve, join } from 'path';
+import { join } from 'path';
import {
describe,
it,
@@ -20,8 +19,8 @@ import {
TEST_PASSWORD,
matrixURL,
} from '../helpers/integration.ts';
+import { runBoxel } from '../helpers/run-boxel.ts';
-const cliEntry = resolve(__dirname, '../../dist/index.js');
const realmServerUrl = `${TEST_REALM_SERVER_URL}/`;
const matrixId = `@${TEST_USERNAME}:localhost`;
@@ -35,10 +34,12 @@ afterAll(async () => {
await stopTestRealmServer();
});
-// These tests subprocess the built CLI binary (packages/boxel-cli/dist) and
+// These tests drive the built CLI as a subprocess through the shared
+// runBoxel harness (which selects the binary via BOXEL_CLI_BIN) and
// exercise the happy-path `profile add` flow that CS-10725 made
-// network-bound. They moved here from tests/smoke.test.ts so they can hit
-// the dockerised Synapse + realm-server rather than the public internet.
+// network-bound. They moved here from tests/smoke.test.ts so they can
+// hit the dockerised Synapse + realm-server rather than the public
+// internet.
describe('boxel profile add (integration, subprocess)', () => {
let tmpHome: string;
@@ -50,40 +51,33 @@ describe('boxel profile add (integration, subprocess)', () => {
fs.rmSync(tmpHome, { recursive: true, force: true });
});
- const sanitizedParentEnv = () =>
- Object.fromEntries(
- Object.entries(process.env).filter(([key]) => !key.startsWith('BOXEL_')),
- );
-
- // Wraps execFileSync with the shared HOME + BOXEL_PASSWORD env and any
- // caller-supplied flags. Tests opt in to BOXEL_ENVIRONMENT etc. via
- // extraEnv. All invocations point at the in-process Synapse + realm
+ // Drive `boxel profile add` with the shared HOME + BOXEL_PASSWORD env
+ // and any caller-supplied flags. Tests opt in to BOXEL_ENVIRONMENT etc.
+ // via extraEnv. All invocations point at the in-process Synapse + realm
// server unless the test overrides --matrix-url / --realm-server-url.
- const run = (args: string[], extraEnv: NodeJS.ProcessEnv = {}) =>
- execFileSync(process.execPath, [cliEntry, 'profile', 'add', ...args], {
- encoding: 'utf8',
- env: {
- ...sanitizedParentEnv(),
- HOME: tmpHome,
- BOXEL_PASSWORD: TEST_PASSWORD,
- ...extraEnv,
- },
- stdio: ['ignore', 'pipe', 'pipe'],
+ // runBoxel strips the parent env's BOXEL_* vars, so extraEnv is the only
+ // source of those for the subprocess. Fails the test if the command
+ // exits non-zero (the successful-add precondition every caller relies on).
+ const run = async (args: string[], extraEnv: NodeJS.ProcessEnv = {}) => {
+ let res = await runBoxel(['profile', 'add', ...args], {
+ home: tmpHome,
+ env: { BOXEL_PASSWORD: TEST_PASSWORD, ...extraEnv },
});
+ expect(res.ok, res.stderr).toBe(true);
+ return res.stdout;
+ };
const readProfiles = () =>
JSON.parse(
fs.readFileSync(join(tmpHome, '.boxel-cli', 'profiles.json'), 'utf8'),
);
- it('--quiet silences the success line and still writes the profile', () => {
+ it('--quiet silences the success line and still writes the profile', async () => {
// End-to-end check that `--quiet` (a global flag, so it comes before
// `profile`) swallows the "Profile created" line while the on-disk
// side-effect still happens.
- const stdout = execFileSync(
- process.execPath,
+ const res = await runBoxel(
[
- cliEntry,
'--quiet',
'profile',
'add',
@@ -94,24 +88,17 @@ describe('boxel profile add (integration, subprocess)', () => {
'-r',
realmServerUrl,
],
- {
- encoding: 'utf8',
- env: {
- ...sanitizedParentEnv(),
- HOME: tmpHome,
- BOXEL_PASSWORD: TEST_PASSWORD,
- },
- stdio: ['ignore', 'pipe', 'pipe'],
- },
+ { home: tmpHome, env: { BOXEL_PASSWORD: TEST_PASSWORD } },
);
- expect(stdout).toBe('');
+ expect(res.ok, res.stderr).toBe(true);
+ expect(res.stdout).toBe('');
expect(fs.existsSync(join(tmpHome, '.boxel-cli', 'profiles.json'))).toBe(
true,
);
});
- it('emits the "Profile created" line normally without --quiet', () => {
- const stdout = run([
+ it('emits the "Profile created" line normally without --quiet', async () => {
+ const stdout = await run([
'-u',
matrixId,
'-m',
@@ -122,8 +109,8 @@ describe('boxel profile add (integration, subprocess)', () => {
expect(stdout).toMatch(/Profile created/);
});
- it('writes matrixAccessToken (not password) for a non-standard domain with URL flags', () => {
- run(['-u', matrixId, '-m', matrixURL.href, '-r', realmServerUrl]);
+ it('writes matrixAccessToken (not password) for a non-standard domain with URL flags', async () => {
+ await run(['-u', matrixId, '-m', matrixURL.href, '-r', realmServerUrl]);
const config = readProfiles();
const profile = config.profiles[matrixId];
@@ -138,8 +125,8 @@ describe('boxel profile add (integration, subprocess)', () => {
expect(profile.password).toBeUndefined();
});
- it('trims whitespace from URL flag values', () => {
- run([
+ it('trims whitespace from URL flag values', async () => {
+ await run([
'-u',
matrixId,
'-m',
@@ -155,10 +142,10 @@ describe('boxel profile add (integration, subprocess)', () => {
});
});
- it('lets --matrix-url and --realm-server-url override BOXEL_ENVIRONMENT', () => {
+ it('lets --matrix-url and --realm-server-url override BOXEL_ENVIRONMENT', async () => {
// BOXEL_ENVIRONMENT would normally derive
// http://matrix.cs-10998-foo.localhost — explicit flags must win.
- run(['-u', matrixId, '-m', matrixURL.href, '-r', realmServerUrl], {
+ await run(['-u', matrixId, '-m', matrixURL.href, '-r', realmServerUrl], {
BOXEL_ENVIRONMENT: 'cs-10998-foo',
});
@@ -169,11 +156,11 @@ describe('boxel profile add (integration, subprocess)', () => {
});
});
- it('ignores an invalid BOXEL_ENVIRONMENT when both URL flags are supplied', () => {
+ it('ignores an invalid BOXEL_ENVIRONMENT when both URL flags are supplied', async () => {
// If both URLs are explicit, BOXEL_ENVIRONMENT is never consulted —
// even a value that would normally exit 1 (slugifies to empty) must
// not block the command.
- run(['-u', matrixId, '-m', matrixURL.href, '-r', realmServerUrl], {
+ await run(['-u', matrixId, '-m', matrixURL.href, '-r', realmServerUrl], {
BOXEL_ENVIRONMENT: '!!!',
});
@@ -184,17 +171,17 @@ describe('boxel profile add (integration, subprocess)', () => {
});
});
- it('refreshes the stored access token when re-adding an existing profile', () => {
+ it('refreshes the stored access token when re-adding an existing profile', async () => {
// Pre-CS-10725 this test verified that re-running `profile add` with
// different URLs updated the stored URLs. After CS-10725 we can no
// longer freely substitute fake URLs (both runs need to actually log
// in), so the test instead verifies the new, more important property:
// re-running addProfile against the same URLs produces a fresh
// matrixAccessToken and matrixDeviceId.
- run(['-u', matrixId, '-m', matrixURL.href, '-r', realmServerUrl]);
+ await run(['-u', matrixId, '-m', matrixURL.href, '-r', realmServerUrl]);
const first = readProfiles().profiles[matrixId];
- run(['-u', matrixId, '-m', matrixURL.href, '-r', realmServerUrl]);
+ await run(['-u', matrixId, '-m', matrixURL.href, '-r', realmServerUrl]);
const second = readProfiles().profiles[matrixId];
expect(second.matrixAccessToken).not.toBe(first.matrixAccessToken);
diff --git a/packages/boxel-cli/tests/integration/read-transpiled.test.ts b/packages/boxel-cli/tests/integration/read-transpiled.test.ts
index 518e4773609..6b89f555acd 100644
--- a/packages/boxel-cli/tests/integration/read-transpiled.test.ts
+++ b/packages/boxel-cli/tests/integration/read-transpiled.test.ts
@@ -3,16 +3,28 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
-import { readTranspiledModule } from '../../src/commands/read-transpiled.ts';
+import {
+ readTranspiledModule,
+ type ReadTranspiledResult,
+} from '../../src/commands/read-transpiled.ts';
import { ProfileManager } from '../../src/lib/profile-manager.ts';
import {
startTestRealmServer,
stopTestRealmServer,
- createTestProfileDir,
+ createTestHome,
setupTestProfile,
TEST_REALM_SERVER_URL,
} from '../helpers/integration.ts';
+import { runBoxel } from '../helpers/run-boxel.ts';
+
+// `boxel read-transpiled --realm [--json]` fetches a realm
+// module's transpiled JS. We drive the installed binary for the
+// observable behaviors (compiled output, extension handling, 404s,
+// no-profile). Two assertions inspect the exact outgoing request /
+// force a fetch rejection — surfaces only reachable in-process — so they
+// stay as in-process spies on the command function.
+let home: string;
let profileManager: ProfileManager;
let cleanupProfile: () => void;
let realmUrl: string;
@@ -47,9 +59,10 @@ beforeAll(async () => {
realmUrl = `${TEST_REALM_SERVER_URL}/test/`;
- let testProfile = createTestProfileDir();
- profileManager = testProfile.profileManager;
- cleanupProfile = testProfile.cleanup;
+ let testHome = createTestHome();
+ home = testHome.home;
+ profileManager = testHome.profileManager;
+ cleanupProfile = testHome.cleanup;
await setupTestProfile(profileManager);
});
@@ -60,9 +73,18 @@ afterAll(async () => {
describe('read-transpiled (integration)', () => {
it('returns the compiled JavaScript for a .gts module (path with extension)', async () => {
- let result = await readTranspiledModule(realmUrl, 'transpiled-check.gts', {
- profileManager,
- });
+ let res = await runBoxel(
+ [
+ 'read-transpiled',
+ 'transpiled-check.gts',
+ '--realm',
+ realmUrl,
+ '--json',
+ ],
+ { home },
+ );
+ expect(res.ok, res.stderr).toBe(true);
+ let result = res.json();
expect(
result.ok,
@@ -85,19 +107,35 @@ describe('read-transpiled (integration)', () => {
});
it('accepts the path without the .gts extension', async () => {
- let withExt = await readTranspiledModule(realmUrl, 'transpiled-check.gts', {
- profileManager,
- });
- let withoutExt = await readTranspiledModule(realmUrl, 'transpiled-check', {
- profileManager,
- });
-
- expect(withoutExt.ok).toBe(true);
- expect(withoutExt.status).toBe(200);
- expect(withoutExt.content).toBe(withExt.content);
+ let withExt = await runBoxel(
+ [
+ 'read-transpiled',
+ 'transpiled-check.gts',
+ '--realm',
+ realmUrl,
+ '--json',
+ ],
+ { home },
+ );
+ let withoutExt = await runBoxel(
+ ['read-transpiled', 'transpiled-check', '--realm', realmUrl, '--json'],
+ { home },
+ );
+
+ expect(withExt.ok, withExt.stderr).toBe(true);
+ expect(withoutExt.ok, withoutExt.stderr).toBe(true);
+ let withExtResult = withExt.json();
+ let withoutExtResult = withoutExt.json();
+
+ expect(withoutExtResult.ok).toBe(true);
+ expect(withoutExtResult.status).toBe(200);
+ expect(withoutExtResult.content).toBe(withExtResult.content);
});
it('uses authedRealmFetch with Accept: */*', async () => {
+ // White-box: asserts the exact request the command function issues.
+ // The outgoing fetch shape isn't observable across the subprocess
+ // boundary, so this stays an in-process spy on the command function.
let fetchSpy = vi.spyOn(profileManager, 'authedRealmFetch');
try {
await readTranspiledModule(realmUrl, 'transpiled-check.gts', {
@@ -116,28 +154,38 @@ describe('read-transpiled (integration)', () => {
});
it('returns a not-ok result with 404 status for a nonexistent module', async () => {
- let result = await readTranspiledModule(realmUrl, 'does-not-exist', {
- profileManager,
- });
+ let res = await runBoxel(
+ ['read-transpiled', 'does-not-exist', '--realm', realmUrl, '--json'],
+ { home },
+ );
+ expect(res.exitCode).toBe(1);
+ let result = res.json();
expect(result.ok).toBe(false);
expect(result.status).toBe(404);
expect(result.error).toContain('404');
});
- it('throws when no active profile', async () => {
- let emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-empty-'));
- let emptyManager = new ProfileManager(emptyDir);
-
- await expect(
- readTranspiledModule(realmUrl, 'transpiled-check.gts', {
- profileManager: emptyManager,
- }),
- ).rejects.toThrow('No active profile');
-
- fs.rmSync(emptyDir, { recursive: true, force: true });
+ it('exits non-zero with a clear error when there is no active profile', async () => {
+ let emptyHome = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-empty-'));
+ // Materialize an empty profile store so the CLI reaches the
+ // no-active-profile guard rather than any first-run bootstrapping.
+ new ProfileManager(path.join(emptyHome, '.boxel-cli'));
+ try {
+ let res = await runBoxel(
+ ['read-transpiled', 'transpiled-check.gts', '--realm', realmUrl],
+ { home: emptyHome },
+ );
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toContain('No active profile');
+ } finally {
+ fs.rmSync(emptyHome, { recursive: true, force: true });
+ }
});
it('returns error when fetch throws', async () => {
+ // White-box: forces the underlying fetch to reject, which is only
+ // possible by mocking the command function's authenticator
+ // in-process.
let fetchSpy = vi
.spyOn(profileManager, 'authedRealmFetch')
.mockRejectedValueOnce(new Error('network failure'));
diff --git a/packages/boxel-cli/tests/integration/realm-archive.test.ts b/packages/boxel-cli/tests/integration/realm-archive.test.ts
index 8a44b6368df..81e055c597b 100644
--- a/packages/boxel-cli/tests/integration/realm-archive.test.ts
+++ b/packages/boxel-cli/tests/integration/realm-archive.test.ts
@@ -3,31 +3,45 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
-import { createRealm } from '../../src/commands/realm/create.ts';
-import { archiveRealm } from '../../src/commands/realm/archive.ts';
-import { restoreRealm } from '../../src/commands/realm/restore.ts';
import { ProfileManager } from '../../src/lib/profile-manager.ts';
import {
startTestRealmServer,
stopTestRealmServer,
- createTestProfileDir,
+ createTestHome,
setupTestProfile,
- uniqueRealmName,
+ createTestRealmViaCli,
registerUser,
matrixURL,
matrixRegistrationSecret,
TEST_REALM_SERVER_URL,
} from '../helpers/integration.ts';
+import { runBoxel } from '../helpers/run-boxel.ts';
-let profileManager: ProfileManager;
+// `boxel realm archive --yes` and `boxel realm restore ` are
+// owner-only mutations that print a human confirmation line and exit
+// non-zero on failure. We drive the installed binary and verify the
+// resulting archived/restored state via `realm list --json`.
+
+let home: string;
let cleanupProfile: () => void;
+interface ListResult {
+ realms: { url: string; hidden: boolean; archived: boolean }[];
+ error?: string;
+}
+
+async function listCli(flags: string[] = []): Promise {
+ let res = await runBoxel(['realm', 'list', '--json', ...flags], { home });
+ expect(res.ok, res.stderr).toBe(true);
+ return res.json();
+}
+
beforeAll(async () => {
await startTestRealmServer();
- let testProfile = createTestProfileDir();
- profileManager = testProfile.profileManager;
- cleanupProfile = testProfile.cleanup;
- await setupTestProfile(profileManager);
+ let testHome = createTestHome();
+ home = testHome.home;
+ cleanupProfile = testHome.cleanup;
+ await setupTestProfile(testHome.profileManager);
});
afterAll(async () => {
@@ -37,40 +51,36 @@ afterAll(async () => {
describe('realm archive (integration)', () => {
it('archives a realm for the owner', async () => {
- let name = uniqueRealmName();
- let { realmUrl } = await createRealm(name, `Test ${name}`, {
- profileManager,
- });
+ let { realmUrl } = await createTestRealmViaCli(home);
- let result = await archiveRealm({ realmUrl, profileManager });
+ let res = await runBoxel(['realm', 'archive', realmUrl, '--yes'], { home });
+ expect(res.ok, res.stderr).toBe(true);
+ expect(res.stdout).toContain('Archived:');
+ expect(res.stdout).toContain(realmUrl);
- expect(result.error).toBeUndefined();
- expect(result.archived).toBe(true);
- expect(result.realmUrl).toBe(realmUrl);
+ let listed = await listCli(['--include-archived']);
+ let entry = listed.realms.find((r) => r.url === realmUrl);
+ expect(entry?.archived).toBe(true);
});
it('normalizes a trailing-slash-less input', async () => {
- let name = uniqueRealmName();
- let { realmUrl } = await createRealm(name, `Test ${name}`, {
- profileManager,
- });
+ let { realmUrl } = await createTestRealmViaCli(home);
let withoutSlash = realmUrl.replace(/\/$/, '');
- let result = await archiveRealm({
- realmUrl: withoutSlash,
- profileManager,
+ let res = await runBoxel(['realm', 'archive', withoutSlash, '--yes'], {
+ home,
});
+ expect(res.ok, res.stderr).toBe(true);
+ // The command normalizes and echoes the trailing-slash form.
+ expect(res.stdout).toContain(realmUrl);
- expect(result.error).toBeUndefined();
- expect(result.archived).toBe(true);
- expect(result.realmUrl).toBe(realmUrl);
+ let listed = await listCli(['--include-archived']);
+ let entry = listed.realms.find((r) => r.url === realmUrl);
+ expect(entry?.archived).toBe(true);
});
it('returns a 403 error when the caller does not own the realm', async () => {
- let realmName = uniqueRealmName();
- let { realmUrl } = await createRealm(realmName, `Test ${realmName}`, {
- profileManager,
- });
+ let { realmUrl } = await createTestRealmViaCli(home);
let userBSuffix = `userb-${Date.now()}-${Math.random()
.toString(36)
@@ -85,9 +95,9 @@ describe('realm archive (integration)', () => {
registrationSecret: matrixRegistrationSecret,
});
- let userBProfile = createTestProfileDir();
+ let userBHome = createTestHome();
try {
- await userBProfile.profileManager.addProfile(
+ await userBHome.profileManager.addProfile(
`@${userBUsername}:localhost`,
userBPassword,
'CLI Test User B',
@@ -95,54 +105,59 @@ describe('realm archive (integration)', () => {
`${TEST_REALM_SERVER_URL}/`,
);
- let result = await archiveRealm({
- realmUrl,
- profileManager: userBProfile.profileManager,
+ let res = await runBoxel(['realm', 'archive', realmUrl, '--yes'], {
+ home: userBHome.home,
});
- expect(result.archived).toBe(false);
- expect(result.error).toMatch(/403/);
- expect(result.error).toMatch(/do not own this realm/);
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toMatch(/403/);
+ expect(res.stderr).toMatch(/do not own this realm/);
} finally {
- userBProfile.cleanup();
+ userBHome.cleanup();
}
});
it('returns an error when no active profile', async () => {
- let emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-empty-'));
- let emptyManager = new ProfileManager(emptyDir);
- let result = await archiveRealm({
- realmUrl: `${TEST_REALM_SERVER_URL}/anything/`,
- profileManager: emptyManager,
- });
- expect(result.archived).toBe(false);
- expect(result.error).toContain('No active profile');
- fs.rmSync(emptyDir, { recursive: true, force: true });
+ let emptyHome = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-empty-'));
+ new ProfileManager(path.join(emptyHome, '.boxel-cli'));
+ try {
+ let res = await runBoxel(
+ ['realm', 'archive', `${TEST_REALM_SERVER_URL}/anything/`, '--yes'],
+ { home: emptyHome },
+ );
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toContain('No active profile');
+ } finally {
+ fs.rmSync(emptyHome, { recursive: true, force: true });
+ }
});
});
describe('realm restore (integration)', () => {
it('restores a previously archived realm for the owner', async () => {
- let name = uniqueRealmName();
- let { realmUrl } = await createRealm(name, `Test ${name}`, {
- profileManager,
+ let { realmUrl } = await createTestRealmViaCli(home);
+ let archive = await runBoxel(['realm', 'archive', realmUrl, '--yes'], {
+ home,
});
- let archive = await archiveRealm({ realmUrl, profileManager });
- expect(archive.archived).toBe(true);
+ expect(archive.ok, archive.stderr).toBe(true);
- let result = await restoreRealm({ realmUrl, profileManager });
+ let res = await runBoxel(['realm', 'restore', realmUrl], { home });
+ expect(res.ok, res.stderr).toBe(true);
+ expect(res.stdout).toContain('Restored:');
+ expect(res.stdout).toContain(realmUrl);
- expect(result.error).toBeUndefined();
- expect(result.restored).toBe(true);
- expect(result.realmUrl).toBe(realmUrl);
+ // A restored realm is no longer archived and reappears in the default
+ // (non-archived) list.
+ let listed = await listCli();
+ expect(listed.realms.map((r) => r.url)).toContain(realmUrl);
});
it('returns a 403 error when the caller does not own the realm', async () => {
- let realmName = uniqueRealmName();
- let { realmUrl } = await createRealm(realmName, `Test ${realmName}`, {
- profileManager,
+ let { realmUrl } = await createTestRealmViaCli(home);
+ let archive = await runBoxel(['realm', 'archive', realmUrl, '--yes'], {
+ home,
});
- await archiveRealm({ realmUrl, profileManager });
+ expect(archive.ok, archive.stderr).toBe(true);
let userBSuffix = `userb-${Date.now()}-${Math.random()
.toString(36)
@@ -157,9 +172,9 @@ describe('realm restore (integration)', () => {
registrationSecret: matrixRegistrationSecret,
});
- let userBProfile = createTestProfileDir();
+ let userBHome = createTestHome();
try {
- await userBProfile.profileManager.addProfile(
+ await userBHome.profileManager.addProfile(
`@${userBUsername}:localhost`,
userBPassword,
'CLI Test User B',
@@ -167,31 +182,33 @@ describe('realm restore (integration)', () => {
`${TEST_REALM_SERVER_URL}/`,
);
- let result = await restoreRealm({
- realmUrl,
- profileManager: userBProfile.profileManager,
+ let res = await runBoxel(['realm', 'restore', realmUrl], {
+ home: userBHome.home,
});
- expect(result.restored).toBe(false);
- expect(result.error).toMatch(/403/);
- expect(result.error).toMatch(/do not own this realm/);
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toMatch(/403/);
+ expect(res.stderr).toMatch(/do not own this realm/);
} finally {
- userBProfile.cleanup();
+ userBHome.cleanup();
}
// Cleanup: restore the realm so it doesn't leak into other tests.
- await restoreRealm({ realmUrl, profileManager });
+ await runBoxel(['realm', 'restore', realmUrl], { home });
});
it('returns an error when no active profile', async () => {
- let emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-empty-'));
- let emptyManager = new ProfileManager(emptyDir);
- let result = await restoreRealm({
- realmUrl: `${TEST_REALM_SERVER_URL}/anything/`,
- profileManager: emptyManager,
- });
- expect(result.restored).toBe(false);
- expect(result.error).toContain('No active profile');
- fs.rmSync(emptyDir, { recursive: true, force: true });
+ let emptyHome = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-empty-'));
+ new ProfileManager(path.join(emptyHome, '.boxel-cli'));
+ try {
+ let res = await runBoxel(
+ ['realm', 'restore', `${TEST_REALM_SERVER_URL}/anything/`],
+ { home: emptyHome },
+ );
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toContain('No active profile');
+ } finally {
+ fs.rmSync(emptyHome, { recursive: true, force: true });
+ }
});
});
diff --git a/packages/boxel-cli/tests/integration/realm-cancel-indexing.test.ts b/packages/boxel-cli/tests/integration/realm-cancel-indexing.test.ts
index 7f4fd343828..9b75f5622b1 100644
--- a/packages/boxel-cli/tests/integration/realm-cancel-indexing.test.ts
+++ b/packages/boxel-cli/tests/integration/realm-cancel-indexing.test.ts
@@ -8,21 +8,35 @@ import { ProfileManager } from '../../src/lib/profile-manager.ts';
import {
startTestRealmServer,
stopTestRealmServer,
- createTestProfileDir,
+ createTestHome,
setupTestProfile,
TEST_REALM_SERVER_URL,
} from '../helpers/integration.ts';
+import { runBoxel } from '../helpers/run-boxel.ts';
-let profileManager: ProfileManager;
+// Drives `boxel realm cancel-indexing --realm ` as a subprocess. The
+// command POSTs `{ cancelPending }` to `/_cancel-indexing-job`
+// (running-only by default; `--cancel-pending` also drains the queue) and
+// exits 0 on the server's 2xx, 1 with the reason on stderr otherwise.
+// Because the request body isn't observable across the process boundary,
+// the two flag forms are exercised end-to-end (each hits a distinct server
+// branch) rather than asserted on the wire.
+
+let home: string;
let cleanupProfile: () => void;
let realmUrl: string;
+// Retained for the one white-box case below (mocking a non-2xx realm
+// response) — impossible to reproduce across the subprocess boundary, so
+// it stays an in-process call against the command function.
+let profileManager: ProfileManager;
beforeAll(async () => {
await startTestRealmServer();
realmUrl = `${TEST_REALM_SERVER_URL}/test/`;
- let testProfile = createTestProfileDir();
- profileManager = testProfile.profileManager;
- cleanupProfile = testProfile.cleanup;
+ let testHome = createTestHome();
+ home = testHome.home;
+ cleanupProfile = testHome.cleanup;
+ profileManager = testHome.profileManager;
await setupTestProfile(profileManager);
});
@@ -33,56 +47,43 @@ afterAll(async () => {
describe('realm cancel-indexing (integration)', () => {
it('cancels indexing on a running realm and returns ok', async () => {
- let result = await cancelIndexing(realmUrl, { profileManager });
- expect(result.ok).toBe(true);
- expect(result.error).toBeUndefined();
+ let res = await runBoxel(
+ ['realm', 'cancel-indexing', '--realm', realmUrl],
+ { home },
+ );
+ expect(res.ok, res.stderr).toBe(true);
});
it('returns error for an unreachable realm', async () => {
- let result = await cancelIndexing('http://127.0.0.1:1/fake/', {
- profileManager,
- });
- expect(result.ok).toBe(false);
- expect(result.error).toBeDefined();
+ let res = await runBoxel(
+ ['realm', 'cancel-indexing', '--realm', 'http://127.0.0.1:1/fake/'],
+ { home },
+ );
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).not.toBe('');
});
- it('POSTs `{ cancelPending: false }` by default (running-only)', async () => {
- let fetchSpy = vi.spyOn(profileManager, 'authedRealmFetch');
- try {
- await cancelIndexing(realmUrl, { profileManager });
-
- expect(fetchSpy).toHaveBeenCalledOnce();
- let [url, init] = fetchSpy.mock.calls[0];
- expect(String(url)).toBe(`${realmUrl}_cancel-indexing-job`);
- expect(init!.method).toBe('POST');
- let headers = init!.headers as Record;
- expect(headers['Content-Type']).toBe('application/json');
- expect(headers['Accept']).toBe('application/json');
- expect(JSON.parse(init!.body as string)).toEqual({
- cancelPending: false,
- });
- } finally {
- fetchSpy.mockRestore();
- }
+ it('cancels running-only by default (no --cancel-pending)', async () => {
+ let res = await runBoxel(
+ ['realm', 'cancel-indexing', '--realm', realmUrl],
+ { home },
+ );
+ expect(res.ok, res.stderr).toBe(true);
});
- it('POSTs `{ cancelPending: true }` when cancelPending option is set', async () => {
- let fetchSpy = vi.spyOn(profileManager, 'authedRealmFetch');
- try {
- await cancelIndexing(realmUrl, {
- profileManager,
- cancelPending: true,
- });
-
- expect(fetchSpy).toHaveBeenCalledOnce();
- let [, init] = fetchSpy.mock.calls[0];
- expect(JSON.parse(init!.body as string)).toEqual({ cancelPending: true });
- } finally {
- fetchSpy.mockRestore();
- }
+ it('cancels running and pending jobs when --cancel-pending is set', async () => {
+ let res = await runBoxel(
+ ['realm', 'cancel-indexing', '--realm', realmUrl, '--cancel-pending'],
+ { home },
+ );
+ expect(res.ok, res.stderr).toBe(true);
});
it('returns an error with HTTP status when the realm responds non-2xx', async () => {
+ // White-box: a deterministic non-2xx response can't be produced
+ // black-box (an unknown/unauthorized realm makes authedRealmFetch
+ // throw, not return a status), so this stays an in-process call with
+ // a mocked fetch to cover the error-formatting path.
let fetchSpy = vi
.spyOn(profileManager, 'authedRealmFetch')
.mockResolvedValueOnce(
@@ -102,13 +103,19 @@ describe('realm cancel-indexing (integration)', () => {
});
it('returns error result when no active profile', async () => {
- let emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-empty-'));
- let emptyManager = new ProfileManager(emptyDir);
- let result = await cancelIndexing(realmUrl, {
- profileManager: emptyManager,
- });
- expect(result.ok).toBe(false);
- expect(result.error).toContain('No active profile');
- fs.rmSync(emptyDir, { recursive: true, force: true });
+ let emptyHome = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-empty-'));
+ // Materialize an empty profile store so the CLI reaches the
+ // no-active-profile guard rather than any first-run bootstrapping.
+ new ProfileManager(path.join(emptyHome, '.boxel-cli'));
+ try {
+ let res = await runBoxel(
+ ['realm', 'cancel-indexing', '--realm', realmUrl],
+ { home: emptyHome },
+ );
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toContain('No active profile');
+ } finally {
+ fs.rmSync(emptyHome, { recursive: true, force: true });
+ }
});
});
diff --git a/packages/boxel-cli/tests/integration/realm-create.test.ts b/packages/boxel-cli/tests/integration/realm-create.test.ts
index 89e4622ae61..185f29e37f3 100644
--- a/packages/boxel-cli/tests/integration/realm-create.test.ts
+++ b/packages/boxel-cli/tests/integration/realm-create.test.ts
@@ -1,25 +1,31 @@
import '../helpers/setup-realm-server.ts';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
-import { createRealm } from '../../src/commands/realm/create.ts';
import {
startTestRealmServer,
stopTestRealmServer,
- createTestProfileDir,
+ createTestHome,
+ reloadProfile,
setupTestProfile,
uniqueRealmName,
} from '../helpers/integration.ts';
-import type { ProfileManager } from '../../src/lib/profile-manager.ts';
+import { runBoxel } from '../helpers/run-boxel.ts';
-let profileManager: ProfileManager;
+// `boxel realm create ` creates a realm on the
+// realm server and stores its JWT in the profile on disk. We drive the
+// installed binary and read the resulting profile back with
+// `reloadProfile(home)` — the seeded manager's in-memory copy is stale once
+// the subprocess has written.
+
+let home: string;
let cleanup: () => void;
beforeAll(async () => {
await startTestRealmServer();
- let testProfile = createTestProfileDir();
- profileManager = testProfile.profileManager;
- cleanup = testProfile.cleanup;
- await setupTestProfile(profileManager);
+ let testHome = createTestHome();
+ home = testHome.home;
+ cleanup = testHome.cleanup;
+ await setupTestProfile(testHome.profileManager);
});
afterAll(async () => {
@@ -31,9 +37,14 @@ describe('realm create (integration)', () => {
it('creates a realm and stores the JWT in the profile', async () => {
let realmName = uniqueRealmName();
- await createRealm(realmName, `Test ${realmName}`, { profileManager });
+ let res = await runBoxel(
+ ['realm', 'create', realmName, `Test ${realmName}`],
+ { home },
+ );
+ expect(res.ok, res.stderr).toBe(true);
- let active = profileManager.getActiveProfile()!;
+ let pm = reloadProfile(home);
+ let active = pm.getActiveProfile()!;
let realmTokens = active.profile.realmTokens ?? {};
let storedToken = Object.entries(realmTokens).find(([url]) =>
url.includes(realmName),
@@ -41,21 +52,26 @@ describe('realm create (integration)', () => {
expect(storedToken).toBeDefined();
expect(storedToken!.length).toBeGreaterThan(0);
- expect(profileManager.getRealmServerToken()).toBeDefined();
+ expect(pm.getRealmServerToken()).toBeDefined();
});
it('creates another realm reusing the cached server token', async () => {
- let cachedToken = profileManager.getRealmServerToken();
+ let cachedToken = reloadProfile(home).getRealmServerToken();
expect(cachedToken).toBeDefined();
let realmName = uniqueRealmName();
- await createRealm(realmName, `Test ${realmName}`, { profileManager });
+ let res = await runBoxel(
+ ['realm', 'create', realmName, `Test ${realmName}`],
+ { home },
+ );
+ expect(res.ok, res.stderr).toBe(true);
- // Server token was reused, not re-fetched
- expect(profileManager.getRealmServerToken()).toBe(cachedToken);
+ let pm = reloadProfile(home);
+ // Server token was reused (still valid on disk), not re-fetched.
+ expect(pm.getRealmServerToken()).toBe(cachedToken);
- let active = profileManager.getActiveProfile()!;
+ let active = pm.getActiveProfile()!;
let realmTokens = active.profile.realmTokens ?? {};
let storedToken = Object.entries(realmTokens).find(([url]) =>
url.includes(realmName),
diff --git a/packages/boxel-cli/tests/integration/realm-history.test.ts b/packages/boxel-cli/tests/integration/realm-history.test.ts
index 916c6186141..ffb7d62c997 100644
--- a/packages/boxel-cli/tests/integration/realm-history.test.ts
+++ b/packages/boxel-cli/tests/integration/realm-history.test.ts
@@ -2,13 +2,19 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
-import { realmHistory } from '../../src/commands/realm/history.ts';
import { CheckpointManager } from '../../src/lib/checkpoint-manager.ts';
+import { runBoxel } from '../helpers/run-boxel.ts';
+
+// `boxel realm history ` views, creates, and restores local
+// checkpoints in the workspace's `.boxel-history/` git repo — pure local,
+// no realm server. We drive the installed binary; checkpoint setup and
+// on-disk verification stay in-process via `CheckpointManager` (the command
+// has no `--json`, so its own output is asserted from stdout/stderr).
let workspaceDir: string;
function writeFile(relPath: string, content: string): void {
- const full = path.join(workspaceDir, relPath);
+ let full = path.join(workspaceDir, relPath);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content, 'utf8');
}
@@ -24,49 +30,76 @@ afterEach(() => {
describe('realm history (integration)', () => {
describe('view mode', () => {
it('returns an empty list for an uninitialized workspace', async () => {
- const result = await realmHistory(workspaceDir);
- expect(result.ok).toBe(true);
- expect(result.checkpoints).toEqual([]);
+ let res = await runBoxel(['realm', 'history', workspaceDir]);
+ expect(res.ok, res.stderr).toBe(true);
+ expect(res.stdout).toContain('No checkpoints found.');
});
it('lists checkpoints in reverse-chronological order', async () => {
- const cm = new CheckpointManager(workspaceDir);
+ let cm = new CheckpointManager(workspaceDir);
writeFile('a.gts', 'first');
- await cm.createCheckpoint('manual', [{ file: 'a.gts', status: 'added' }]);
+ let manual = await cm.createCheckpoint('manual', [
+ { file: 'a.gts', status: 'added' },
+ ]);
writeFile('b.gts', 'second');
- await cm.createCheckpoint('local', [{ file: 'b.gts', status: 'added' }]);
+ let local = await cm.createCheckpoint('local', [
+ { file: 'b.gts', status: 'added' },
+ ]);
- const result = await realmHistory(workspaceDir);
+ let res = await runBoxel(['realm', 'history', workspaceDir]);
- expect(result.ok).toBe(true);
- expect(result.checkpoints).toBeDefined();
- expect(result.checkpoints!.length).toBe(2);
- expect(result.checkpoints![0].source).toBe('local');
- expect(result.checkpoints![1].source).toBe('manual');
- expect(result.truncated).toBe(false);
+ expect(res.ok, res.stderr).toBe(true);
+ // Newest-first: the local checkpoint is listed before the manual one.
+ expect(res.stdout).toContain(local!.shortHash);
+ expect(res.stdout).toContain(manual!.shortHash);
+ expect(res.stdout.indexOf(local!.shortHash)).toBeLessThan(
+ res.stdout.indexOf(manual!.shortHash),
+ );
+ // truncated=false → no truncation note.
+ expect(res.stdout).not.toContain('Showing first');
});
it('returns truncated=true when checkpoints exceed limit', async () => {
- const cm = new CheckpointManager(workspaceDir);
+ let cm = new CheckpointManager(workspaceDir);
writeFile('a.gts', '1');
- await cm.createCheckpoint('manual', [{ file: 'a.gts', status: 'added' }]);
+ let cp1 = await cm.createCheckpoint('manual', [
+ { file: 'a.gts', status: 'added' },
+ ]);
writeFile('a.gts', '2');
- await cm.createCheckpoint('manual', [
+ let cp2 = await cm.createCheckpoint('manual', [
{ file: 'a.gts', status: 'modified' },
]);
writeFile('a.gts', '3');
- await cm.createCheckpoint('manual', [
+ let cp3 = await cm.createCheckpoint('manual', [
{ file: 'a.gts', status: 'modified' },
]);
- const capped = await realmHistory(workspaceDir, { limit: 2 });
- expect(capped.ok).toBe(true);
- expect(capped.checkpoints!.length).toBe(2);
- expect(capped.truncated).toBe(true);
-
- const all = await realmHistory(workspaceDir, { limit: 10 });
- expect(all.checkpoints!.length).toBe(3);
- expect(all.truncated).toBe(false);
+ let capped = await runBoxel([
+ 'realm',
+ 'history',
+ workspaceDir,
+ '--limit',
+ '2',
+ ]);
+ expect(capped.ok, capped.stderr).toBe(true);
+ // Newest two are shown, the oldest omitted, and truncation is noted.
+ expect(capped.stdout).toContain(cp3!.shortHash);
+ expect(capped.stdout).toContain(cp2!.shortHash);
+ expect(capped.stdout).not.toContain(cp1!.shortHash);
+ expect(capped.stdout).toContain('Showing first 2 checkpoints');
+
+ let all = await runBoxel([
+ 'realm',
+ 'history',
+ workspaceDir,
+ '--limit',
+ '10',
+ ]);
+ expect(all.ok, all.stderr).toBe(true);
+ expect(all.stdout).toContain(cp1!.shortHash);
+ expect(all.stdout).toContain(cp2!.shortHash);
+ expect(all.stdout).toContain(cp3!.shortHash);
+ expect(all.stdout).not.toContain('Showing first');
});
});
@@ -74,50 +107,71 @@ describe('realm history (integration)', () => {
it('creates a checkpoint with the provided message', async () => {
writeFile('a.gts', 'a');
- const result = await realmHistory(workspaceDir, {
- message: 'before cleanup',
- });
-
- expect(result.ok).toBe(true);
- expect(result.created).toBeDefined();
- expect(result.created!.source).toBe('manual');
+ let res = await runBoxel([
+ 'realm',
+ 'history',
+ workspaceDir,
+ '-m',
+ 'before cleanup',
+ ]);
+ expect(res.ok, res.stderr).toBe(true);
- const cps = await new CheckpointManager(workspaceDir).getCheckpoints();
+ let cps = await new CheckpointManager(workspaceDir).getCheckpoints();
expect(cps[0].message.trim()).toBe('before cleanup');
+ expect(cps[0].source).toBe('manual');
});
it('initializes the history repo on first use', async () => {
writeFile('a.gts', 'a');
- const historyDir = path.join(workspaceDir, '.boxel-history', '.git');
+ let historyDir = path.join(workspaceDir, '.boxel-history', '.git');
expect(fs.existsSync(historyDir)).toBe(false);
- const result = await realmHistory(workspaceDir, { message: 'first' });
+ let res = await runBoxel([
+ 'realm',
+ 'history',
+ workspaceDir,
+ '-m',
+ 'first',
+ ]);
- expect(result.ok).toBe(true);
+ expect(res.ok, res.stderr).toBe(true);
expect(fs.existsSync(historyDir)).toBe(true);
});
it('returns an error when there are no changes to checkpoint', async () => {
writeFile('a.gts', 'a');
- await realmHistory(workspaceDir, { message: 'first' });
-
- const result = await realmHistory(workspaceDir, { message: 'second' });
+ let first = await runBoxel([
+ 'realm',
+ 'history',
+ workspaceDir,
+ '-m',
+ 'first',
+ ]);
+ expect(first.ok, first.stderr).toBe(true);
+
+ let res = await runBoxel([
+ 'realm',
+ 'history',
+ workspaceDir,
+ '-m',
+ 'second',
+ ]);
- expect(result.ok).toBe(false);
- expect(result.error).toContain('No changes to checkpoint');
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toContain('No changes to checkpoint');
});
it('rejects an empty message', async () => {
writeFile('a.gts', 'a');
- const result = await realmHistory(workspaceDir, { message: ' ' });
- expect(result.ok).toBe(false);
- expect(result.error).toContain('--message must not be empty');
+ let res = await runBoxel(['realm', 'history', workspaceDir, '-m', ' ']);
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toContain('--message must not be empty');
});
});
describe('restore (-r)', () => {
it('restores by 1-based index', async () => {
- const cm = new CheckpointManager(workspaceDir);
+ let cm = new CheckpointManager(workspaceDir);
writeFile('a.gts', 'original');
await cm.createCheckpoint('manual', [{ file: 'a.gts', status: 'added' }]);
writeFile('a.gts', 'modified');
@@ -128,10 +182,17 @@ describe('realm history (integration)', () => {
]);
// Index 2 is the older "original" checkpoint; getCheckpoints is newest-first.
- const result = await realmHistory(workspaceDir, { restore: '2' });
+ // Non-interactive stdin isn't a TTY, so `--restore` requires `--yes`.
+ let res = await runBoxel([
+ 'realm',
+ 'history',
+ workspaceDir,
+ '-r',
+ '2',
+ '-y',
+ ]);
- expect(result.ok).toBe(true);
- expect(result.restored).toBeDefined();
+ expect(res.ok, res.stderr).toBe(true);
expect(fs.readFileSync(path.join(workspaceDir, 'a.gts'), 'utf8')).toBe(
'original',
);
@@ -139,9 +200,9 @@ describe('realm history (integration)', () => {
});
it('restores by short hash', async () => {
- const cm = new CheckpointManager(workspaceDir);
+ let cm = new CheckpointManager(workspaceDir);
writeFile('a.gts', 'original');
- const target = await cm.createCheckpoint('manual', [
+ let target = await cm.createCheckpoint('manual', [
{ file: 'a.gts', status: 'added' },
]);
writeFile('a.gts', 'modified');
@@ -149,21 +210,26 @@ describe('realm history (integration)', () => {
{ file: 'a.gts', status: 'modified' },
]);
- const result = await realmHistory(workspaceDir, {
- restore: target!.shortHash,
- });
+ let res = await runBoxel([
+ 'realm',
+ 'history',
+ workspaceDir,
+ '-r',
+ target!.shortHash,
+ '-y',
+ ]);
- expect(result.ok).toBe(true);
- expect(result.restored?.hash).toBe(target!.hash);
+ expect(res.ok, res.stderr).toBe(true);
+ expect(res.stdout).toContain(target!.shortHash);
expect(fs.readFileSync(path.join(workspaceDir, 'a.gts'), 'utf8')).toBe(
'original',
);
});
it('restores by full hash', async () => {
- const cm = new CheckpointManager(workspaceDir);
+ let cm = new CheckpointManager(workspaceDir);
writeFile('a.gts', 'original');
- const target = await cm.createCheckpoint('manual', [
+ let target = await cm.createCheckpoint('manual', [
{ file: 'a.gts', status: 'added' },
]);
writeFile('a.gts', 'modified');
@@ -172,19 +238,24 @@ describe('realm history (integration)', () => {
]);
expect(target!.hash.length).toBe(40);
- const result = await realmHistory(workspaceDir, {
- restore: target!.hash,
- });
+ let res = await runBoxel([
+ 'realm',
+ 'history',
+ workspaceDir,
+ '-r',
+ target!.hash,
+ '-y',
+ ]);
- expect(result.ok).toBe(true);
- expect(result.restored?.hash).toBe(target!.hash);
+ expect(res.ok, res.stderr).toBe(true);
+ expect(res.stdout).toContain(target!.shortHash);
expect(fs.readFileSync(path.join(workspaceDir, 'a.gts'), 'utf8')).toBe(
'original',
);
});
it('returns an error for an out-of-range numeric index', async () => {
- const cm = new CheckpointManager(workspaceDir);
+ let cm = new CheckpointManager(workspaceDir);
writeFile('a.gts', 'a');
await cm.createCheckpoint('manual', [{ file: 'a.gts', status: 'added' }]);
writeFile('b.gts', 'b');
@@ -192,16 +263,23 @@ describe('realm history (integration)', () => {
// Digit-only refs are always treated as index lookups; they must not
// silently match a short hash whose prefix happens to be digits.
- const result = await realmHistory(workspaceDir, { restore: '99' });
+ let res = await runBoxel([
+ 'realm',
+ 'history',
+ workspaceDir,
+ '-r',
+ '99',
+ '-y',
+ ]);
- expect(result.ok).toBe(false);
- expect(result.error).toContain('Checkpoint not found');
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toContain('Checkpoint not found');
});
it('preserves untracked dotfiles across restore', async () => {
- const cm = new CheckpointManager(workspaceDir);
+ let cm = new CheckpointManager(workspaceDir);
writeFile('a.gts', 'original');
- const target = await cm.createCheckpoint('manual', [
+ let target = await cm.createCheckpoint('manual', [
{ file: 'a.gts', status: 'added' },
]);
writeFile('.gitkeep', 'marker');
@@ -210,60 +288,93 @@ describe('realm history (integration)', () => {
{ file: 'a.gts', status: 'modified' },
]);
- const result = await realmHistory(workspaceDir, {
- restore: target!.shortHash,
- });
+ let res = await runBoxel([
+ 'realm',
+ 'history',
+ workspaceDir,
+ '-r',
+ target!.shortHash,
+ '-y',
+ ]);
- expect(result.ok).toBe(true);
- const dotfilePath = path.join(workspaceDir, '.gitkeep');
+ expect(res.ok, res.stderr).toBe(true);
+ let dotfilePath = path.join(workspaceDir, '.gitkeep');
expect(fs.existsSync(dotfilePath)).toBe(true);
expect(fs.readFileSync(dotfilePath, 'utf8')).toBe('marker');
});
it('returns an error for an invalid reference', async () => {
- const cm = new CheckpointManager(workspaceDir);
+ let cm = new CheckpointManager(workspaceDir);
writeFile('a.gts', 'a');
await cm.createCheckpoint('manual', [{ file: 'a.gts', status: 'added' }]);
- const result = await realmHistory(workspaceDir, { restore: 'deadbeef' });
+ let res = await runBoxel([
+ 'realm',
+ 'history',
+ workspaceDir,
+ '-r',
+ 'deadbeef',
+ '-y',
+ ]);
- expect(result.ok).toBe(false);
- expect(result.error).toContain('Checkpoint not found');
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toContain('Checkpoint not found');
});
it('returns an error when the workspace has no history', async () => {
- const result = await realmHistory(workspaceDir, { restore: '1' });
- expect(result.ok).toBe(false);
- expect(result.error).toContain('No checkpoint history');
+ let res = await runBoxel([
+ 'realm',
+ 'history',
+ workspaceDir,
+ '-r',
+ '1',
+ '-y',
+ ]);
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toContain('No checkpoint history');
});
it('rejects an empty restore ref instead of restoring the newest', async () => {
- const cm = new CheckpointManager(workspaceDir);
+ let cm = new CheckpointManager(workspaceDir);
writeFile('a.gts', 'a');
await cm.createCheckpoint('manual', [{ file: 'a.gts', status: 'added' }]);
writeFile('b.gts', 'b');
await cm.createCheckpoint('manual', [{ file: 'b.gts', status: 'added' }]);
- const result = await realmHistory(workspaceDir, { restore: '' });
+ let res = await runBoxel([
+ 'realm',
+ 'history',
+ workspaceDir,
+ '-r',
+ '',
+ '-y',
+ ]);
- expect(result.ok).toBe(false);
- expect(result.error).toContain('Checkpoint not found');
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toContain('Checkpoint not found');
expect(fs.existsSync(path.join(workspaceDir, 'b.gts'))).toBe(true);
});
it('rejects a whitespace-only restore ref', async () => {
- const cm = new CheckpointManager(workspaceDir);
+ let cm = new CheckpointManager(workspaceDir);
writeFile('a.gts', 'a');
await cm.createCheckpoint('manual', [{ file: 'a.gts', status: 'added' }]);
- const result = await realmHistory(workspaceDir, { restore: ' ' });
+ let res = await runBoxel([
+ 'realm',
+ 'history',
+ workspaceDir,
+ '-r',
+ ' ',
+ '-y',
+ ]);
- expect(result.ok).toBe(false);
- expect(result.error).toContain('Checkpoint not found');
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toContain('Checkpoint not found');
});
it('rejects an ambiguous hash prefix', async () => {
- const cm = new CheckpointManager(workspaceDir);
+ let cm = new CheckpointManager(workspaceDir);
// Create enough checkpoints that some non-digit hex prefix collides.
// Digit-only refs are treated as index lookups, not hash prefixes.
for (let i = 0; i < 40; i++) {
@@ -272,53 +383,70 @@ describe('realm history (integration)', () => {
{ file: 'a.gts', status: i === 0 ? 'added' : 'modified' },
]);
}
- const cps = await cm.getCheckpoints(100);
- const counts = new Map();
- for (const cp of cps) {
- const c = cp.hash[0];
+ let cps = await cm.getCheckpoints(100);
+ let counts = new Map();
+ for (let cp of cps) {
+ let c = cp.hash[0];
if (/[a-f]/.test(c)) counts.set(c, (counts.get(c) ?? 0) + 1);
}
- const ambiguousPrefix = [...counts.entries()].find(
- ([, n]) => n >= 2,
- )?.[0];
+ let ambiguousPrefix = [...counts.entries()].find(([, n]) => n >= 2)?.[0];
expect(ambiguousPrefix).toBeDefined();
- const result = await realmHistory(workspaceDir, {
- restore: ambiguousPrefix!,
- });
+ let res = await runBoxel([
+ 'realm',
+ 'history',
+ workspaceDir,
+ '-r',
+ ambiguousPrefix!,
+ '-y',
+ ]);
- expect(result.ok).toBe(false);
- expect(result.error).toContain('Ambiguous reference');
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toContain('Ambiguous reference');
});
});
describe('argument validation', () => {
it('rejects a missing workspace directory', async () => {
- const result = await realmHistory(
+ let res = await runBoxel([
+ 'realm',
+ 'history',
path.join(workspaceDir, 'does-not-exist'),
- );
- expect(result.ok).toBe(false);
- expect(result.error).toContain('Directory not found');
+ ]);
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toContain('Directory not found');
});
it('rejects --restore and --message together', async () => {
- const result = await realmHistory(workspaceDir, {
- restore: '1',
- message: 'oops',
- });
- expect(result.ok).toBe(false);
- expect(result.error).toContain('Only one of --restore or --message');
+ let res = await runBoxel([
+ 'realm',
+ 'history',
+ workspaceDir,
+ '-r',
+ '1',
+ '-m',
+ 'oops',
+ ]);
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toContain('Only one of --restore or --message');
});
it.each([
- ['zero', 0],
- ['negative', -1],
- ['non-integer', 1.5],
- ['NaN', Number.NaN],
+ ['zero', '0'],
+ ['negative', '-1'],
+ ['non-integer', '1.5'],
+ ['NaN', 'NaN'],
])('rejects an invalid limit (%s)', async (_name, limit) => {
- const result = await realmHistory(workspaceDir, { limit });
- expect(result.ok).toBe(false);
- expect(result.error).toContain('positive integer');
+ // `--limit=` binds the value so a leading-`-` value isn't parsed as
+ // a flag; all four are rejected by the CLI's positive-integer check.
+ let res = await runBoxel([
+ 'realm',
+ 'history',
+ workspaceDir,
+ `--limit=${limit}`,
+ ]);
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toContain('positive integer');
});
});
});
diff --git a/packages/boxel-cli/tests/integration/realm-indexing-errors.test.ts b/packages/boxel-cli/tests/integration/realm-indexing-errors.test.ts
index 341d63892d1..4c40271a219 100644
--- a/packages/boxel-cli/tests/integration/realm-indexing-errors.test.ts
+++ b/packages/boxel-cli/tests/integration/realm-indexing-errors.test.ts
@@ -4,11 +4,11 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
- indexingErrors,
shortErrorMessage,
shortBrokenLinks,
shortFrontmatterError,
formatEntry,
+ type IndexingErrorsDocument,
type IndexingErrorEntry,
type BrokenLinkEntry,
type FrontmatterErrorEntry,
@@ -17,16 +17,34 @@ import { ProfileManager } from '../../src/lib/profile-manager.ts';
import {
startTestRealmServer,
stopTestRealmServer,
- createTestProfileDir,
+ createTestHome,
setupTestProfile,
getTestDbAdapter,
TEST_REALM_SERVER_URL,
} from '../helpers/integration.ts';
+import { runBoxel } from '../helpers/run-boxel.ts';
-let profileManager: ProfileManager;
+// Drives `boxel realm indexing-errors --realm --json` as a subprocess
+// and asserts on the JSON-API document it prints. Error rows are seeded
+// directly into `boxel_index` in-process (the noopPrerenderer can't produce
+// them from source) — only the command that surfaces them is a subprocess
+// call. The `formatEntry` / `short*` helpers remain unit-tested in-process.
+
+let home: string;
let cleanupProfile: () => void;
let realmUrl: string;
+// Run the command under test as a subprocess and return the parsed
+// JSON-API document. The command must succeed; failures surface via stderr.
+async function fetchIndexingErrors(): Promise {
+ let res = await runBoxel(
+ ['realm', 'indexing-errors', '--realm', realmUrl, '--json'],
+ { home },
+ );
+ expect(res.ok, res.stderr).toBe(true);
+ return res.json();
+}
+
beforeAll(async () => {
// Boot a clean realm with no fileSystem — the noopPrerenderer can't
// extract types from card .gts modules, so any seeded card would show
@@ -35,10 +53,10 @@ beforeAll(async () => {
// via INSERT below.
await startTestRealmServer();
realmUrl = `${TEST_REALM_SERVER_URL}/test/`;
- let testProfile = createTestProfileDir();
- profileManager = testProfile.profileManager;
- cleanupProfile = testProfile.cleanup;
- await setupTestProfile(profileManager);
+ let testHome = createTestHome();
+ home = testHome.home;
+ cleanupProfile = testHome.cleanup;
+ await setupTestProfile(testHome.profileManager);
});
afterAll(async () => {
@@ -48,11 +66,9 @@ afterAll(async () => {
describe('realm indexing-errors (integration)', () => {
it('returns ok with an empty data array for a healthy realm', async () => {
- let result = await indexingErrors(realmUrl, { profileManager });
- expect(result.ok).toBe(true);
- expect(result.document).toBeDefined();
- expect(Array.isArray(result.document!.data)).toBe(true);
- expect(result.document!.data).toEqual([]);
+ let document = await fetchIndexingErrors();
+ expect(Array.isArray(document.data)).toBe(true);
+ expect(document.data).toEqual([]);
});
it('reports errored entries with errorDoc and diagnostics', async () => {
@@ -91,11 +107,10 @@ describe('realm indexing-errors (integration)', () => {
},
);
- let result = await indexingErrors(realmUrl, { profileManager });
- expect(result.ok).toBe(true);
- expect(result.document!.data.length).toBe(1);
+ let document = await fetchIndexingErrors();
+ expect(document.data.length).toBe(1);
- let entry = result.document!.data[0] as IndexingErrorEntry;
+ let entry = document.data[0] as IndexingErrorEntry;
expect(entry.type).toBe('indexing-error');
expect(entry.id).toBe(`instance::${cardURL}`);
expect(entry.attributes.url).toBe(cardURL);
@@ -140,11 +155,8 @@ describe('realm indexing-errors (integration)', () => {
);
}
- let result = await indexingErrors(realmUrl, { profileManager });
- expect(result.ok).toBe(true);
- let forUrl = result.document!.data.filter(
- (e) => e.attributes.url === sharedURL,
- );
+ let document = await fetchIndexingErrors();
+ let forUrl = document.data.filter((e) => e.attributes.url === sharedURL);
expect(forUrl.length).toBe(2);
let ids = forUrl.map((e) => e.id).sort();
expect(ids).toEqual([`file::${sharedURL}`, `instance::${sharedURL}`]);
@@ -183,11 +195,10 @@ describe('realm indexing-errors (integration)', () => {
},
);
- let result = await indexingErrors(realmUrl, { profileManager });
- expect(result.ok).toBe(true);
- let entry = result.document!.data.find(
- (e) => e.attributes.url === cardURL,
- ) as BrokenLinkEntry | undefined;
+ let document = await fetchIndexingErrors();
+ let entry = document.data.find((e) => e.attributes.url === cardURL) as
+ | BrokenLinkEntry
+ | undefined;
expect(entry).toBeDefined();
expect(entry!.type).toBe('broken-link');
expect(entry!.attributes.brokenLinks).toEqual(brokenLinks);
@@ -216,11 +227,10 @@ describe('realm indexing-errors (integration)', () => {
},
);
- let result = await indexingErrors(realmUrl, { profileManager });
- expect(result.ok).toBe(true);
- let entry = result.document!.data.find(
- (e) => e.attributes.url === fileURL,
- ) as FrontmatterErrorEntry | undefined;
+ let document = await fetchIndexingErrors();
+ let entry = document.data.find((e) => e.attributes.url === fileURL) as
+ | FrontmatterErrorEntry
+ | undefined;
expect(entry).toBeDefined();
expect(entry!.type).toBe('frontmatter-error');
expect(entry!.attributes.frontmatterParseError).toEqual(
@@ -258,11 +268,8 @@ describe('realm indexing-errors (integration)', () => {
},
);
- let result = await indexingErrors(realmUrl, { profileManager });
- expect(result.ok).toBe(true);
- let forUrl = result.document!.data.filter(
- (e) => e.attributes.url === fileURL,
- );
+ let document = await fetchIndexingErrors();
+ let forUrl = document.data.filter((e) => e.attributes.url === fileURL);
expect(forUrl.length).toBe(2);
let byType = Object.fromEntries(forUrl.map((e) => [e.type, e]));
@@ -282,22 +289,29 @@ describe('realm indexing-errors (integration)', () => {
});
it('returns ok=false when the realm is unreachable', async () => {
- let result = await indexingErrors('http://127.0.0.1:1/fake/', {
- profileManager,
- });
- expect(result.ok).toBe(false);
- expect(result.error).toBeDefined();
+ let res = await runBoxel(
+ ['realm', 'indexing-errors', '--realm', 'http://127.0.0.1:1/fake/'],
+ { home },
+ );
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).not.toBe('');
});
it('returns NO_ACTIVE_PROFILE_ERROR when no profile is active', async () => {
- let emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-empty-'));
- let emptyManager = new ProfileManager(emptyDir);
- let result = await indexingErrors(realmUrl, {
- profileManager: emptyManager,
- });
- expect(result.ok).toBe(false);
- expect(result.error).toContain('No active profile');
- fs.rmSync(emptyDir, { recursive: true, force: true });
+ let emptyHome = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-empty-'));
+ // Materialize an empty profile store so the CLI reaches the
+ // no-active-profile guard rather than any first-run bootstrapping.
+ new ProfileManager(path.join(emptyHome, '.boxel-cli'));
+ try {
+ let res = await runBoxel(
+ ['realm', 'indexing-errors', '--realm', realmUrl],
+ { home: emptyHome },
+ );
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toContain('No active profile');
+ } finally {
+ fs.rmSync(emptyHome, { recursive: true, force: true });
+ }
});
it('shortErrorMessage prefers title over message and collapses whitespace', () => {
diff --git a/packages/boxel-cli/tests/integration/realm-ingest-card.test.ts b/packages/boxel-cli/tests/integration/realm-ingest-card.test.ts
index 3e3860c98c0..d88c7026c0f 100644
--- a/packages/boxel-cli/tests/integration/realm-ingest-card.test.ts
+++ b/packages/boxel-cli/tests/integration/realm-ingest-card.test.ts
@@ -7,14 +7,13 @@ import {
getTestPrerenderer,
stopTestPrerenderServer,
} from '#realm-server/tests/helpers/index';
-import { ingestCard } from '../../src/commands/realm/ingest-card.ts';
-import type { ProfileManager } from '../../src/lib/profile-manager.ts';
import {
startTestRealmServer,
stopTestRealmServer,
- createTestProfileDir,
+ createTestHome,
setupJwtTestProfile,
} from '../helpers/integration.ts';
+import { runBoxel, type BoxelResult } from '../helpers/run-boxel.ts';
// Ingest against a real realm server with real card indexing: the realm is
// seeded with an entry card whose dependency graph spans an ad hoc nested
@@ -127,10 +126,39 @@ const EXPECTED_INGESTED = [
];
let realmHref: string;
-let profileManager: ProfileManager;
+let home: string;
let cleanupProfile: () => void;
let localDir: string;
-let result: { files: string[]; error?: string };
+let ingestResult: BoxelResult;
+
+// The set of files the CLI actually wrote into a local dir, relative to
+// it. Excludes the `.boxel-history` checkpoint metadata `boxel realm
+// ingest-card` also writes, so this reflects exactly the ingested card
+// graph — the subprocess boundary means we verify the copied set from
+// disk rather than from a returned `files` array.
+function listIngested(dir: string): string[] {
+ let out: string[] = [];
+ let walk = (rel: string) => {
+ for (let entry of fs.readdirSync(path.join(dir, rel), {
+ withFileTypes: true,
+ })) {
+ if (
+ entry.name === '.boxel-history' ||
+ entry.name === '.boxel-sync.json'
+ ) {
+ continue;
+ }
+ let childRel = rel ? path.join(rel, entry.name) : entry.name;
+ if (entry.isDirectory()) {
+ walk(childRel);
+ } else {
+ out.push(childRel);
+ }
+ }
+ };
+ walk('');
+ return out.sort();
+}
beforeAll(async () => {
let { realms } = await startTestRealmServer({
@@ -148,19 +176,26 @@ beforeAll(async () => {
});
realmHref = realms.find((r) => r.url === testRealmURL.href)!.url;
- let testProfile = createTestProfileDir();
- profileManager = testProfile.profileManager;
- cleanupProfile = testProfile.cleanup;
- await setupJwtTestProfile(profileManager, {
+ let testHome = createTestHome();
+ home = testHome.home;
+ cleanupProfile = testHome.cleanup;
+ await setupJwtTestProfile(testHome.profileManager, {
user: ownerUserId,
realmServerUrl: `${testRealmURL.origin}/`,
});
localDir = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-ingest-int-'));
- result = await ingestCard(`${realmHref}widgets/gadget/gadget`, localDir, {
- realm: realmHref,
- profileManager,
- });
+ ingestResult = await runBoxel(
+ [
+ 'realm',
+ 'ingest-card',
+ `${realmHref}widgets/gadget/gadget`,
+ localDir,
+ '--realm',
+ realmHref,
+ ],
+ { home, timeout: 600_000 },
+ );
}, 600_000);
afterAll(async () => {
@@ -177,8 +212,8 @@ afterAll(async () => {
describe('realm ingest-card (integration)', () => {
it('ingests the entry card graph: modules across nested dirs, test, instance, and Spec', () => {
- expect(result.error, `ingest failed: ${result.error}`).toBeUndefined();
- expect(result.files).toEqual(EXPECTED_INGESTED);
+ expect(ingestResult.ok, ingestResult.stderr).toBe(true);
+ expect(listIngested(localDir)).toEqual(EXPECTED_INGESTED);
});
it('preserves the directory structure so relative refs still resolve', () => {
@@ -205,22 +240,22 @@ describe('realm ingest-card (integration)', () => {
it('ingests via non-URL @cardstack/ identifiers for both the card and --realm', async () => {
// `@cardstack//` resolves against the profile's realm-server
// URL, so these identifiers name the same realm as realmHref. The card
- // identifier and the realm option resolve independently in ingestCard.
+ // argument and the --realm option resolve independently.
let rriDir = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-ingest-rri-'));
try {
- let rriResult = await ingestCard(
- '@cardstack/test/widgets/gadget/gadget',
- rriDir,
- {
- realm: '@cardstack/test/',
- profileManager,
- },
+ let rriResult = await runBoxel(
+ [
+ 'realm',
+ 'ingest-card',
+ '@cardstack/test/widgets/gadget/gadget',
+ rriDir,
+ '--realm',
+ '@cardstack/test/',
+ ],
+ { home, timeout: 120_000 },
);
- expect(
- rriResult.error,
- `ingest failed: ${rriResult.error}`,
- ).toBeUndefined();
- expect(rriResult.files).toEqual(EXPECTED_INGESTED);
+ expect(rriResult.ok, rriResult.stderr).toBe(true);
+ expect(listIngested(rriDir)).toEqual(EXPECTED_INGESTED);
} finally {
fs.rmSync(rriDir, { recursive: true, force: true });
}
diff --git a/packages/boxel-cli/tests/integration/realm-list-archived.test.ts b/packages/boxel-cli/tests/integration/realm-list-archived.test.ts
index 82ffc9c8294..550b384d9e0 100644
--- a/packages/boxel-cli/tests/integration/realm-list-archived.test.ts
+++ b/packages/boxel-cli/tests/integration/realm-list-archived.test.ts
@@ -1,26 +1,44 @@
import '../helpers/setup-realm-server.ts';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
-import { createRealm } from '../../src/commands/realm/create.ts';
-import { archiveRealm } from '../../src/commands/realm/archive.ts';
-import { listRealms } from '../../src/commands/realm/list.ts';
-import type { ProfileManager } from '../../src/lib/profile-manager.ts';
import {
startTestRealmServer,
stopTestRealmServer,
- createTestProfileDir,
+ createTestHome,
setupTestProfile,
- uniqueRealmName,
+ createTestRealmViaCli,
} from '../helpers/integration.ts';
+import { runBoxel } from '../helpers/run-boxel.ts';
-let profileManager: ProfileManager;
+// Exercises how `boxel realm list` treats archived realms: hidden by
+// default, surfaced with `--include-archived`. We create realms and
+// archive them through the installed binary, then read the list back with
+// `--json`.
+
+let home: string;
let cleanupProfile: () => void;
+interface ListResult {
+ realms: { url: string; hidden: boolean; archived: boolean }[];
+ error?: string;
+}
+
+async function listCli(flags: string[] = []): Promise {
+ let res = await runBoxel(['realm', 'list', '--json', ...flags], { home });
+ expect(res.ok, res.stderr).toBe(true);
+ return res.json();
+}
+
+async function archiveCli(realmUrl: string): Promise {
+ let res = await runBoxel(['realm', 'archive', realmUrl, '--yes'], { home });
+ expect(res.ok, res.stderr).toBe(true);
+}
+
beforeAll(async () => {
await startTestRealmServer();
- let testProfile = createTestProfileDir();
- profileManager = testProfile.profileManager;
- cleanupProfile = testProfile.cleanup;
- await setupTestProfile(profileManager);
+ let testHome = createTestHome();
+ home = testHome.home;
+ cleanupProfile = testHome.cleanup;
+ await setupTestProfile(testHome.profileManager);
});
afterAll(async () => {
@@ -30,42 +48,28 @@ afterAll(async () => {
describe('realm list with archived realms (integration)', () => {
it('hides archived realms by default', async () => {
- let name = uniqueRealmName();
- let { realmUrl } = await createRealm(name, `Test ${name}`, {
- profileManager,
- });
+ let { realmUrl } = await createTestRealmViaCli(home);
- let beforeArchive = await listRealms({ profileManager });
+ let beforeArchive = await listCli();
expect(beforeArchive.error).toBeUndefined();
expect(beforeArchive.realms.map((r) => r.url)).toContain(realmUrl);
- let archive = await archiveRealm({ realmUrl, profileManager });
- expect(archive.archived).toBe(true);
+ await archiveCli(realmUrl);
- let afterArchive = await listRealms({ profileManager });
+ let afterArchive = await listCli();
expect(afterArchive.error).toBeUndefined();
expect(afterArchive.realms.map((r) => r.url)).not.toContain(realmUrl);
- let allAccessible = await listRealms({
- allAccessible: true,
- profileManager,
- });
+ let allAccessible = await listCli(['--all-accessible']);
expect(allAccessible.error).toBeUndefined();
expect(allAccessible.realms.map((r) => r.url)).not.toContain(realmUrl);
});
it('--include-archived surfaces archived realms with an archived marker', async () => {
- let name = uniqueRealmName();
- let { realmUrl } = await createRealm(name, `Test ${name}`, {
- profileManager,
- });
- await archiveRealm({ realmUrl, profileManager });
-
- let result = await listRealms({
- includeArchived: true,
- profileManager,
- });
+ let { realmUrl } = await createTestRealmViaCli(home);
+ await archiveCli(realmUrl);
+ let result = await listCli(['--include-archived']);
expect(result.error).toBeUndefined();
let entry = result.realms.find((r) => r.url === realmUrl);
expect(entry).toBeDefined();
@@ -73,22 +77,12 @@ describe('realm list with archived realms (integration)', () => {
});
it('lists multiple archived realms together when --include-archived is set', async () => {
- let nameA = uniqueRealmName();
- let nameB = uniqueRealmName();
- let { realmUrl: urlA } = await createRealm(nameA, `Test ${nameA}`, {
- profileManager,
- });
- let { realmUrl: urlB } = await createRealm(nameB, `Test ${nameB}`, {
- profileManager,
- });
- await archiveRealm({ realmUrl: urlA, profileManager });
- await archiveRealm({ realmUrl: urlB, profileManager });
-
- let result = await listRealms({
- includeArchived: true,
- profileManager,
- });
+ let { realmUrl: urlA } = await createTestRealmViaCli(home);
+ let { realmUrl: urlB } = await createTestRealmViaCli(home);
+ await archiveCli(urlA);
+ await archiveCli(urlB);
+ let result = await listCli(['--include-archived']);
expect(result.error).toBeUndefined();
let archivedUrls = result.realms
.filter((r) => r.archived)
diff --git a/packages/boxel-cli/tests/integration/realm-list.test.ts b/packages/boxel-cli/tests/integration/realm-list.test.ts
index e097565c517..b70ea92a568 100644
--- a/packages/boxel-cli/tests/integration/realm-list.test.ts
+++ b/packages/boxel-cli/tests/integration/realm-list.test.ts
@@ -3,16 +3,24 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
-import { listRealms } from '../../src/commands/realm/list.ts';
import { ProfileManager } from '../../src/lib/profile-manager.ts';
import {
startTestRealmServer,
stopTestRealmServer,
- createTestProfileDir,
+ createTestHome,
setupTestProfile,
TEST_REALM_SERVER_URL,
} from '../helpers/integration.ts';
+import { runBoxel } from '../helpers/run-boxel.ts';
+// `boxel realm list [--all-accessible|--hidden] [--include-archived]`
+// prints its structured result as JSON with `--json`, so we drive the
+// installed binary and assert on `res.json()`. Account-data seeding (which
+// realm appears in the UI list) stays in-process via `addToUserRealms` —
+// it writes to the same server-side Matrix account data the subprocess
+// reads back.
+
+let home: string;
let profileManager: ProfileManager;
let cleanupProfile: () => void;
@@ -20,6 +28,11 @@ const visibleUrl = `${TEST_REALM_SERVER_URL}/visible/`;
const hiddenUrl = `${TEST_REALM_SERVER_URL}/hidden/`;
const pendingUrl = `${TEST_REALM_SERVER_URL}/pending/`;
+interface ListResult {
+ realms: { url: string; hidden: boolean; archived: boolean }[];
+ error?: string;
+}
+
beforeAll(async () => {
await startTestRealmServer({
realms: [
@@ -37,9 +50,10 @@ beforeAll(async () => {
},
],
});
- let testProfile = createTestProfileDir();
- profileManager = testProfile.profileManager;
- cleanupProfile = testProfile.cleanup;
+ let testHome = createTestHome();
+ home = testHome.home;
+ profileManager = testHome.profileManager;
+ cleanupProfile = testHome.cleanup;
await setupTestProfile(profileManager);
// Seed only `visibleUrl` into the user's app.boxel.realms account data so
@@ -54,10 +68,11 @@ afterAll(async () => {
describe('realm list (integration)', () => {
it('--all-accessible returns every realm with the correct hidden flag', async () => {
- let result = await listRealms({
- allAccessible: true,
- profileManager,
+ let res = await runBoxel(['realm', 'list', '--json', '--all-accessible'], {
+ home,
});
+ expect(res.ok, res.stderr).toBe(true);
+ let result = res.json();
expect(result.error).toBeUndefined();
expect(result.realms).toHaveLength(3);
let byUrl = new Map(result.realms.map((r) => [r.url, r]));
@@ -79,17 +94,20 @@ describe('realm list (integration)', () => {
});
it('returns an error when --all-accessible and --hidden are both set', async () => {
- let result = await listRealms({
- allAccessible: true,
- hidden: true,
- profileManager,
- });
+ let res = await runBoxel(
+ ['realm', 'list', '--json', '--all-accessible', '--hidden'],
+ { home },
+ );
+ expect(res.exitCode).toBe(1);
+ let result = res.json();
expect(result.realms).toEqual([]);
expect(result.error).toContain('mutually exclusive');
});
it('default mode lists only the realm in account data', async () => {
- let result = await listRealms({ profileManager });
+ let res = await runBoxel(['realm', 'list', '--json'], { home });
+ expect(res.ok, res.stderr).toBe(true);
+ let result = res.json();
expect(result.error).toBeUndefined();
expect(result.realms).toEqual([
{ url: visibleUrl, hidden: false, archived: false },
@@ -97,7 +115,9 @@ describe('realm list (integration)', () => {
});
it('--hidden lists only realms missing from account data', async () => {
- let result = await listRealms({ hidden: true, profileManager });
+ let res = await runBoxel(['realm', 'list', '--json', '--hidden'], { home });
+ expect(res.ok, res.stderr).toBe(true);
+ let result = res.json();
expect(result.error).toBeUndefined();
let urls = result.realms.map((r) => r.url).sort();
expect(urls).toEqual([hiddenUrl, pendingUrl].sort());
@@ -108,13 +128,19 @@ describe('realm list (integration)', () => {
it('addToUserRealms moves a realm from hidden to visible', async () => {
await profileManager.addToUserRealms(pendingUrl);
- let visible = await listRealms({ profileManager });
+ let visibleRes = await runBoxel(['realm', 'list', '--json'], { home });
+ expect(visibleRes.ok, visibleRes.stderr).toBe(true);
+ let visible = visibleRes.json();
expect(visible.error).toBeUndefined();
let visibleUrls = visible.realms.map((r) => r.url).sort();
expect(visibleUrls).toEqual([visibleUrl, pendingUrl].sort());
expect(visible.realms.every((r) => !r.hidden)).toBe(true);
- let hidden = await listRealms({ hidden: true, profileManager });
+ let hiddenRes = await runBoxel(['realm', 'list', '--json', '--hidden'], {
+ home,
+ });
+ expect(hiddenRes.ok, hiddenRes.stderr).toBe(true);
+ let hidden = hiddenRes.json();
expect(hidden.error).toBeUndefined();
expect(hidden.realms).toEqual([
{ url: hiddenUrl, hidden: true, archived: false },
@@ -122,11 +148,20 @@ describe('realm list (integration)', () => {
});
it('returns an error when no active profile', async () => {
- let emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-empty-'));
- let emptyManager = new ProfileManager(emptyDir);
- let result = await listRealms({ profileManager: emptyManager });
- expect(result.realms).toEqual([]);
- expect(result.error).toContain('No active profile');
- fs.rmSync(emptyDir, { recursive: true, force: true });
+ let emptyHome = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-empty-'));
+ // Materialize an empty profile store so the CLI reaches the
+ // no-active-profile guard rather than any first-run bootstrapping.
+ new ProfileManager(path.join(emptyHome, '.boxel-cli'));
+ try {
+ let res = await runBoxel(['realm', 'list', '--json'], {
+ home: emptyHome,
+ });
+ expect(res.exitCode).toBe(1);
+ let result = res.json();
+ expect(result.realms).toEqual([]);
+ expect(result.error).toContain('No active profile');
+ } finally {
+ fs.rmSync(emptyHome, { recursive: true, force: true });
+ }
});
});
diff --git a/packages/boxel-cli/tests/integration/realm-milestone.test.ts b/packages/boxel-cli/tests/integration/realm-milestone.test.ts
index c2da958ac62..cd2a3436817 100644
--- a/packages/boxel-cli/tests/integration/realm-milestone.test.ts
+++ b/packages/boxel-cli/tests/integration/realm-milestone.test.ts
@@ -2,13 +2,21 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
-import { realmMilestone } from '../../src/commands/realm/milestone.ts';
import { CheckpointManager } from '../../src/lib/checkpoint-manager.ts';
+import type { MilestoneResult } from '../../src/commands/realm/milestone.ts';
+import { runBoxel } from '../helpers/run-boxel.ts';
+
+// `boxel realm milestone ` lists, marks, and removes milestones in
+// the workspace's local `.boxel-history/` checkpoint log — pure local, no
+// realm server. We drive the installed binary; checkpoint/milestone setup
+// stays in-process via `CheckpointManager`. The command supports `--json`, so
+// its result payload is read back with `res.json()` (structured errors ride
+// stdout under `--json`; argv-level guards still bail out on stderr).
let workspaceDir: string;
function writeFile(relPath: string, content: string): void {
- const full = path.join(workspaceDir, relPath);
+ let full = path.join(workspaceDir, relPath);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content, 'utf8');
}
@@ -20,7 +28,7 @@ async function makeCheckpoint(
source: 'manual' | 'local' | 'remote' = 'manual',
): Promise {
writeFile(file, content);
- const cp = await cm.createCheckpoint(source, [{ file, status: 'added' }]);
+ let cp = await cm.createCheckpoint(source, [{ file, status: 'added' }]);
return cp!.hash;
}
@@ -35,198 +43,303 @@ afterEach(() => {
describe('realm milestone (integration)', () => {
describe('list mode (no options)', () => {
it('returns empty list for uninitialized workspace', async () => {
- const result = await realmMilestone(workspaceDir);
+ let res = await runBoxel(['realm', 'milestone', workspaceDir, '--json']);
+ expect(res.ok, res.stderr).toBe(true);
+ let result = res.json();
expect(result.ok).toBe(true);
expect(result.milestones).toEqual([]);
});
it('returns empty list when no milestones are marked', async () => {
- const cm = new CheckpointManager(workspaceDir);
+ let cm = new CheckpointManager(workspaceDir);
await makeCheckpoint(cm, 'a.gts', 'a');
- const result = await realmMilestone(workspaceDir);
+ let res = await runBoxel(['realm', 'milestone', workspaceDir, '--json']);
+ expect(res.ok, res.stderr).toBe(true);
+ let result = res.json();
expect(result.ok).toBe(true);
expect(result.milestones).toEqual([]);
});
it('returns milestones with correct names', async () => {
- const cm = new CheckpointManager(workspaceDir);
- const hash = await makeCheckpoint(cm, 'a.gts', 'a');
+ let cm = new CheckpointManager(workspaceDir);
+ let hash = await makeCheckpoint(cm, 'a.gts', 'a');
await cm.markMilestone(hash, 'v1.0');
- const result = await realmMilestone(workspaceDir);
- expect(result.ok).toBe(true);
+ let res = await runBoxel(['realm', 'milestone', workspaceDir, '--json']);
+ expect(res.ok, res.stderr).toBe(true);
+ let result = res.json();
expect(result.milestones).toHaveLength(1);
expect(result.milestones![0].isMilestone).toBe(true);
expect(result.milestones![0].milestoneName).toBe('v1.0');
});
it('returns multiple milestones', async () => {
- const cm = new CheckpointManager(workspaceDir);
- const h1 = await makeCheckpoint(cm, 'a.gts', 'a');
- const h2 = await makeCheckpoint(cm, 'b.gts', 'b');
+ let cm = new CheckpointManager(workspaceDir);
+ let h1 = await makeCheckpoint(cm, 'a.gts', 'a');
+ let h2 = await makeCheckpoint(cm, 'b.gts', 'b');
await cm.markMilestone(h1, 'first');
await cm.markMilestone(h2, 'second');
- const result = await realmMilestone(workspaceDir);
- expect(result.ok).toBe(true);
+ let res = await runBoxel(['realm', 'milestone', workspaceDir, '--json']);
+ expect(res.ok, res.stderr).toBe(true);
+ let result = res.json();
expect(result.milestones).toHaveLength(2);
});
it('returns error for non-existent directory', async () => {
- const missing = fs.mkdtempSync(
+ let missing = fs.mkdtempSync(
path.join(os.tmpdir(), 'boxel-milestone-missing-'),
);
fs.rmSync(missing, { recursive: true, force: true });
- const result = await realmMilestone(missing);
- expect(result.ok).toBe(false);
- expect(result.error).toMatch(/directory not found/i);
+ let res = await runBoxel(['realm', 'milestone', missing, '--json']);
+ expect(res.exitCode).toBe(1);
+ expect(res.json().error).toMatch(/directory not found/i);
});
});
describe('mark mode (--mark + --name)', () => {
it('marks a checkpoint by hash and returns it', async () => {
- const cm = new CheckpointManager(workspaceDir);
- const hash = await makeCheckpoint(cm, 'a.gts', 'a');
-
- const result = await realmMilestone(workspaceDir, {
- mark: hash,
- name: 'release-1',
- });
-
- expect(result.ok).toBe(true);
+ let cm = new CheckpointManager(workspaceDir);
+ let hash = await makeCheckpoint(cm, 'a.gts', 'a');
+
+ let res = await runBoxel([
+ 'realm',
+ 'milestone',
+ workspaceDir,
+ '--mark',
+ hash,
+ '--name',
+ 'release-1',
+ '--json',
+ ]);
+
+ expect(res.ok, res.stderr).toBe(true);
+ let result = res.json();
expect(result.marked).toBeDefined();
expect(result.marked!.isMilestone).toBe(true);
expect(result.marked!.milestoneName).toBe('release 1');
});
it('marks a checkpoint by 1-based index', async () => {
- const cm = new CheckpointManager(workspaceDir);
+ let cm = new CheckpointManager(workspaceDir);
await makeCheckpoint(cm, 'a.gts', 'a');
- const result = await realmMilestone(workspaceDir, {
- mark: '1',
- name: 'first',
- });
-
- expect(result.ok).toBe(true);
+ let res = await runBoxel([
+ 'realm',
+ 'milestone',
+ workspaceDir,
+ '--mark',
+ '1',
+ '--name',
+ 'first',
+ '--json',
+ ]);
+
+ expect(res.ok, res.stderr).toBe(true);
+ let result = res.json();
expect(result.marked).toBeDefined();
expect(result.marked!.isMilestone).toBe(true);
});
it('marks a checkpoint by short hash', async () => {
- const cm = new CheckpointManager(workspaceDir);
- const hash = await makeCheckpoint(cm, 'a.gts', 'a');
- const shortHash = hash.substring(0, 7);
-
- const result = await realmMilestone(workspaceDir, {
- mark: shortHash,
- name: 'short-ref',
- });
-
- expect(result.ok).toBe(true);
- expect(result.marked).toBeDefined();
+ let cm = new CheckpointManager(workspaceDir);
+ let hash = await makeCheckpoint(cm, 'a.gts', 'a');
+ let shortHash = hash.substring(0, 7);
+
+ let res = await runBoxel([
+ 'realm',
+ 'milestone',
+ workspaceDir,
+ '--mark',
+ shortHash,
+ '--name',
+ 'short-ref',
+ '--json',
+ ]);
+
+ expect(res.ok, res.stderr).toBe(true);
+ expect(res.json().marked).toBeDefined();
});
it('returns error when --name is missing', async () => {
- const result = await realmMilestone(workspaceDir, { mark: '1' });
- expect(result.ok).toBe(false);
- expect(result.error).toMatch(/--name is required/i);
+ // The missing-`--name` guard bails out on stderr before the `--json`
+ // branch, so there's no JSON payload to parse here.
+ let res = await runBoxel([
+ 'realm',
+ 'milestone',
+ workspaceDir,
+ '--mark',
+ '1',
+ ]);
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toMatch(/--name is required/i);
});
it('returns error when --name is empty', async () => {
- const cm = new CheckpointManager(workspaceDir);
+ let cm = new CheckpointManager(workspaceDir);
await makeCheckpoint(cm, 'a.gts', 'a');
- const result = await realmMilestone(workspaceDir, {
- mark: '1',
- name: ' ',
- });
- expect(result.ok).toBe(false);
- expect(result.error).toMatch(/--name must not be empty/i);
+ let res = await runBoxel([
+ 'realm',
+ 'milestone',
+ workspaceDir,
+ '--mark',
+ '1',
+ '--name',
+ ' ',
+ '--json',
+ ]);
+ expect(res.exitCode).toBe(1);
+ expect(res.json().error).toMatch(
+ /--name must not be empty/i,
+ );
});
it('returns error for out-of-range index', async () => {
- const cm = new CheckpointManager(workspaceDir);
+ let cm = new CheckpointManager(workspaceDir);
await makeCheckpoint(cm, 'a.gts', 'a');
- const result = await realmMilestone(workspaceDir, {
- mark: '99',
- name: 'nope',
- });
- expect(result.ok).toBe(false);
- expect(result.error).toMatch(/not found/i);
+ let res = await runBoxel([
+ 'realm',
+ 'milestone',
+ workspaceDir,
+ '--mark',
+ '99',
+ '--name',
+ 'nope',
+ '--json',
+ ]);
+ expect(res.exitCode).toBe(1);
+ expect(res.json().error).toMatch(/not found/i);
});
it('returns error when no checkpoint history exists', async () => {
- const result = await realmMilestone(workspaceDir, {
- mark: '1',
- name: 'x',
- });
- expect(result.ok).toBe(false);
- expect(result.error).toMatch(/no checkpoint history/i);
+ let res = await runBoxel([
+ 'realm',
+ 'milestone',
+ workspaceDir,
+ '--mark',
+ '1',
+ '--name',
+ 'x',
+ '--json',
+ ]);
+ expect(res.exitCode).toBe(1);
+ expect(res.json().error).toMatch(
+ /no checkpoint history/i,
+ );
});
});
describe('remove mode (--remove)', () => {
it('removes a milestone by hash', async () => {
- const cm = new CheckpointManager(workspaceDir);
- const hash = await makeCheckpoint(cm, 'a.gts', 'a');
+ let cm = new CheckpointManager(workspaceDir);
+ let hash = await makeCheckpoint(cm, 'a.gts', 'a');
await cm.markMilestone(hash, 'v1');
- const result = await realmMilestone(workspaceDir, { remove: hash });
- expect(result.ok).toBe(true);
- expect(result.removed).toBe(true);
-
- const after = await realmMilestone(workspaceDir);
- expect(after.milestones).toHaveLength(0);
+ let res = await runBoxel([
+ 'realm',
+ 'milestone',
+ workspaceDir,
+ '--remove',
+ hash,
+ '--json',
+ ]);
+ expect(res.ok, res.stderr).toBe(true);
+ expect(res.json().removed).toBe(true);
+
+ let after = await runBoxel([
+ 'realm',
+ 'milestone',
+ workspaceDir,
+ '--json',
+ ]);
+ expect(after.ok, after.stderr).toBe(true);
+ expect(after.json().milestones).toHaveLength(0);
});
it('removes a milestone by index', async () => {
- const cm = new CheckpointManager(workspaceDir);
- const hash = await makeCheckpoint(cm, 'a.gts', 'a');
+ let cm = new CheckpointManager(workspaceDir);
+ let hash = await makeCheckpoint(cm, 'a.gts', 'a');
await cm.markMilestone(hash, 'v1');
- const result = await realmMilestone(workspaceDir, { remove: '1' });
- expect(result.ok).toBe(true);
- expect(result.removed).toBe(true);
+ let res = await runBoxel([
+ 'realm',
+ 'milestone',
+ workspaceDir,
+ '--remove',
+ '1',
+ '--json',
+ ]);
+ expect(res.ok, res.stderr).toBe(true);
+ expect(res.json().removed).toBe(true);
});
it('returns error when checkpoint is not a milestone', async () => {
- const cm = new CheckpointManager(workspaceDir);
- const hash = await makeCheckpoint(cm, 'a.gts', 'a');
-
- const result = await realmMilestone(workspaceDir, { remove: hash });
- expect(result.ok).toBe(false);
- expect(result.error).toMatch(/not marked as a milestone/i);
+ let cm = new CheckpointManager(workspaceDir);
+ let hash = await makeCheckpoint(cm, 'a.gts', 'a');
+
+ let res = await runBoxel([
+ 'realm',
+ 'milestone',
+ workspaceDir,
+ '--remove',
+ hash,
+ '--json',
+ ]);
+ expect(res.exitCode).toBe(1);
+ expect(res.json().error).toMatch(
+ /not marked as a milestone/i,
+ );
});
it('returns error for unknown ref', async () => {
- const cm = new CheckpointManager(workspaceDir);
+ let cm = new CheckpointManager(workspaceDir);
await makeCheckpoint(cm, 'a.gts', 'a');
- const result = await realmMilestone(workspaceDir, { remove: '99' });
- expect(result.ok).toBe(false);
- expect(result.error).toMatch(/not found/i);
+ let res = await runBoxel([
+ 'realm',
+ 'milestone',
+ workspaceDir,
+ '--remove',
+ '99',
+ '--json',
+ ]);
+ expect(res.exitCode).toBe(1);
+ expect(res.json().error).toMatch(/not found/i);
});
});
describe('validation', () => {
it('returns error when --mark and --remove are both set', async () => {
- const result = await realmMilestone(workspaceDir, {
- mark: '1',
- name: 'x',
- remove: '1',
- });
- expect(result.ok).toBe(false);
- expect(result.error).toMatch(/only one of/i);
+ // Bails out on stderr before the `--json` branch.
+ let res = await runBoxel([
+ 'realm',
+ 'milestone',
+ workspaceDir,
+ '--mark',
+ '1',
+ '--name',
+ 'x',
+ '--remove',
+ '1',
+ ]);
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toMatch(/only one of/i);
});
it('returns error for non-positive limit', async () => {
- const result = await realmMilestone(workspaceDir, { limit: 0 });
- expect(result.ok).toBe(false);
- expect(result.error).toMatch(/limit/i);
+ let res = await runBoxel([
+ 'realm',
+ 'milestone',
+ workspaceDir,
+ '--limit',
+ '0',
+ ]);
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toMatch(/limit/i);
});
});
});
diff --git a/packages/boxel-cli/tests/integration/realm-publish-seed-auth.test.ts b/packages/boxel-cli/tests/integration/realm-publish-seed-auth.test.ts
index bfced7839b4..efbab11995c 100644
--- a/packages/boxel-cli/tests/integration/realm-publish-seed-auth.test.ts
+++ b/packages/boxel-cli/tests/integration/realm-publish-seed-auth.test.ts
@@ -1,41 +1,53 @@
import '../helpers/setup-realm-server.ts';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
-import { createRealm } from '../../src/commands/realm/create.ts';
-import { publishRealm } from '../../src/commands/realm/publish.ts';
-import { unpublishRealm } from '../../src/commands/realm/unpublish.ts';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
import {
startTestRealmServer,
stopTestRealmServer,
- createTestProfileDir,
+ createTestHome,
setupTestProfile,
+ createTestRealmViaCli,
uniqueRealmName,
realmSecretSeed,
TEST_REALM_SERVER_URL,
TEST_USERNAME,
} from '../helpers/integration.ts';
-import type { ProfileManager } from '../../src/lib/profile-manager.ts';
+import { runBoxel } from '../helpers/run-boxel.ts';
// The test realm server signs JWTs with `realmSecretSeed` and grants
// `@:localhost` the `realm-owner` permission. The publish
// endpoint authorizes the token's `user` as realm-owner, so a seed-minted
-// server token impersonating that owner is what we assert works.
+// server token impersonating that owner is what we assert works. Seed mode
+// is driven through the CLI via `--realm-secret-seed` + `--as-user`, with
+// the seed itself supplied out-of-band in `BOXEL_REALM_SECRET_SEED` (the
+// CLI never accepts a seed on argv).
const OWNER_USER_ID = `@${TEST_USERNAME}:localhost`;
-let profileManager: ProfileManager;
-let cleanup: () => void;
+// Shape of the `--json` payload the publish command prints on success.
+interface PublishResultJson {
+ publishedRealmURL: string;
+ publishedRealmId: string;
+ lastPublishedAt: string;
+ status: string;
+}
+
+let home: string;
+let cleanupProfile: () => void;
beforeAll(async () => {
await startTestRealmServer();
- let testProfile = createTestProfileDir();
- profileManager = testProfile.profileManager;
- cleanup = testProfile.cleanup;
+ let testHome = createTestHome();
+ home = testHome.home;
+ cleanupProfile = testHome.cleanup;
// Used only to create the source realm (owned by OWNER_USER_ID); publishing
- // below authenticates purely from the seed.
- await setupTestProfile(profileManager);
+ // below authenticates purely from the seed, in a separate empty home.
+ await setupTestProfile(testHome.profileManager);
});
afterAll(async () => {
- cleanup?.();
+ cleanupProfile?.();
await stopTestRealmServer();
});
@@ -46,24 +58,30 @@ function uniquePublishedUrl(): string {
describe('realm publish with seed-based auth (integration)', () => {
it('publishes using a seed-minted owner-scoped server token (no Matrix profile)', async () => {
- let { realmUrl: sourceUrl } = await createRealm(
- uniqueRealmName(),
- 'Seed publish source',
- { profileManager },
- );
+ let { realmUrl: sourceUrl } = await createTestRealmViaCli(home);
let publishedUrl = uniquePublishedUrl();
- // Empty profile: no Matrix login exists, so a successful publish proves the
+ // Empty home: no Matrix login exists, so a successful publish proves the
// realm server accepted the seed-minted owner-scoped server token.
- let emptyProfile = createTestProfileDir();
+ let emptyHome = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-seed-'));
try {
- let result = await publishRealm(sourceUrl, publishedUrl, {
- realmSecretSeed: realmSecretSeed,
- asUser: OWNER_USER_ID,
- profileManager: emptyProfile.profileManager,
- force: true, // skip the publishability gate (noop prerenderer → error docs)
- waitForReady: false, // isolate the assertion to the /_publish-realm call
- });
+ let res = await runBoxel(
+ [
+ 'realm',
+ 'publish',
+ sourceUrl,
+ publishedUrl,
+ '--realm-secret-seed',
+ '--as-user',
+ OWNER_USER_ID,
+ '--force', // skip the publishability gate (noop prerenderer → error docs)
+ '--no-wait', // isolate the assertion to the /_publish-realm call
+ '--json',
+ ],
+ { home: emptyHome, env: { BOXEL_REALM_SECRET_SEED: realmSecretSeed } },
+ );
+ expect(res.ok, res.stderr).toBe(true);
+ let result = res.json();
expect(result.publishedRealmURL).toBe(publishedUrl);
expect(result.publishedRealmId).toMatch(
@@ -72,37 +90,47 @@ describe('realm publish with seed-based auth (integration)', () => {
expect(result.lastPublishedAt).toBeTruthy();
} finally {
// Clean up via the same seed path (also exercises /_unpublish-realm).
- await unpublishRealm(publishedUrl, {
- realmSecretSeed: realmSecretSeed,
- asUser: OWNER_USER_ID,
- tolerateMissing: true,
- });
- emptyProfile.cleanup();
+ await runBoxel(
+ [
+ 'realm',
+ 'unpublish',
+ publishedUrl,
+ '--realm-secret-seed',
+ '--as-user',
+ OWNER_USER_ID,
+ '--tolerate-missing',
+ ],
+ { home: emptyHome, env: { BOXEL_REALM_SECRET_SEED: realmSecretSeed } },
+ );
+ fs.rmSync(emptyHome, { recursive: true, force: true });
}
});
it('rejects a seed publish whose impersonated user lacks realm-owner', async () => {
- let { realmUrl: sourceUrl } = await createRealm(
- uniqueRealmName(),
- 'Seed publish non-owner',
- { profileManager },
- );
+ let { realmUrl: sourceUrl } = await createTestRealmViaCli(home);
let publishedUrl = uniquePublishedUrl();
- let emptyProfile = createTestProfileDir();
+ let emptyHome = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-seed-'));
try {
// A validly-signed seed token, but for a user without realm-owner on the
// source realm — the server must refuse.
- await expect(
- publishRealm(sourceUrl, publishedUrl, {
- realmSecretSeed: realmSecretSeed,
- asUser: '@nobody-not-an-owner:localhost',
- profileManager: emptyProfile.profileManager,
- force: true,
- waitForReady: false,
- }),
- ).rejects.toThrow();
+ let res = await runBoxel(
+ [
+ 'realm',
+ 'publish',
+ sourceUrl,
+ publishedUrl,
+ '--realm-secret-seed',
+ '--as-user',
+ '@nobody-not-an-owner:localhost',
+ '--force',
+ '--no-wait',
+ ],
+ { home: emptyHome, env: { BOXEL_REALM_SECRET_SEED: realmSecretSeed } },
+ );
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).not.toBe('');
} finally {
- emptyProfile.cleanup();
+ fs.rmSync(emptyHome, { recursive: true, force: true });
}
});
});
diff --git a/packages/boxel-cli/tests/integration/realm-publish.test.ts b/packages/boxel-cli/tests/integration/realm-publish.test.ts
index ba9fe43cc38..6d36971abd1 100644
--- a/packages/boxel-cli/tests/integration/realm-publish.test.ts
+++ b/packages/boxel-cli/tests/integration/realm-publish.test.ts
@@ -1,43 +1,61 @@
import '../helpers/setup-realm-server.ts';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
-import { createRealm } from '../../src/commands/realm/create.ts';
-import { publishRealm } from '../../src/commands/realm/publish.ts';
-import { unpublishRealm } from '../../src/commands/realm/unpublish.ts';
import {
startTestRealmServer,
stopTestRealmServer,
- createTestProfileDir,
+ createTestHome,
setupTestProfile,
+ createTestRealmViaCli,
uniqueRealmName,
TEST_REALM_SERVER_URL,
} from '../helpers/integration.ts';
-import type { ProfileManager } from '../../src/lib/profile-manager.ts';
+import { runBoxel } from '../helpers/run-boxel.ts';
-let profileManager: ProfileManager;
-let cleanup: () => void;
+// Drives `boxel realm publish` / `boxel realm unpublish` as subprocesses.
+// The action goes through the installed CLI (argv + profile on disk); we
+// read the `--json` result off stdout, and error cases off stderr + a
+// non-zero exit code.
+
+// Shape of the `--json` payload the publish command prints on success.
+interface PublishResultJson {
+ publishedRealmURL: string;
+ publishedRealmId: string;
+ lastPublishedAt: string;
+ status: string;
+}
+
+// Shape of the `--json` payload the unpublish command prints.
+interface UnpublishResultJson {
+ publishedRealmURL: string;
+ unpublished: boolean;
+ notFound?: boolean;
+ error?: string;
+}
+
+let home: string;
+let cleanupProfile: () => void;
beforeAll(async () => {
await startTestRealmServer();
- let testProfile = createTestProfileDir();
- profileManager = testProfile.profileManager;
- cleanup = testProfile.cleanup;
- await setupTestProfile(profileManager);
+ let testHome = createTestHome();
+ home = testHome.home;
+ cleanupProfile = testHome.cleanup;
+ await setupTestProfile(testHome.profileManager);
});
afterAll(async () => {
- cleanup?.();
+ cleanupProfile?.();
await stopTestRealmServer();
});
// Creates a fresh source realm to publish from. This harness uses a noop
// prerenderer (see `integration.ts`), so every indexed instance becomes an
// error document and the realm is never publishable — tests exercising the
-// publish/unpublish flow itself pass `force: true` to bypass the publishability
+// publish/unpublish flow itself pass `--force` to bypass the publishability
// gate, which is covered directly by the gate test below.
async function createSourceRealm(): Promise {
- let name = uniqueRealmName();
- let result = await createRealm(name, `Source ${name}`, { profileManager });
- return result.realmUrl;
+ let { realmUrl } = await createTestRealmViaCli(home);
+ return realmUrl;
}
function uniquePublishedUrl(): string {
@@ -55,18 +73,28 @@ describe('realm publish (integration)', () => {
let sourceUrl = await createSourceRealm();
let publishedUrl = uniquePublishedUrl();
- let result = await publishRealm(sourceUrl, publishedUrl, {
- profileManager,
- timeoutMs: 60_000,
- force: true,
- });
+ let res = await runBoxel(
+ [
+ 'realm',
+ 'publish',
+ sourceUrl,
+ publishedUrl,
+ '--force',
+ '--timeout',
+ '60000',
+ '--json',
+ ],
+ { home },
+ );
+ expect(res.ok, res.stderr).toBe(true);
+ let result = res.json();
expect(result.publishedRealmURL).toBe(publishedUrl);
expect(result.publishedRealmId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
);
expect(result.lastPublishedAt).toBeTruthy();
- // The server signals async indexing with status:'pending'. publishRealm()
+ // The server signals async indexing with status:'pending'. publish
// must surface that value rather than failing the call — earlier
// boxel-home CI broke when callers required 200/201 and got a 202.
expect(result.status).toBe('pending');
@@ -76,11 +104,20 @@ describe('realm publish (integration)', () => {
let sourceUrl = await createSourceRealm();
let publishedUrl = uniquePublishedUrl();
- let result = await publishRealm(sourceUrl, publishedUrl, {
- profileManager,
- waitForReady: false,
- force: true,
- });
+ let res = await runBoxel(
+ [
+ 'realm',
+ 'publish',
+ sourceUrl,
+ publishedUrl,
+ '--force',
+ '--no-wait',
+ '--json',
+ ],
+ { home },
+ );
+ expect(res.ok, res.stderr).toBe(true);
+ let result = res.json();
expect(result.publishedRealmURL).toBe(publishedUrl);
expect(result.status).toBe('pending');
@@ -90,20 +127,29 @@ describe('realm publish (integration)', () => {
let sourceUrl = await createSourceRealm();
let publishedUrl = uniquePublishedUrl();
- await publishRealm(sourceUrl, publishedUrl, {
- profileManager,
- waitForReady: false,
- force: true,
- });
+ let first = await runBoxel(
+ ['realm', 'publish', sourceUrl, publishedUrl, '--force', '--no-wait'],
+ { home },
+ );
+ expect(first.ok, first.stderr).toBe(true);
- // Republishing the same URL must succeed via the action's auto-recovery
+ // Republishing the same URL must succeed via the command's auto-recovery
// path (unpublish-then-retry on 400/409). This mirrors what
// boxel-home's PR preview flow needs across successive PR pushes.
- let republished = await publishRealm(sourceUrl, publishedUrl, {
- profileManager,
- waitForReady: false,
- force: true,
- });
+ let res = await runBoxel(
+ [
+ 'realm',
+ 'publish',
+ sourceUrl,
+ publishedUrl,
+ '--force',
+ '--no-wait',
+ '--json',
+ ],
+ { home },
+ );
+ expect(res.ok, res.stderr).toBe(true);
+ let republished = res.json();
expect(republished.publishedRealmURL).toBe(publishedUrl);
}, 90_000);
@@ -112,38 +158,53 @@ describe('realm publish (integration)', () => {
let bogusSource = `${TEST_REALM_SERVER_URL}/does-not-exist-${uniqueRealmName()}/`;
let publishedUrl = uniquePublishedUrl();
- await expect(
- publishRealm(bogusSource, publishedUrl, {
- profileManager,
- waitForReady: false,
- republish: false,
- // Bypass the publishability gate so this exercises the publish POST's
- // failure path (the gate would otherwise fail first on the missing
- // realm's `_publishability` endpoint).
- force: true,
- }),
- ).rejects.toThrow(/Publish failed: HTTP/);
+ // Bypass the publishability gate with --force so this exercises the
+ // publish POST's failure path (the gate would otherwise fail first on
+ // the missing realm's `_publishability` endpoint).
+ let res = await runBoxel(
+ [
+ 'realm',
+ 'publish',
+ bogusSource,
+ publishedUrl,
+ '--no-wait',
+ '--no-republish',
+ '--force',
+ ],
+ { home },
+ );
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toContain('Publish failed: HTTP');
}, 30_000);
it('blocks publishing an unpublishable realm unless forced', async () => {
// The noop prerenderer makes every indexed instance an error document, so
// a freshly created realm trips the publishability gate. The gate (on by
- // default) refuses to publish; `force` bypasses it.
+ // default) refuses to publish; --force bypasses it.
let sourceUrl = await createSourceRealm();
let publishedUrl = uniquePublishedUrl();
- await expect(
- publishRealm(sourceUrl, publishedUrl, {
- profileManager,
- waitForReady: false,
- }),
- ).rejects.toThrow(/not publishable/);
-
- let result = await publishRealm(sourceUrl, publishedUrl, {
- profileManager,
- waitForReady: false,
- force: true,
- });
+ let blocked = await runBoxel(
+ ['realm', 'publish', sourceUrl, publishedUrl, '--no-wait'],
+ { home },
+ );
+ expect(blocked.exitCode).toBe(1);
+ expect(blocked.stderr).toContain('not publishable');
+
+ let res = await runBoxel(
+ [
+ 'realm',
+ 'publish',
+ sourceUrl,
+ publishedUrl,
+ '--no-wait',
+ '--force',
+ '--json',
+ ],
+ { home },
+ );
+ expect(res.ok, res.stderr).toBe(true);
+ let result = res.json();
expect(result.publishedRealmURL).toBe(publishedUrl);
}, 90_000);
});
@@ -153,13 +214,17 @@ describe('realm unpublish (integration)', () => {
let sourceUrl = await createSourceRealm();
let publishedUrl = uniquePublishedUrl();
- await publishRealm(sourceUrl, publishedUrl, {
- profileManager,
- waitForReady: false,
- force: true,
- });
+ let published = await runBoxel(
+ ['realm', 'publish', sourceUrl, publishedUrl, '--force', '--no-wait'],
+ { home },
+ );
+ expect(published.ok, published.stderr).toBe(true);
- let result = await unpublishRealm(publishedUrl, { profileManager });
+ let res = await runBoxel(['realm', 'unpublish', publishedUrl, '--json'], {
+ home,
+ });
+ expect(res.ok, res.stderr).toBe(true);
+ let result = res.json();
expect(result.unpublished).toBe(true);
expect(result.error).toBeUndefined();
@@ -168,10 +233,12 @@ describe('realm unpublish (integration)', () => {
it('treats a missing realm as success when tolerateMissing is set', async () => {
let bogusUrl = `${TEST_REALM_SERVER_URL}/never-published-${uniqueRealmName()}/`;
- let result = await unpublishRealm(bogusUrl, {
- profileManager,
- tolerateMissing: true,
- });
+ let res = await runBoxel(
+ ['realm', 'unpublish', bogusUrl, '--tolerate-missing', '--json'],
+ { home },
+ );
+ expect(res.ok, res.stderr).toBe(true);
+ let result = res.json();
expect(result.unpublished).toBe(false);
expect(result.notFound).toBe(true);
@@ -181,7 +248,13 @@ describe('realm unpublish (integration)', () => {
it('reports an error for a missing realm when tolerateMissing is unset', async () => {
let bogusUrl = `${TEST_REALM_SERVER_URL}/never-published-${uniqueRealmName()}/`;
- let result = await unpublishRealm(bogusUrl, { profileManager });
+ let res = await runBoxel(['realm', 'unpublish', bogusUrl, '--json'], {
+ home,
+ });
+ // In --json mode the command prints the result payload to stdout and
+ // still exits non-zero when it carries an error.
+ expect(res.exitCode).toBe(1);
+ let result = res.json();
expect(result.unpublished).toBe(false);
expect(result.notFound).toBe(true);
diff --git a/packages/boxel-cli/tests/integration/realm-pull-seed-auth.test.ts b/packages/boxel-cli/tests/integration/realm-pull-seed-auth.test.ts
index c4c1600502f..f9453210d3c 100644
--- a/packages/boxel-cli/tests/integration/realm-pull-seed-auth.test.ts
+++ b/packages/boxel-cli/tests/integration/realm-pull-seed-auth.test.ts
@@ -3,28 +3,26 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
-import { pull } from '../../src/commands/realm/pull.ts';
-import { SeedAuthenticator } from '../../src/lib/seed-auth.ts';
import {
startTestRealmServer,
stopTestRealmServer,
- createTestProfileDir,
+ createTestHome,
TEST_REALM_SERVER_URL,
} from '../helpers/integration.ts';
+import { runBoxel } from '../helpers/run-boxel.ts';
-// The test realm server in helpers/integration.ts uses `realmSecretSeed =
-// "shhh! it's a secret"` to sign realm JWTs and
-// `username = 'node-test_realm-server'` for the realm's matrix client, so the
-// bot id the realm short-circuits on is `@node-test_realm-server:localhost`.
-//
-// In real deployments this would just be `@realm_server:`, but the test
-// helper deviates from the convention — we feed the exact expected bot id to
-// SeedAuthenticator via the `botUserId` override.
+// The test realm server in helpers/integration.ts signs realm JWTs with
+// `realmSecretSeed = "shhh! it's a secret"`. Driving the CLI with that seed in
+// `BOXEL_REALM_SECRET_SEED` exercises the administrative seed-auth path: the
+// CLI mints a JWT locally (default `realm_server` username) and never performs
+// a Matrix login. The test realm permits read/write to any authenticated user
+// (`'*': ['read','write']`), so the download succeeds whenever the JWT is
+// signed with the right seed.
const TEST_REALM_SECRET_SEED = `shhh! it's a secret`;
-const TEST_REALM_BOT_USER_ID = '@node-test_realm-server:localhost';
let realmUrl: string;
let localDirs: string[] = [];
+let homes: string[] = [];
function makeLocalDir(): string {
let dir = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-pull-seed-int-'));
@@ -32,6 +30,14 @@ function makeLocalDir(): string {
return dir;
}
+// A throwaway empty home (no profile seeded) so the CLI can only authenticate
+// from the seed, never a Matrix profile.
+function makeEmptyHome(): string {
+ let { home } = createTestHome();
+ homes.push(home);
+ return home;
+}
+
beforeAll(async () => {
await startTestRealmServer({
fileSystem: {
@@ -46,70 +52,54 @@ afterAll(async () => {
for (let dir of localDirs) {
fs.rmSync(dir, { recursive: true, force: true });
}
+ for (let home of homes) {
+ fs.rmSync(home, { recursive: true, force: true });
+ }
await stopTestRealmServer();
});
describe('realm pull with seed-based auth (integration)', () => {
it('pulls files authenticating via a locally-minted JWT (no Matrix login)', async () => {
let localDir = makeLocalDir();
-
// Empty profile: no Matrix login credentials exist at all. The CLI must
- // authenticate purely from the seed.
- let { profileManager, cleanup } = createTestProfileDir();
+ // authenticate purely from the seed supplied via the environment.
+ let home = makeEmptyHome();
- try {
- let authenticator = new SeedAuthenticator({
- seed: TEST_REALM_SECRET_SEED,
- botUserId: TEST_REALM_BOT_USER_ID,
- });
+ let res = await runBoxel(['realm', 'pull', realmUrl, localDir], {
+ home,
+ env: { BOXEL_REALM_SECRET_SEED: TEST_REALM_SECRET_SEED },
+ });
+ expect(res.ok, res.stderr).toBe(true);
- let result = await pull(realmUrl, localDir, {
- authenticator,
- profileManager,
- });
-
- expect(result.error).toBeUndefined();
- expect(result.files).toContain('hello.gts');
- expect(result.files).toContain('nested/card.gts');
-
- let helloPath = path.join(localDir, 'hello.gts');
- expect(fs.existsSync(helloPath)).toBe(true);
- expect(fs.readFileSync(helloPath, 'utf8')).toContain('hello = "world"');
- } finally {
- cleanup();
- }
+ let helloPath = path.join(localDir, 'hello.gts');
+ let nestedPath = path.join(localDir, 'nested', 'card.gts');
+ expect(fs.existsSync(helloPath)).toBe(true);
+ expect(fs.existsSync(nestedPath)).toBe(true);
+ expect(fs.readFileSync(helloPath, 'utf8')).toContain('hello = "world"');
});
it('fails cleanly with the "No active profile" error when neither a seed nor a profile is configured', async () => {
let localDir = makeLocalDir();
- let { profileManager, cleanup } = createTestProfileDir();
- try {
- let result = await pull(realmUrl, localDir, { profileManager });
- expect(result.files).toEqual([]);
- expect(result.error).toContain('No active profile');
- } finally {
- cleanup();
- }
+ let home = makeEmptyHome();
+
+ let res = await runBoxel(['realm', 'pull', realmUrl, localDir], { home });
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toContain('No active profile');
});
- it('resolves --realm-secret-seed through the CLI resolver without requiring a profile', async () => {
- // Exercises the flag-driven path end-to-end: the CLI builds a
- // SeedAuthenticator from the seed using the default `realm_server`
- // username, no Matrix login is attempted, and no "No active profile"
- // error surfaces even with an empty profile dir. The test realm permits
- // read access to any authenticated user (`'*': ['read','write']`), so
- // the download succeeds whenever the JWT is signed with the right seed.
+ it('resolves the realm secret seed through the CLI resolver without requiring a profile', async () => {
+ // Exercises the seed-driven path end-to-end: the CLI resolves
+ // BOXEL_REALM_SECRET_SEED, builds a SeedAuthenticator using the default
+ // `realm_server` username, attempts no Matrix login, and surfaces no
+ // "No active profile" error even with an empty profile dir.
let localDir = makeLocalDir();
- let emptyProfile = createTestProfileDir();
- try {
- let result = await pull(realmUrl, localDir, {
- realmSecretSeed: TEST_REALM_SECRET_SEED,
- profileManager: emptyProfile.profileManager,
- });
- expect(result.error).toBeUndefined();
- expect(result.files).toContain('hello.gts');
- } finally {
- emptyProfile.cleanup();
- }
+ let home = makeEmptyHome();
+
+ let res = await runBoxel(['realm', 'pull', realmUrl, localDir], {
+ home,
+ env: { BOXEL_REALM_SECRET_SEED: TEST_REALM_SECRET_SEED },
+ });
+ expect(res.ok, res.stderr).toBe(true);
+ expect(fs.existsSync(path.join(localDir, 'hello.gts'))).toBe(true);
});
});
diff --git a/packages/boxel-cli/tests/integration/realm-pull.test.ts b/packages/boxel-cli/tests/integration/realm-pull.test.ts
index 4d4aa8a48dd..2919d9b94fa 100644
--- a/packages/boxel-cli/tests/integration/realm-pull.test.ts
+++ b/packages/boxel-cli/tests/integration/realm-pull.test.ts
@@ -3,19 +3,23 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
-import { pull } from '../../src/commands/realm/pull.ts';
import { CheckpointManager } from '../../src/lib/checkpoint-manager.ts';
-import type { ProfileManager } from '../../src/lib/profile-manager.ts';
import {
startTestRealmServer,
stopTestRealmServer,
- createTestProfileDir,
+ createTestHome,
+ reloadProfile,
setupTestProfile,
TEST_REALM_SERVER_URL,
} from '../helpers/integration.ts';
+import { runBoxel } from '../helpers/run-boxel.ts';
import { TINY_PNG_BYTES } from '../helpers/binary-fixtures.ts';
-let profileManager: ProfileManager;
+// `boxel realm pull ` is driven as a subprocess. The
+// local directory, `.boxel-history` checkpoints, and downloaded files are
+// inspected in-process; only the pull COMMAND goes through the binary.
+
+let home: string;
let cleanupProfile: () => void;
let realmUrl: string;
let localDirs: string[] = [];
@@ -26,6 +30,16 @@ function makeLocalDir(): string {
return dir;
}
+// Drive the pull subprocess. Note the argv order: pull takes
+// first, then (the reverse of push).
+function runPull(
+ realmUrlArg: string,
+ localDir: string,
+ flags: string[] = [],
+): ReturnType {
+ return runBoxel(['realm', 'pull', realmUrlArg, localDir, ...flags], { home });
+}
+
beforeAll(async () => {
// Seed files into the realm at creation time, matching the realm-server
// test pattern of passing a fileSystem to the realm setup helpers.
@@ -39,10 +53,10 @@ beforeAll(async () => {
realmUrl = `${TEST_REALM_SERVER_URL}/test/`;
- let testProfile = createTestProfileDir();
- profileManager = testProfile.profileManager;
- cleanupProfile = testProfile.cleanup;
- await setupTestProfile(profileManager);
+ let testHome = createTestHome();
+ home = testHome.home;
+ cleanupProfile = testHome.cleanup;
+ await setupTestProfile(testHome.profileManager);
});
afterAll(async () => {
@@ -57,11 +71,8 @@ describe('realm pull (integration)', () => {
it('pulls seeded files into an empty local directory', async () => {
let localDir = makeLocalDir();
- let result = await pull(realmUrl, localDir, { profileManager });
-
- expect(result.error).toBeUndefined();
- expect(result.files).toContain('hello.gts');
- expect(result.files).toContain('nested/card.gts');
+ let res = await runPull(realmUrl, localDir);
+ expect(res.ok, res.stderr).toBe(true);
let helloPath = path.join(localDir, 'hello.gts');
let nestedPath = path.join(localDir, 'nested', 'card.gts');
@@ -86,12 +97,8 @@ describe('realm pull (integration)', () => {
it('writes nothing when invoked with --dry-run', async () => {
let localDir = makeLocalDir();
- let result = await pull(realmUrl, localDir, {
- dryRun: true,
- profileManager,
- });
-
- expect(result.error).toBeUndefined();
+ let res = await runPull(realmUrl, localDir, ['--dry-run']);
+ expect(res.ok, res.stderr).toBe(true);
let entries = fs
.readdirSync(localDir)
@@ -112,9 +119,9 @@ describe('realm pull (integration)', () => {
'{"local":"only"}',
);
- let result = await pull(realmUrl, localDir, { profileManager });
+ let res = await runPull(realmUrl, localDir);
+ expect(res.ok, res.stderr).toBe(true);
- expect(result.error).toBeUndefined();
expect(fs.existsSync(path.join(localDir, 'hello.gts'))).toBe(true);
expect(fs.existsSync(path.join(localOnlyDir, 'local-only.json'))).toBe(
true,
@@ -127,12 +134,9 @@ describe('realm pull (integration)', () => {
let stalePath = path.join(localDir, staleRel);
fs.writeFileSync(stalePath, 'export const stale = true;\n', 'utf8');
- let result = await pull(realmUrl, localDir, {
- delete: true,
- profileManager,
- });
+ let res = await runPull(realmUrl, localDir, ['--delete']);
+ expect(res.ok, res.stderr).toBe(true);
- expect(result.error).toBeUndefined();
expect(fs.existsSync(stalePath)).toBe(false);
expect(fs.existsSync(path.join(localDir, 'hello.gts'))).toBe(true);
@@ -152,9 +156,9 @@ describe('realm pull (integration)', () => {
it('pulls subdirectories recursively', async () => {
let localDir = makeLocalDir();
- let result = await pull(realmUrl, localDir, { profileManager });
+ let res = await runPull(realmUrl, localDir);
+ expect(res.ok, res.stderr).toBe(true);
- expect(result.error).toBeUndefined();
expect(fs.existsSync(path.join(localDir, 'hello.gts'))).toBe(true);
expect(fs.existsSync(path.join(localDir, 'nested', 'card.gts'))).toBe(true);
expect(
@@ -173,11 +177,8 @@ describe('realm pull (integration)', () => {
let stalePath = path.join(localDir, 'stale.gts');
fs.writeFileSync(stalePath, 'export const stale = true;\n', 'utf8');
- await pull(realmUrl, localDir, {
- delete: true,
- dryRun: true,
- profileManager,
- });
+ let res = await runPull(realmUrl, localDir, ['--delete', '--dry-run']);
+ expect(res.ok, res.stderr).toBe(true);
expect(fs.existsSync(stalePath)).toBe(true);
expect(fs.existsSync(path.join(localDir, '.boxel-history'))).toBe(false);
@@ -186,10 +187,8 @@ describe('realm pull (integration)', () => {
it('creates only a post-pull checkpoint when --delete has nothing to delete', async () => {
let localDir = makeLocalDir();
- await pull(realmUrl, localDir, {
- delete: true,
- profileManager,
- });
+ let res = await runPull(realmUrl, localDir, ['--delete']);
+ expect(res.ok, res.stderr).toBe(true);
let cm = new CheckpointManager(localDir);
let checkpoints = await cm.getCheckpoints();
@@ -201,11 +200,13 @@ describe('realm pull (integration)', () => {
it('re-pulling an up-to-date directory adds no new checkpoint', async () => {
let localDir = makeLocalDir();
- await pull(realmUrl, localDir, { profileManager });
+ let res1 = await runPull(realmUrl, localDir);
+ expect(res1.ok, res1.stderr).toBe(true);
let cm = new CheckpointManager(localDir);
let afterFirst = (await cm.getCheckpoints()).length;
- await pull(realmUrl, localDir, { profileManager });
+ let res2 = await runPull(realmUrl, localDir);
+ expect(res2.ok, res2.stderr).toBe(true);
let afterSecond = (await cm.getCheckpoints()).length;
expect(afterSecond).toBe(afterFirst);
@@ -217,7 +218,8 @@ describe('realm pull (integration)', () => {
// sanity: directory does not exist before the pull
expect(fs.existsSync(localDir)).toBe(false);
- await pull(realmUrl, localDir, { profileManager });
+ let res = await runPull(realmUrl, localDir);
+ expect(res.ok, res.stderr).toBe(true);
expect(fs.existsSync(localDir)).toBe(true);
expect(fs.existsSync(path.join(localDir, 'hello.gts'))).toBe(true);
@@ -232,7 +234,8 @@ describe('realm pull (integration)', () => {
let helloPath = path.join(localDir, 'hello.gts');
fs.writeFileSync(helloPath, 'export const hello = "local-edit";\n');
- await pull(realmUrl, localDir, { profileManager });
+ let res = await runPull(realmUrl, localDir);
+ expect(res.ok, res.stderr).toBe(true);
expect(fs.readFileSync(helloPath, 'utf8')).toContain('hello = "world"');
@@ -242,27 +245,26 @@ describe('realm pull (integration)', () => {
expect(checkpoints[0].source).toBe('remote');
});
- it('returns an error (not process.exit) when no active profile is configured', async () => {
- let emptyProfile = createTestProfileDir();
+ it('exits non-zero with a clear error when no active profile is configured', async () => {
+ let emptyHome = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-pull-empty-'));
+ let localDir = makeLocalDir();
try {
- let result = await pull(realmUrl, makeLocalDir(), {
- profileManager: emptyProfile.profileManager,
+ let res = await runBoxel(['realm', 'pull', realmUrl, localDir], {
+ home: emptyHome,
});
- expect(result.files).toEqual([]);
- expect(result.error).toContain('No active profile');
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toContain('No active profile');
} finally {
- emptyProfile.cleanup();
+ fs.rmSync(emptyHome, { recursive: true, force: true });
}
});
- it('returns an error when the realm URL is unreachable', async () => {
+ it('exits non-zero when the realm URL is unreachable', async () => {
let localDir = makeLocalDir();
- let result = await pull('http://127.0.0.1:1/nonexistent/', localDir, {
- profileManager,
- });
+ let res = await runPull('http://127.0.0.1:1/nonexistent/', localDir);
- expect(result.error).toBeDefined();
- expect(result.files).toEqual([]);
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr.length).toBeGreaterThan(0);
});
// --- Binary file downloads (CS-11075) ---
@@ -273,16 +275,17 @@ describe('realm pull (integration)', () => {
// Seed the realm with raw bytes via the octet-stream endpoint (the
// canonical wire format the realm-server's upsertBinaryFile route
// expects). The startTestRealmServer fileSystem option only accepts
- // strings, so we POST after server start.
+ // strings, so we POST after server start (in-process realm state setup).
let pngUrl = new URL('image.png', realmUrl).href;
- let seedResponse = await profileManager.authedRealmFetch(pngUrl, {
+ let seedResponse = await reloadProfile(home).authedRealmFetch(pngUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: TINY_PNG_BYTES,
});
expect(seedResponse.ok).toBe(true);
- await pull(realmUrl, localDir, { profileManager });
+ let res = await runPull(realmUrl, localDir);
+ expect(res.ok, res.stderr).toBe(true);
let localPath = path.join(localDir, 'image.png');
let pulled = fs.readFileSync(localPath);
diff --git a/packages/boxel-cli/tests/integration/realm-push-rri.test.ts b/packages/boxel-cli/tests/integration/realm-push-rri.test.ts
index a2d303765a7..4cc08fd4144 100644
--- a/packages/boxel-cli/tests/integration/realm-push-rri.test.ts
+++ b/packages/boxel-cli/tests/integration/realm-push-rri.test.ts
@@ -3,25 +3,25 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
-import { pushCommand } from '../../src/commands/realm/push.ts';
import {
startTestRealmServer,
stopTestRealmServer,
- createTestProfileDir,
+ createTestHome,
+ reloadProfile,
setupTestProfile,
TEST_REALM_SERVER_URL,
} from '../helpers/integration.ts';
-import type { ProfileManager } from '../../src/lib/profile-manager.ts';
+import { runBoxel } from '../helpers/run-boxel.ts';
// A realm with a registered prefix mapping serves its document ids in RRI
// form (`@cli-test/prefixed/...`) rather than as URLs — the shape that made
// `realm push` crash after uploading (raw ids leaked into the succeeded list
// and were treated as local file paths). This suite pushes against such a
-// realm end to end.
+// realm end to end through the installed CLI binary.
const REALM_PREFIX = '@cli-test/prefixed/';
-let profileManager: ProfileManager;
+let home: string;
let cleanupProfile: () => void;
let realmUrl: string;
let localDirs: string[] = [];
@@ -58,10 +58,10 @@ beforeAll(async () => {
realmPrefixes: { [REALM_PREFIX]: realmUrl },
});
- let testProfile = createTestProfileDir();
- profileManager = testProfile.profileManager;
- cleanupProfile = testProfile.cleanup;
- await setupTestProfile(profileManager);
+ let testHome = createTestHome();
+ home = testHome.home;
+ cleanupProfile = testHome.cleanup;
+ await setupTestProfile(testHome.profileManager);
});
afterAll(async () => {
@@ -76,27 +76,32 @@ describe('realm push against a prefix-form RRI realm (integration)', () => {
it('the realm serves atomic result ids in prefix form', async () => {
// Pins the precondition the regression test below relies on: if the
// server stops answering in RRI form, this fails rather than the suite
- // silently testing the URL-form path.
- let response = await profileManager.authedRealmFetch(`${realmUrl}_atomic`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/vnd.api+json',
- Accept: 'application/vnd.api+json',
- },
- body: JSON.stringify({
- 'atomic:operations': [
- {
- op: 'add',
- href: `${realmUrl}precondition-check.txt`,
- data: {
- type: 'source',
- attributes: { content: 'x\n' },
- meta: {},
+ // silently testing the URL-form path. This is a direct realm-state
+ // probe, so it stays an in-process fetch (using the profile the CLI
+ // authenticated against).
+ let response = await reloadProfile(home).authedRealmFetch(
+ `${realmUrl}_atomic`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/vnd.api+json',
+ Accept: 'application/vnd.api+json',
+ },
+ body: JSON.stringify({
+ 'atomic:operations': [
+ {
+ op: 'add',
+ href: `${realmUrl}precondition-check.txt`,
+ data: {
+ type: 'source',
+ attributes: { content: 'x\n' },
+ meta: {},
+ },
},
- },
- ],
- }),
- });
+ ],
+ }),
+ },
+ );
expect(response.status).toBe(201);
let body = (await response.json()) as {
'atomic:results': Array<{ data?: { id?: string } }>;
@@ -111,7 +116,8 @@ describe('realm push against a prefix-form RRI realm (integration)', () => {
writeLocalFile(localDir, 'hello.txt', 'hello\n');
writeLocalFile(localDir, 'nested/card.gts', 'export const x = 1;\n');
- await pushCommand(localDir, realmUrl, { profileManager });
+ let res = await runBoxel(['realm', 'push', localDir, realmUrl], { home });
+ expect(res.ok, res.stderr).toBe(true);
let manifest = readManifest(localDir);
expect(manifest.realmUrl).toBe(realmUrl);
diff --git a/packages/boxel-cli/tests/integration/realm-push.test.ts b/packages/boxel-cli/tests/integration/realm-push.test.ts
index 1f595d4f62e..66c8afe2118 100644
--- a/packages/boxel-cli/tests/integration/realm-push.test.ts
+++ b/packages/boxel-cli/tests/integration/realm-push.test.ts
@@ -4,23 +4,30 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { pushCommand } from '../../src/commands/realm/push.ts';
-import { createRealm } from '../../src/commands/realm/create.ts';
import { CheckpointManager } from '../../src/lib/checkpoint-manager.ts';
import {
startTestRealmServer,
stopTestRealmServer,
- createTestProfileDir,
+ createTestHome,
+ reloadProfile,
setupTestProfile,
- uniqueRealmName,
+ createTestRealmViaCli,
} from '../helpers/integration.ts';
+import { runBoxel } from '../helpers/run-boxel.ts';
import {
TINY_PNG_BYTES,
TINY_PDF_BYTES,
TINY_MP3_BYTES,
} from '../helpers/binary-fixtures.ts';
-import type { ProfileManager } from '../../src/lib/profile-manager.ts';
-let profileManager: ProfileManager;
+// `boxel realm push ` is driven as a subprocess. The
+// local working directory, the `.boxel-sync.json` manifest, and the
+// CheckpointManager history are all inspected in-process from the same
+// directory the CLI wrote — only the push COMMAND goes through the binary.
+// Realm state is verified with a fresh profile loaded from the home the CLI
+// authenticated against (`reloadProfile(home)`).
+
+let home: string;
let cleanupProfile: () => void;
let localDirs: string[] = [];
@@ -42,12 +49,21 @@ function writeLocalBytes(localDir: string, relPath: string, bytes: Uint8Array) {
fs.writeFileSync(fullPath, bytes);
}
+// Drive the push subprocess against the CLI-authenticated home.
+function runPush(
+ localDir: string,
+ realmUrl: string,
+ flags: string[] = [],
+): ReturnType {
+ return runBoxel(['realm', 'push', localDir, realmUrl, ...flags], { home });
+}
+
async function fetchRemoteBytes(
realmUrl: string,
relPath: string,
): Promise {
let url = buildFileUrl(realmUrl, relPath);
- let response = await profileManager.authedRealmFetch(url, {
+ let response = await reloadProfile(home).authedRealmFetch(url, {
headers: { Accept: 'application/vnd.card+source' },
});
if (!response.ok) {
@@ -74,18 +90,10 @@ function manifestExists(localDir: string): boolean {
return fs.existsSync(path.join(localDir, '.boxel-sync.json'));
}
-// Create a fresh realm and return its URL
+// Create a fresh realm via the CLI and return its URL.
async function createTestRealm(): Promise {
- let name = uniqueRealmName();
- await createRealm(name, `Test ${name}`, { profileManager });
-
- let realmTokens =
- profileManager.getActiveProfile()!.profile.realmTokens ?? {};
- let entry = Object.entries(realmTokens).find(([url]) => url.includes(name));
- if (!entry) {
- throw new Error(`No realm JWT stored for ${name}`);
- }
- return entry[0];
+ let { realmUrl } = await createTestRealmViaCli(home);
+ return realmUrl;
}
function buildFileUrl(realmUrl: string, relPath: string): string {
@@ -99,7 +107,7 @@ async function fetchRemoteFile(
relPath: string,
): Promise {
let url = buildFileUrl(realmUrl, relPath);
- let response = await profileManager.authedRealmFetch(url, {
+ let response = await reloadProfile(home).authedRealmFetch(url, {
headers: { Accept: 'application/vnd.card+source' },
});
if (!response.ok) {
@@ -115,7 +123,7 @@ async function remoteFileExists(
relPath: string,
): Promise {
let url = buildFileUrl(realmUrl, relPath);
- let response = await profileManager.authedRealmFetch(url, {
+ let response = await reloadProfile(home).authedRealmFetch(url, {
headers: { Accept: 'application/vnd.card+source' },
});
return response.ok;
@@ -127,7 +135,7 @@ async function deleteRemoteFile(
relPath: string,
): Promise {
let url = buildFileUrl(realmUrl, relPath);
- let response = await profileManager.authedRealmFetch(url, {
+ let response = await reloadProfile(home).authedRealmFetch(url, {
method: 'DELETE',
headers: { Accept: 'application/vnd.card+source' },
});
@@ -144,7 +152,7 @@ async function writeRemoteFile(
content: string,
): Promise {
let url = buildFileUrl(realmUrl, relPath);
- let response = await profileManager.authedRealmFetch(url, {
+ let response = await reloadProfile(home).authedRealmFetch(url, {
method: 'POST',
headers: {
'Content-Type': 'text/plain;charset=UTF-8',
@@ -162,10 +170,10 @@ async function writeRemoteFile(
beforeAll(async () => {
await startTestRealmServer();
- let testProfile = createTestProfileDir();
- profileManager = testProfile.profileManager;
- cleanupProfile = testProfile.cleanup;
- await setupTestProfile(profileManager);
+ let testHome = createTestHome();
+ home = testHome.home;
+ cleanupProfile = testHome.cleanup;
+ await setupTestProfile(testHome.profileManager);
});
afterAll(async () => {
@@ -184,7 +192,8 @@ describe('realm push (integration)', () => {
writeLocalFile(localDir, 'card.gts', 'export const card = true;\n');
writeLocalFile(localDir, 'data.json', '{"title":"Hello"}\n');
- await pushCommand(localDir, realmUrl, { profileManager });
+ let res = await runPush(localDir, realmUrl);
+ expect(res.ok, res.stderr).toBe(true);
// Manifest assertions
expect(manifestExists(localDir)).toBe(true);
@@ -225,7 +234,8 @@ describe('realm push (integration)', () => {
writeLocalFile(localDir, 'a.gts', 'export const a = 1;\n');
writeLocalFile(localDir, 'b.gts', 'export const b = 2;\n');
- await pushCommand(localDir, realmUrl, { profileManager });
+ let res1 = await runPush(localDir, realmUrl);
+ expect(res1.ok, res1.stderr).toBe(true);
let manifestAfterFirst = readManifest(localDir);
let bHashFirst = manifestAfterFirst.files['b.gts'];
let aHashFirst = manifestAfterFirst.files['a.gts'];
@@ -236,7 +246,8 @@ describe('realm push (integration)', () => {
// Modify only one file
writeLocalFile(localDir, 'a.gts', 'export const a = 999;\n');
- await pushCommand(localDir, realmUrl, { profileManager });
+ let res2 = await runPush(localDir, realmUrl);
+ expect(res2.ok, res2.stderr).toBe(true);
let manifestAfterSecond = readManifest(localDir);
expect(await fetchRemoteFile(realmUrl, 'a.gts')).toContain('a = 999');
@@ -258,7 +269,8 @@ describe('realm push (integration)', () => {
let localDir = makeLocalDir();
writeLocalFile(localDir, 'noop.gts', 'export const noop = true;\n');
- await pushCommand(localDir, realmUrl, { profileManager });
+ let res1 = await runPush(localDir, realmUrl);
+ expect(res1.ok, res1.stderr).toBe(true);
let manifestBefore = fs.readFileSync(
path.join(localDir, '.boxel-sync.json'),
@@ -268,7 +280,8 @@ describe('realm push (integration)', () => {
let cm = new CheckpointManager(localDir);
let baseline = (await cm.getCheckpoints()).length;
- await pushCommand(localDir, realmUrl, { profileManager });
+ let res2 = await runPush(localDir, realmUrl);
+ expect(res2.ok, res2.stderr).toBe(true);
let manifestAfter = fs.readFileSync(
path.join(localDir, '.boxel-sync.json'),
@@ -284,10 +297,12 @@ describe('realm push (integration)', () => {
writeLocalFile(localDir, 'file.gts', 'export const v = 1;\n');
- await pushCommand(localDir, realmUrl, { profileManager });
+ let res1 = await runPush(localDir, realmUrl);
+ expect(res1.ok, res1.stderr).toBe(true);
let manifestAfterFirst = readManifest(localDir);
- await pushCommand(localDir, realmUrl, { force: true, profileManager });
+ let res2 = await runPush(localDir, realmUrl, ['--force']);
+ expect(res2.ok, res2.stderr).toBe(true);
let manifestAfterForce = readManifest(localDir);
expect(await remoteFileExists(realmUrl, 'file.gts')).toBe(true);
@@ -312,10 +327,12 @@ describe('realm push (integration)', () => {
let localDir = makeLocalDir();
writeLocalFile(localDir, 'file.gts', 'export const v = 1;\n');
- await pushCommand(localDir, realmUrl, { profileManager });
+ let res1 = await runPush(localDir, realmUrl);
+ expect(res1.ok, res1.stderr).toBe(true);
writeLocalFile(localDir, 'file.gts', 'export const v = 2;\n');
- await pushCommand(localDir, realmUrl, { force: true, profileManager });
+ let res2 = await runPush(localDir, realmUrl, ['--force']);
+ expect(res2.ok, res2.stderr).toBe(true);
expect(await fetchRemoteFile(realmUrl, 'file.gts')).toContain('v = 2');
@@ -331,13 +348,15 @@ describe('realm push (integration)', () => {
writeLocalFile(localDir, 'keep.gts', 'export const keep = true;\n');
writeLocalFile(localDir, 'remove.gts', 'export const remove = true;\n');
- await pushCommand(localDir, realmUrl, { profileManager });
+ let res1 = await runPush(localDir, realmUrl);
+ expect(res1.ok, res1.stderr).toBe(true);
let cm = new CheckpointManager(localDir);
let baseline = (await cm.getCheckpoints()).length;
fs.unlinkSync(path.join(localDir, 'remove.gts'));
- await pushCommand(localDir, realmUrl, { delete: true, profileManager });
+ let res2 = await runPush(localDir, realmUrl, ['--delete']);
+ expect(res2.ok, res2.stderr).toBe(true);
expect(await remoteFileExists(realmUrl, 'keep.gts')).toBe(true);
expect(await remoteFileExists(realmUrl, 'remove.gts')).toBe(false);
@@ -358,7 +377,8 @@ describe('realm push (integration)', () => {
writeLocalFile(localDir, 'draft.gts', 'export const draft = true;\n');
- await pushCommand(localDir, realmUrl, { dryRun: true, profileManager });
+ let res = await runPush(localDir, realmUrl, ['--dry-run']);
+ expect(res.ok, res.stderr).toBe(true);
expect(manifestExists(localDir)).toBe(false);
expect(await remoteFileExists(realmUrl, 'draft.gts')).toBe(false);
@@ -379,7 +399,8 @@ describe('realm push (integration)', () => {
'{"realmUrl":"old","files":{}}',
);
- await pushCommand(localDir, realmUrl, { profileManager });
+ let res = await runPush(localDir, realmUrl);
+ expect(res.ok, res.stderr).toBe(true);
expect(await remoteFileExists(realmUrl, 'card.gts')).toBe(true);
expect(await remoteFileExists(realmUrl, '.boxel-sync.json')).toBe(false);
@@ -397,7 +418,8 @@ describe('realm push (integration)', () => {
writeLocalFile(localDir, 'test.ignore', 'should not be uploaded');
writeLocalFile(localDir, 'ignore-dir/ignored.json', '{"ignored":true}\n');
- await pushCommand(localDir, realmUrl, { profileManager });
+ let res = await runPush(localDir, realmUrl);
+ expect(res.ok, res.stderr).toBe(true);
// Non-ignored file is uploaded
expect(await remoteFileExists(realmUrl, 'card.gts')).toBe(true);
@@ -421,7 +443,8 @@ describe('realm push (integration)', () => {
writeLocalFile(localDir, 'a/inner.gts', 'export const inner = 2;\n');
writeLocalFile(localDir, 'a/b/deep.gts', 'export const deep = 3;\n');
- await pushCommand(localDir, realmUrl, { profileManager });
+ let res = await runPush(localDir, realmUrl);
+ expect(res.ok, res.stderr).toBe(true);
expect(await remoteFileExists(realmUrl, 'top.gts')).toBe(true);
expect(await remoteFileExists(realmUrl, 'a/inner.gts')).toBe(true);
@@ -451,7 +474,8 @@ describe('realm push (integration)', () => {
// realm-sync-base).
writeLocalFile(localDir, '.gitkeep', 'locally-edited-marker');
- await pushCommand(localDir, realmUrl, { profileManager });
+ let res = await runPush(localDir, realmUrl);
+ expect(res.ok, res.stderr).toBe(true);
expect(await remoteFileExists(realmUrl, 'card.gts')).toBe(true);
expect(await remoteFileExists(realmUrl, '.gitkeep')).toBe(false);
@@ -468,11 +492,8 @@ describe('realm push (integration)', () => {
writeLocalFile(localDir, 'forced.gts', 'export const forced = true;\n');
- await pushCommand(localDir, realmUrl, {
- force: true,
- dryRun: true,
- profileManager,
- });
+ let res = await runPush(localDir, realmUrl, ['--force', '--dry-run']);
+ expect(res.ok, res.stderr).toBe(true);
expect(manifestExists(localDir)).toBe(false);
expect(await remoteFileExists(realmUrl, 'forced.gts')).toBe(false);
@@ -487,7 +508,8 @@ describe('realm push (integration)', () => {
writeLocalFile(localDir, 'a.gts', 'export const a = 1;\n');
writeLocalFile(localDir, 'b.gts', 'export const b = 2;\n');
- await pushCommand(localDir, realmUrl, { profileManager });
+ let res1 = await runPush(localDir, realmUrl);
+ expect(res1.ok, res1.stderr).toBe(true);
let manifestBefore = fs.readFileSync(
path.join(localDir, '.boxel-sync.json'),
@@ -497,11 +519,8 @@ describe('realm push (integration)', () => {
let baseline = (await cm.getCheckpoints()).length;
fs.unlinkSync(path.join(localDir, 'b.gts'));
- await pushCommand(localDir, realmUrl, {
- delete: true,
- dryRun: true,
- profileManager,
- });
+ let res2 = await runPush(localDir, realmUrl, ['--delete', '--dry-run']);
+ expect(res2.ok, res2.stderr).toBe(true);
expect(await remoteFileExists(realmUrl, 'a.gts')).toBe(true);
expect(await remoteFileExists(realmUrl, 'b.gts')).toBe(true);
@@ -522,11 +541,13 @@ describe('realm push (integration)', () => {
// dirA has a.gts and b.gts
writeLocalFile(dirA, 'a.gts', 'export const a = 1;\n');
writeLocalFile(dirA, 'b.gts', 'export const b = 2;\n');
- await pushCommand(dirA, realmUrl, { profileManager });
+ let resA = await runPush(dirA, realmUrl);
+ expect(resA.ok, resA.stderr).toBe(true);
// Out-of-band: dirB pushes c.gts to the same realm
writeLocalFile(dirB, 'c.gts', 'export const c = 3;\n');
- await pushCommand(dirB, realmUrl, { profileManager });
+ let resB = await runPush(dirB, realmUrl);
+ expect(resB.ok, resB.stderr).toBe(true);
expect(await remoteFileExists(realmUrl, 'c.gts')).toBe(true);
@@ -534,11 +555,8 @@ describe('realm push (integration)', () => {
let baselineA = (await cmA.getCheckpoints()).length;
// Now push from dirA with both flags
- await pushCommand(dirA, realmUrl, {
- force: true,
- delete: true,
- profileManager,
- });
+ let res = await runPush(dirA, realmUrl, ['--force', '--delete']);
+ expect(res.ok, res.stderr).toBe(true);
expect(await remoteFileExists(realmUrl, 'a.gts')).toBe(true);
expect(await remoteFileExists(realmUrl, 'b.gts')).toBe(true);
@@ -563,10 +581,12 @@ describe('realm push (integration)', () => {
writeLocalFile(dirA, 'a.gts', 'export const a = 1;\n');
writeLocalFile(dirA, 'b.gts', 'export const b = 2;\n');
- await pushCommand(dirA, realmUrl, { profileManager });
+ let resA = await runPush(dirA, realmUrl);
+ expect(resA.ok, resA.stderr).toBe(true);
writeLocalFile(dirB, 'c.gts', 'export const c = 3;\n');
- await pushCommand(dirB, realmUrl, { profileManager });
+ let resB = await runPush(dirB, realmUrl);
+ expect(resB.ok, resB.stderr).toBe(true);
let manifestBefore = fs.readFileSync(
path.join(dirA, '.boxel-sync.json'),
@@ -575,12 +595,12 @@ describe('realm push (integration)', () => {
let cmA = new CheckpointManager(dirA);
let baselineA = (await cmA.getCheckpoints()).length;
- await pushCommand(dirA, realmUrl, {
- force: true,
- delete: true,
- dryRun: true,
- profileManager,
- });
+ let res = await runPush(dirA, realmUrl, [
+ '--force',
+ '--delete',
+ '--dry-run',
+ ]);
+ expect(res.ok, res.stderr).toBe(true);
// Out-of-band file remains on the server
expect(await remoteFileExists(realmUrl, 'c.gts')).toBe(true);
@@ -605,19 +625,13 @@ describe('realm push (integration)', () => {
writeLocalFile(localDir, 'b.gts', 'export const b = 2;\n');
writeLocalFile(localDir, 'c.gts', 'export const c = 3;\n');
- let fetchSpy = vi.spyOn(profileManager, 'authedRealmFetch');
- let atomicCalls: typeof fetchSpy.mock.calls;
- try {
- await pushCommand(localDir, realmUrl, { profileManager });
- // Read mock.calls BEFORE mockRestore, which clears call history.
- atomicCalls = fetchSpy.mock.calls.filter(([input, init]) => {
- let url = typeof input === 'string' ? input : (input as URL).href;
- return url.endsWith('/_atomic') && init?.method === 'POST';
- });
- } finally {
- fetchSpy.mockRestore();
- }
- expect(atomicCalls.length).toBe(1);
+ let res = await runPush(localDir, realmUrl);
+ expect(res.ok, res.stderr).toBe(true);
+ // The three text files are batched through a single /_atomic upload —
+ // the CLI logs the batch it sends. The per-request count can't be
+ // observed across the subprocess boundary, so we assert the atomic
+ // path was taken for all three and that every file landed.
+ expect(res.stdout).toContain('3 file(s) via /_atomic');
// All three files landed on the server
expect(await remoteFileExists(realmUrl, 'a.gts')).toBe(true);
@@ -630,7 +644,8 @@ describe('realm push (integration)', () => {
let localDir = makeLocalDir();
writeLocalFile(localDir, 'f.gts', 'export const f = 1;\n');
- await pushCommand(localDir, realmUrl, { profileManager });
+ let res1 = await runPush(localDir, realmUrl);
+ expect(res1.ok, res1.stderr).toBe(true);
await deleteRemoteFile(realmUrl, 'f.gts');
expect(await remoteFileExists(realmUrl, 'f.gts')).toBe(false);
@@ -638,16 +653,8 @@ describe('realm push (integration)', () => {
let cm = new CheckpointManager(localDir);
let baseline = (await cm.getCheckpoints()).length;
- let warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
- let warnedAboutFile = false;
- try {
- await pushCommand(localDir, realmUrl, { profileManager });
- warnedAboutFile = warnSpy.mock.calls
- .map((args) => args.join(' '))
- .some((msg) => msg.includes('f.gts'));
- } finally {
- warnSpy.mockRestore();
- }
+ let res2 = await runPush(localDir, realmUrl);
+ expect(res2.ok, res2.stderr).toBe(true);
// Drift detection re-uploads the file.
expect(await remoteFileExists(realmUrl, 'f.gts')).toBe(true);
@@ -657,8 +664,8 @@ describe('realm push (integration)', () => {
// record a new git commit.
expect((await cm.getCheckpoints()).length).toBe(baseline);
- // The user sees a warning naming the drifted file.
- expect(warnedAboutFile).toBe(true);
+ // The user sees a warning naming the drifted file (on stderr).
+ expect(res2.stderr).toContain('f.gts');
});
it('re-pushes a file that was edited on the realm out-of-band', async () => {
@@ -666,7 +673,8 @@ describe('realm push (integration)', () => {
let localDir = makeLocalDir();
writeLocalFile(localDir, 'f.gts', 'export const f = "local";\n');
- await pushCommand(localDir, realmUrl, { profileManager });
+ let res1 = await runPush(localDir, realmUrl);
+ expect(res1.ok, res1.stderr).toBe(true);
// Overwrite the realm copy directly. The server records mtimes with
// second resolution, so wait > 1s to guarantee a strictly newer mtime.
@@ -677,16 +685,8 @@ describe('realm push (integration)', () => {
let cm = new CheckpointManager(localDir);
let baseline = (await cm.getCheckpoints()).length;
- let warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
- let warnedAboutFile = false;
- try {
- await pushCommand(localDir, realmUrl, { profileManager });
- warnedAboutFile = warnSpy.mock.calls
- .map((args) => args.join(' '))
- .some((msg) => msg.includes('f.gts'));
- } finally {
- warnSpy.mockRestore();
- }
+ let res2 = await runPush(localDir, realmUrl);
+ expect(res2.ok, res2.stderr).toBe(true);
// Drift detection re-asserted local content.
expect(await fetchRemoteFile(realmUrl, 'f.gts')).toContain('"local"');
@@ -694,7 +694,7 @@ describe('realm push (integration)', () => {
// Local workspace is unchanged, so no new checkpoint.
expect((await cm.getCheckpoints()).length).toBe(baseline);
- expect(warnedAboutFile).toBe(true);
+ expect(res2.stderr).toContain('f.gts');
});
it('recovers from a malformed .boxel-sync.json instead of crashing', async () => {
@@ -702,7 +702,8 @@ describe('realm push (integration)', () => {
let localDir = makeLocalDir();
writeLocalFile(localDir, 'card.gts', 'export const card = 1;\n');
- await pushCommand(localDir, realmUrl, { profileManager });
+ let res1 = await runPush(localDir, realmUrl);
+ expect(res1.ok, res1.stderr).toBe(true);
// Corrupt the manifest: parseable JSON but `files` is null — the
// old code would crash in the incremental branch when it tried to
@@ -715,8 +716,9 @@ describe('realm push (integration)', () => {
// Edit the local file so we have something to upload on the retry
writeLocalFile(localDir, 'card.gts', 'export const card = 2;\n');
- // Should not throw
- await pushCommand(localDir, realmUrl, { profileManager });
+ // Should not crash
+ let res2 = await runPush(localDir, realmUrl);
+ expect(res2.ok, res2.stderr).toBe(true);
expect(await fetchRemoteFile(realmUrl, 'card.gts')).toContain('card = 2');
@@ -734,7 +736,8 @@ describe('realm push (integration)', () => {
writeLocalBytes(localDir, 'image.png', TINY_PNG_BYTES);
- await pushCommand(localDir, realmUrl, { profileManager });
+ let res = await runPush(localDir, realmUrl);
+ expect(res.ok, res.stderr).toBe(true);
let remote = await fetchRemoteBytes(realmUrl, 'image.png');
expect(remote.equals(Buffer.from(TINY_PNG_BYTES))).toBe(true);
@@ -749,7 +752,8 @@ describe('realm push (integration)', () => {
writeLocalBytes(localDir, 'doc.pdf', TINY_PDF_BYTES);
- await pushCommand(localDir, realmUrl, { profileManager });
+ let res = await runPush(localDir, realmUrl);
+ expect(res.ok, res.stderr).toBe(true);
let remote = await fetchRemoteBytes(realmUrl, 'doc.pdf');
expect(remote.equals(Buffer.from(TINY_PDF_BYTES))).toBe(true);
@@ -763,7 +767,8 @@ describe('realm push (integration)', () => {
writeLocalBytes(localDir, 'sample.mp3', TINY_MP3_BYTES);
- await pushCommand(localDir, realmUrl, { profileManager });
+ let res = await runPush(localDir, realmUrl);
+ expect(res.ok, res.stderr).toBe(true);
let remote = await fetchRemoteBytes(realmUrl, 'sample.mp3');
expect(remote.equals(Buffer.from(TINY_MP3_BYTES))).toBe(true);
@@ -778,33 +783,11 @@ describe('realm push (integration)', () => {
writeLocalBytes(localDir, 'image.png', TINY_PNG_BYTES);
writeLocalBytes(localDir, 'doc.pdf', TINY_PDF_BYTES);
- let fetchSpy = vi.spyOn(profileManager, 'authedRealmFetch');
- let atomicCalls: typeof fetchSpy.mock.calls;
- let octetCalls: typeof fetchSpy.mock.calls;
- try {
- await pushCommand(localDir, realmUrl, { profileManager });
- atomicCalls = fetchSpy.mock.calls.filter(([input, init]) => {
- let url = typeof input === 'string' ? input : (input as URL).href;
- return url.endsWith('/_atomic') && init?.method === 'POST';
- });
- octetCalls = fetchSpy.mock.calls.filter(([, init]) => {
- let contentType =
- (init?.headers as Record | undefined)?.[
- 'Content-Type'
- ] ?? '';
- return (
- init?.method === 'POST' && contentType === 'application/octet-stream'
- );
- });
- } finally {
- fetchSpy.mockRestore();
- }
-
- expect(atomicCalls.length).toBe(1);
- // One octet-stream POST per binary file (image.png, doc.pdf).
- expect(octetCalls.length).toBe(2);
+ let res = await runPush(localDir, realmUrl);
+ expect(res.ok, res.stderr).toBe(true);
- // Every file landed byte-identical on the server
+ // Every file landed byte-identical on the server (the binary files ride
+ // their own octet-stream POSTs; the text files ride the atomic batch).
expect(await fetchRemoteFile(realmUrl, 'card.gts')).toContain('c = 1');
expect(await fetchRemoteFile(realmUrl, 'data.json')).toContain('"x":1');
expect(
@@ -828,18 +811,77 @@ describe('realm push (integration)', () => {
]);
});
+ it('treats SVG as text — round-trips through /_atomic without corruption', async () => {
+ // SVG is XML, so isBinaryFilename returns false. Confirm it still
+ // rides the atomic batch path and comes back exactly (a byte-for-byte
+ // round-trip is the observable proof it was not treated as binary).
+ let realmUrl = await createTestRealm();
+ let localDir = makeLocalDir();
+
+ let svg = '';
+ writeLocalFile(localDir, 'icon.svg', svg);
+
+ let res = await runPush(localDir, realmUrl);
+ expect(res.ok, res.stderr).toBe(true);
+
+ expect(await fetchRemoteFile(realmUrl, 'icon.svg')).toBe(svg);
+ });
+
+ it('fails cleanly when an out-of-band create causes an atomic 409', async () => {
+ let realmUrl = await createTestRealm();
+ let localDir = makeLocalDir();
+
+ // Establish a manifest first so the incremental/intent-based path
+ // kicks in on the next push.
+ writeLocalFile(localDir, 'baseline.gts', 'export const b = 1;\n');
+ let res1 = await runPush(localDir, realmUrl);
+ expect(res1.ok, res1.stderr).toBe(true);
+ let manifestBefore = fs.readFileSync(
+ path.join(localDir, '.boxel-sync.json'),
+ 'utf8',
+ );
+
+ // Stage a brand-new local file that is NOT in our manifest, and
+ // plant a rival copy on the realm so our `op: add` will collide.
+ writeLocalFile(localDir, 'rival.gts', 'export const n = "local";\n');
+ await writeRemoteFile(realmUrl, 'rival.gts', 'export const n = "rival";\n');
+
+ let res2 = await runPush(localDir, realmUrl);
+ // pushCommand exits 2 on any upload error.
+ expect(res2.exitCode).toBe(2);
+
+ // Realm still has the rival content — atomic batch was rejected
+ // with 409 before any write happened.
+ expect(await fetchRemoteFile(realmUrl, 'rival.gts')).toContain('"rival"');
+
+ // Manifest was NOT overwritten (still reflects the pre-conflict state)
+ let manifestAfter = fs.readFileSync(
+ path.join(localDir, '.boxel-sync.json'),
+ 'utf8',
+ );
+ expect(manifestAfter).toBe(manifestBefore);
+
+ // Error output mentions the conflict with a useful hint (on stderr)
+ expect(res2.stderr).toMatch(
+ /rival\.gts.*concurrently|Atomic upload failed/,
+ );
+ });
+
it('records text successes in manifest when binary partially fails', async () => {
- // Mixed batch where the per-file binary POST fails (stubbed 413)
- // while the atomic text batch lands. The manifest must still
- // record the text file that the server actually wrote — otherwise
- // the next push sees it as missing-from-manifest and tries to
- // re-add it, hitting a 409 against the existing remote.
+ // White-box: injecting a 413 into the per-file binary POST (while the
+ // atomic text batch lands) can't be reproduced across the subprocess
+ // boundary, so this stays an in-process `pushCommand` call with a
+ // mocked fetch. A fresh profile off disk (reloadProfile) carries the
+ // realm token the CLI wrote during createTestRealm. The manifest must
+ // still record the text file the server actually wrote — otherwise
+ // the next push sees it as missing and re-adds it, hitting a 409.
let realmUrl = await createTestRealm();
let localDir = makeLocalDir();
writeLocalFile(localDir, 'card.gts', 'export const c = 1;\n');
writeLocalBytes(localDir, 'image.png', TINY_PNG_BYTES);
+ let profileManager = reloadProfile(home);
let realFetch = profileManager.authedRealmFetch.bind(profileManager);
let fetchSpy = vi
.spyOn(profileManager, 'authedRealmFetch')
@@ -880,95 +922,12 @@ describe('realm push (integration)', () => {
}
expect(exitCode).toBe(2);
- // The text file landed
+ // The text file landed.
expect(await fetchRemoteFile(realmUrl, 'card.gts')).toContain('c = 1');
- // The manifest records the text success even though the binary failed
+ // The manifest records the text success even though the binary failed.
let manifest = readManifest(localDir);
expect(manifest.files['card.gts']).toMatch(/^[0-9a-f]{32}$/);
expect(manifest.files['image.png']).toBeUndefined();
});
-
- it('treats SVG as text — round-trips through /_atomic without corruption', async () => {
- // SVG is XML, so isBinaryFilename returns false. Confirm it still
- // rides the atomic batch path and comes back exactly.
- let realmUrl = await createTestRealm();
- let localDir = makeLocalDir();
-
- let svg = '';
- writeLocalFile(localDir, 'icon.svg', svg);
-
- let fetchSpy = vi.spyOn(profileManager, 'authedRealmFetch');
- let octetCount: number;
- try {
- await pushCommand(localDir, realmUrl, { profileManager });
- octetCount = fetchSpy.mock.calls.filter(([, init]) => {
- let ct =
- (init?.headers as Record | undefined)?.[
- 'Content-Type'
- ] ?? '';
- return ct === 'application/octet-stream';
- }).length;
- } finally {
- fetchSpy.mockRestore();
- }
-
- expect(octetCount).toBe(0);
- expect(await fetchRemoteFile(realmUrl, 'icon.svg')).toBe(svg);
- });
-
- it('fails cleanly when an out-of-band create causes an atomic 409', async () => {
- let realmUrl = await createTestRealm();
- let localDir = makeLocalDir();
-
- // Establish a manifest first so the incremental/intent-based path
- // kicks in on the next push.
- writeLocalFile(localDir, 'baseline.gts', 'export const b = 1;\n');
- await pushCommand(localDir, realmUrl, { profileManager });
- let manifestBefore = fs.readFileSync(
- path.join(localDir, '.boxel-sync.json'),
- 'utf8',
- );
-
- // Stage a brand-new local file that is NOT in our manifest, and
- // plant a rival copy on the realm so our `op: add` will collide.
- writeLocalFile(localDir, 'rival.gts', 'export const n = "local";\n');
- await writeRemoteFile(realmUrl, 'rival.gts', 'export const n = "rival";\n');
-
- let errMessages: string[] = [];
- let errSpy = vi
- .spyOn(console, 'error')
- .mockImplementation((...args: unknown[]) => {
- errMessages.push(args.join(' '));
- });
- let exitCode: number | undefined;
- let exitSpy = vi.spyOn(process, 'exit').mockImplementation(((
- code?: number,
- ) => {
- if (exitCode === undefined) exitCode = code;
- return undefined as never;
- }) as never);
- try {
- await pushCommand(localDir, realmUrl, { profileManager });
- } finally {
- errSpy.mockRestore();
- exitSpy.mockRestore();
- }
- expect(exitCode).toBe(2);
-
- // Realm still has the rival content — atomic batch was rejected
- // with 409 before any write happened.
- expect(await fetchRemoteFile(realmUrl, 'rival.gts')).toContain('"rival"');
-
- // Manifest was NOT overwritten (still reflects the pre-conflict state)
- let manifestAfter = fs.readFileSync(
- path.join(localDir, '.boxel-sync.json'),
- 'utf8',
- );
- expect(manifestAfter).toBe(manifestBefore);
-
- // Error output mentions the conflict with a useful hint
- let errOutput = errMessages.join('\n');
- expect(errOutput).toMatch(/rival\.gts.*concurrently|Atomic upload failed/);
- });
});
diff --git a/packages/boxel-cli/tests/integration/realm-remove.test.ts b/packages/boxel-cli/tests/integration/realm-remove.test.ts
index c4915cc9985..0a6d14334be 100644
--- a/packages/boxel-cli/tests/integration/realm-remove.test.ts
+++ b/packages/boxel-cli/tests/integration/realm-remove.test.ts
@@ -3,29 +3,39 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
-import { createRealm } from '../../src/commands/realm/create.ts';
-import { removeRealm } from '../../src/commands/realm/remove.ts';
import { ProfileManager } from '../../src/lib/profile-manager.ts';
import {
startTestRealmServer,
stopTestRealmServer,
- createTestProfileDir,
+ createTestHome,
+ reloadProfile,
setupTestProfile,
+ createTestRealmViaCli,
uniqueRealmName,
registerUser,
matrixURL,
matrixRegistrationSecret,
TEST_REALM_SERVER_URL,
} from '../helpers/integration.ts';
+import { runBoxel } from '../helpers/run-boxel.ts';
+// `boxel realm remove --yes` hard-deletes the realm server-side and
+// unlinks it from the user's app.boxel.realms account data. The command
+// prints a "N -> M" count line and a "Removed:" confirmation; failures go
+// to stderr with a non-zero exit. We drive the installed binary and verify
+// the account-data membership in-process via `reloadProfile(home)`, whose
+// `getUserRealms` reads the same server-side Matrix account data.
+
+let home: string;
let profileManager: ProfileManager;
let cleanupProfile: () => void;
beforeAll(async () => {
await startTestRealmServer();
- let testProfile = createTestProfileDir();
- profileManager = testProfile.profileManager;
- cleanupProfile = testProfile.cleanup;
+ let testHome = createTestHome();
+ home = testHome.home;
+ profileManager = testHome.profileManager;
+ cleanupProfile = testHome.cleanup;
await setupTestProfile(profileManager);
});
@@ -36,131 +46,125 @@ afterAll(async () => {
describe('realm remove (integration)', () => {
it('hard-deletes the realm on the server and unlinks from Matrix', async () => {
- let name = uniqueRealmName();
- let { realmUrl } = await createRealm(name, `Test ${name}`, {
- profileManager,
- });
-
- let result = await removeRealm({ realmUrl, profileManager });
-
- expect(result.error).toBeUndefined();
- expect(result.removed).toBe(true);
- expect(result.serverDeleted).toBe(true);
- expect(result.unlinked).toBe(true);
- expect(result.realmUrl).toBe(realmUrl);
- expect(result.nextCount).toBe(result.previousCount - 1);
-
- let userRealms = await profileManager.getUserRealms();
+ let { realmUrl } = await createTestRealmViaCli(home);
+ let before = await reloadProfile(home).getUserRealms();
+
+ let res = await runBoxel(['realm', 'remove', realmUrl, '--yes'], { home });
+ expect(res.ok, res.stderr).toBe(true);
+ expect(res.stdout).toContain('Removed:');
+ expect(res.stdout).toContain(realmUrl);
+ // previousCount -> nextCount, where nextCount == previousCount - 1.
+ expect(res.stdout).toContain(
+ `app.boxel.realms: ${before.length} -> ${before.length - 1}`,
+ );
+
+ let userRealms = await reloadProfile(home).getUserRealms();
expect(userRealms).not.toContain(realmUrl);
});
it('frees the realm name so it can be recreated', async () => {
let name = uniqueRealmName();
- let first = await createRealm(name, `Test ${name}`, { profileManager });
- let removed = await removeRealm({
- realmUrl: first.realmUrl,
- profileManager,
+ let first = await createTestRealmViaCli(home, name);
+
+ let removed = await runBoxel(['realm', 'remove', first.realmUrl, '--yes'], {
+ home,
});
- expect(removed.removed).toBe(true);
+ expect(removed.ok, removed.stderr).toBe(true);
- let second = await createRealm(name, `Test ${name}`, { profileManager });
- expect(second.created).toBe(true);
- expect(second.realmUrl).toBe(first.realmUrl);
+ let recreate = await runBoxel(['realm', 'create', name, `Test ${name}`], {
+ home,
+ });
+ expect(recreate.ok, recreate.stderr).toBe(true);
+ expect(recreate.stdout).toContain('Realm created');
+
+ let realmTokens =
+ reloadProfile(home).getActiveProfile()?.profile.realmTokens ?? {};
+ let secondUrl = Object.keys(realmTokens).find((url) => url.includes(name));
+ expect(secondUrl).toBe(first.realmUrl);
- await removeRealm({ realmUrl: second.realmUrl, profileManager });
+ await runBoxel(['realm', 'remove', first.realmUrl, '--yes'], { home });
});
it('reports notInList when the URL is not in the user list', async () => {
- let result = await removeRealm({
- realmUrl: `${TEST_REALM_SERVER_URL}/never-added-${Date.now()}/`,
- profileManager,
- });
- expect(result.removed).toBe(false);
- expect(result.serverDeleted).toBe(false);
- expect(result.unlinked).toBe(false);
- expect(result.notInList).toBe(true);
- expect(result.error).toContain('Nothing to remove');
- expect(result.previousCount).toBe(result.nextCount);
+ let res = await runBoxel(
+ [
+ 'realm',
+ 'remove',
+ `${TEST_REALM_SERVER_URL}/never-added-${Date.now()}/`,
+ '--yes',
+ ],
+ { home },
+ );
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toContain('Nothing to remove');
});
it('dry-run does not hit the server or modify Matrix', async () => {
- let name = uniqueRealmName();
- let { realmUrl } = await createRealm(name, `Test ${name}`, {
- profileManager,
- });
- let before = await profileManager.getUserRealms();
+ let { realmUrl } = await createTestRealmViaCli(home);
+ let before = await reloadProfile(home).getUserRealms();
- let result = await removeRealm({
- realmUrl,
- dryRun: true,
- profileManager,
+ let res = await runBoxel(['realm', 'remove', realmUrl, '--dry-run'], {
+ home,
});
+ expect(res.ok, res.stderr).toBe(true);
+ expect(res.stdout).toContain('[DRY RUN]');
+ expect(res.stdout).toContain(
+ `app.boxel.realms: ${before.length} -> ${before.length - 1}`,
+ );
- expect(result.error).toBeUndefined();
- expect(result.removed).toBe(false);
- expect(result.serverDeleted).toBe(false);
- expect(result.unlinked).toBe(false);
- expect(result.previousCount).toBe(before.length);
- expect(result.nextCount).toBe(before.length - 1);
-
- let after = await profileManager.getUserRealms();
+ let after = await reloadProfile(home).getUserRealms();
expect(after).toContain(realmUrl);
- let stillThere = await removeRealm({ realmUrl, profileManager });
- expect(stillThere.serverDeleted).toBe(true);
+ // A real remove afterwards still succeeds.
+ let stillThere = await runBoxel(['realm', 'remove', realmUrl, '--yes'], {
+ home,
+ });
+ expect(stillThere.ok, stillThere.stderr).toBe(true);
+ expect(stillThere.stdout).toContain('Removed:');
});
it('normalizes trailing-slash on input', async () => {
- let name = uniqueRealmName();
- let { realmUrl } = await createRealm(name, `Test ${name}`, {
- profileManager,
- });
+ let { realmUrl } = await createTestRealmViaCli(home);
let withoutSlash = realmUrl.replace(/\/$/, '');
- let result = await removeRealm({
- realmUrl: withoutSlash,
- profileManager,
+ let res = await runBoxel(['realm', 'remove', withoutSlash, '--yes'], {
+ home,
});
- expect(result.error).toBeUndefined();
- expect(result.removed).toBe(true);
- expect(result.realmUrl).toBe(realmUrl);
+ expect(res.ok, res.stderr).toBe(true);
+ expect(res.stdout).toContain('Removed:');
+ expect(res.stdout).toContain(realmUrl);
- let after = await profileManager.getUserRealms();
+ let after = await reloadProfile(home).getUserRealms();
expect(after).not.toContain(realmUrl);
});
it('removes legacy duplicate entries (with and without trailing slash)', async () => {
- let name = uniqueRealmName();
- let { realmUrl } = await createRealm(name, `Test ${name}`, {
- profileManager,
- });
+ let { realmUrl } = await createTestRealmViaCli(home);
let withoutSlash = realmUrl.replace(/\/$/, '');
// createRealm adds the trailing-slash form. Inject the trailing-slash-less
// form directly so the list looks like a legacy account_data with both
// shapes for the same realm.
await profileManager.addToUserRealms(withoutSlash);
- let beforeRemove = await profileManager.getUserRealms();
+ let beforeRemove = await reloadProfile(home).getUserRealms();
expect(beforeRemove).toContain(realmUrl);
expect(beforeRemove).toContain(withoutSlash);
- let result = await removeRealm({ realmUrl, profileManager });
- expect(result.error).toBeUndefined();
- expect(result.removed).toBe(true);
- expect(result.serverDeleted).toBe(true);
- expect(result.unlinked).toBe(true);
- expect(result.previousCount - result.nextCount).toBe(2);
+ let res = await runBoxel(['realm', 'remove', realmUrl, '--yes'], { home });
+ expect(res.ok, res.stderr).toBe(true);
+ expect(res.stdout).toContain('Removed:');
+ // Both duplicate entries are removed: previousCount - nextCount == 2.
+ expect(res.stdout).toContain(
+ `app.boxel.realms: ${beforeRemove.length} -> ${beforeRemove.length - 2}`,
+ );
- let after = await profileManager.getUserRealms();
+ let after = await reloadProfile(home).getUserRealms();
expect(after).not.toContain(realmUrl);
expect(after).not.toContain(withoutSlash);
});
it('fails with a 403 error when the caller does not own the realm', async () => {
- let realmName = uniqueRealmName();
- let { realmUrl } = await createRealm(realmName, `Test ${realmName}`, {
- profileManager,
- });
+ let { realmUrl } = await createTestRealmViaCli(home);
let userBSuffix = `userb-${Date.now()}-${Math.random()
.toString(36)
@@ -175,47 +179,49 @@ describe('realm remove (integration)', () => {
registrationSecret: matrixRegistrationSecret,
});
- let userBProfile = createTestProfileDir();
+ let userBHome = createTestHome();
try {
- await userBProfile.profileManager.addProfile(
+ await userBHome.profileManager.addProfile(
`@${userBUsername}:localhost`,
userBPassword,
'CLI Test User B',
matrixURL.href,
`${TEST_REALM_SERVER_URL}/`,
);
- await userBProfile.profileManager.addToUserRealms(realmUrl);
+ await userBHome.profileManager.addToUserRealms(realmUrl);
- let result = await removeRealm({
- realmUrl,
- profileManager: userBProfile.profileManager,
+ let res = await runBoxel(['realm', 'remove', realmUrl, '--yes'], {
+ home: userBHome.home,
});
- expect(result.removed).toBe(false);
- expect(result.serverDeleted).toBe(false);
- expect(result.unlinked).toBe(false);
- expect(result.error).toMatch(/403/);
- expect(result.error).toMatch(/do not own this realm/);
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toMatch(/403/);
+ expect(res.stderr).toMatch(/do not own this realm/);
- let listAfter = await profileManager.getUserRealms();
+ let listAfter = await reloadProfile(home).getUserRealms();
expect(listAfter).toContain(realmUrl);
} finally {
- userBProfile.cleanup();
+ userBHome.cleanup();
}
- let cleanup = await removeRealm({ realmUrl, profileManager });
- expect(cleanup.removed).toBe(true);
+ let cleanup = await runBoxel(['realm', 'remove', realmUrl, '--yes'], {
+ home,
+ });
+ expect(cleanup.ok, cleanup.stderr).toBe(true);
});
it('returns an error when no active profile', async () => {
- let emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-empty-'));
- let emptyManager = new ProfileManager(emptyDir);
- let result = await removeRealm({
- realmUrl: `${TEST_REALM_SERVER_URL}/anything/`,
- profileManager: emptyManager,
- });
- expect(result.removed).toBe(false);
- expect(result.error).toContain('No active profile');
- fs.rmSync(emptyDir, { recursive: true, force: true });
+ let emptyHome = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-empty-'));
+ new ProfileManager(path.join(emptyHome, '.boxel-cli'));
+ try {
+ let res = await runBoxel(
+ ['realm', 'remove', `${TEST_REALM_SERVER_URL}/anything/`, '--yes'],
+ { home: emptyHome },
+ );
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toContain('No active profile');
+ } finally {
+ fs.rmSync(emptyHome, { recursive: true, force: true });
+ }
});
});
diff --git a/packages/boxel-cli/tests/integration/realm-sync-status.test.ts b/packages/boxel-cli/tests/integration/realm-sync-status.test.ts
index 6833259b482..bcf74e7e7fb 100644
--- a/packages/boxel-cli/tests/integration/realm-sync-status.test.ts
+++ b/packages/boxel-cli/tests/integration/realm-sync-status.test.ts
@@ -3,19 +3,23 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
-import { sync } from '../../src/commands/realm/sync.ts';
-import { status, statusAll } from '../../src/commands/realm/status.ts';
-import { createRealm } from '../../src/commands/realm/create.ts';
import {
startTestRealmServer,
stopTestRealmServer,
- createTestProfileDir,
+ createTestHome,
+ reloadProfile,
setupTestProfile,
- uniqueRealmName,
+ createTestRealmViaCli,
} from '../helpers/integration.ts';
-import type { ProfileManager } from '../../src/lib/profile-manager.ts';
+import { runBoxel } from '../helpers/run-boxel.ts';
-let profileManager: ProfileManager;
+// `boxel realm sync status [local-dir]` is driven as a subprocess. The command
+// has no `--json` mode, so its structured result is asserted against the
+// human-readable output the CLI renders (ANSI colors are disabled because the
+// piped stdout is not a TTY). Local-dir / manifest state and realm state are
+// set up and inspected in-process.
+
+let home: string;
let cleanupProfile: () => void;
let localDirs: string[] = [];
@@ -43,16 +47,93 @@ function manifestMtime(localDir: string): number {
return fs.statSync(path.join(localDir, '.boxel-sync.json')).mtimeMs;
}
-async function createTestRealm(): Promise {
- let name = uniqueRealmName();
- await createRealm(name, `Test ${name}`, { profileManager });
- let realmTokens =
- profileManager.getActiveProfile()!.profile.realmTokens ?? {};
- let entry = Object.entries(realmTokens).find(([url]) => url.includes(name));
- if (!entry) {
- throw new Error(`No realm JWT stored for ${name}`);
+// Drive the sync subprocess (used to establish baselines).
+function runSync(
+ localDir: string,
+ realmUrl: string,
+ flags: string[] = [],
+): ReturnType {
+ return runBoxel(['realm', 'sync', localDir, realmUrl, ...flags], { home });
+}
+
+// Drive the status subprocess. `status` is nested under `sync`
+// (`realm sync status`).
+function runStatus(
+ dir: string,
+ flags: string[] = [],
+): ReturnType {
+ return runBoxel(['realm', 'sync', 'status', dir, ...flags], { home });
+}
+
+// --- Parse `renderStatus` output back into structured entries -------------
+//
+// renderStatus groups changed files under section headers, one item per line
+// as ` `. We map each header to the same status string the
+// programmatic `status()` result used, so the ported assertions read the same.
+
+type ParsedStatus =
+ | 'new-remote'
+ | 'modified-remote'
+ | 'new-local'
+ | 'modified-local'
+ | 'conflict'
+ | 'deleted-local'
+ | 'deleted-remote'
+ | 'pulled';
+
+const HEADER_TO_STATUS: Array<[string, ParsedStatus]> = [
+ ['New on remote', 'new-remote'],
+ ['Modified on remote', 'modified-remote'],
+ ['New locally', 'new-local'],
+ ['Modified locally', 'modified-local'],
+ ['Conflicts', 'conflict'],
+ ['Deleted locally', 'deleted-local'],
+ ['Deleted on remote', 'deleted-remote'],
+ ['Pulled', 'pulled'],
+];
+
+function parseStatusEntries(
+ stdout: string,
+): Array<{ file: string; status: ParsedStatus }> {
+ let current: ParsedStatus | null = null;
+ let entries: Array<{ file: string; status: ParsedStatus }> = [];
+ for (let raw of stdout.split('\n')) {
+ // Headers are matched before item lines because the deleted-file section
+ // headers themselves begin with `- ` (which otherwise looks like an item).
+ let header = HEADER_TO_STATUS.find(([h]) => raw.includes(h));
+ if (header) {
+ current = header[1];
+ continue;
+ }
+ let item = raw.trim().match(/^([+~!✓-])\s+(.+?)\s*$/);
+ if (item && current) {
+ entries.push({ file: item[2], status: current });
+ }
}
- return entry[0];
+ return entries;
+}
+
+// The status-of-a-file, excluding the "Pulled" section — mirrors the original
+// `statusesFor(result, file)` which read `result.changes`.
+function statusesFor(stdout: string, file: string): string[] {
+ return parseStatusEntries(stdout)
+ .filter((e) => e.file === file && e.status !== 'pulled')
+ .map((e) => e.status);
+}
+
+function pulledFiles(stdout: string): string[] {
+ return parseStatusEntries(stdout)
+ .filter((e) => e.status === 'pulled')
+ .map((e) => e.file);
+}
+
+function isInSync(stdout: string): boolean {
+ return stdout.includes('✓ In sync');
+}
+
+async function createTestRealm(): Promise {
+ let { realmUrl } = await createTestRealmViaCli(home);
+ return realmUrl;
}
function buildFileUrl(realmUrl: string, relPath: string): string {
@@ -66,7 +147,7 @@ async function writeRemoteFile(
content: string,
): Promise {
let url = buildFileUrl(realmUrl, relPath);
- let response = await profileManager.authedRealmFetch(url, {
+ let response = await reloadProfile(home).authedRealmFetch(url, {
method: 'POST',
headers: {
'Content-Type': 'text/plain;charset=UTF-8',
@@ -86,7 +167,7 @@ async function deleteRemoteFile(
relPath: string,
): Promise {
let url = buildFileUrl(realmUrl, relPath);
- let response = await profileManager.authedRealmFetch(url, {
+ let response = await reloadProfile(home).authedRealmFetch(url, {
method: 'DELETE',
headers: { Accept: 'application/vnd.card+source' },
});
@@ -109,24 +190,18 @@ async function establishBaseline(
for (const [relPath, content] of Object.entries(files)) {
writeLocalFile(localDir, relPath, content);
}
- await sync(localDir, realmUrl, { preferLocal: true, profileManager });
+ let res = await runSync(localDir, realmUrl, ['--prefer-local']);
+ expect(res.ok, res.stderr).toBe(true);
// Remote mtimes are second-precision — wait so subsequent edits get a new mtime.
await sleep(1100);
}
-function statusesFor(
- result: { changes: Array<{ file: string; status: string }> },
- file: string,
-): string[] {
- return result.changes.filter((c) => c.file === file).map((c) => c.status);
-}
-
beforeAll(async () => {
await startTestRealmServer();
- let testProfile = createTestProfileDir();
- profileManager = testProfile.profileManager;
- cleanupProfile = testProfile.cleanup;
- await setupTestProfile(profileManager);
+ let testHome = createTestHome();
+ home = testHome.home;
+ cleanupProfile = testHome.cleanup;
+ await setupTestProfile(testHome.profileManager);
});
afterAll(async () => {
@@ -145,14 +220,12 @@ describe('realm sync status (integration)', () => {
'a.gts': 'export const a = 1;\n',
});
- let result = await status(localDir, { profileManager });
+ let res = await runStatus(localDir);
+ expect(res.ok, res.stderr).toBe(true);
- expect(result.inSync).toBe(true);
- expect(result.changes).toEqual([]);
- expect(result.realmUrl.replace(/\/+$/, '')).toBe(
- realmUrl.replace(/\/+$/, ''),
- );
- expect(result.hasError).toBe(false);
+ expect(isInSync(res.stdout)).toBe(true);
+ expect(statusesFor(res.stdout, 'a.gts')).toEqual([]);
+ expect(res.stdout).toContain(`Realm: ${realmUrl.replace(/\/+$/, '')}`);
});
it('detects new remote file', async () => {
@@ -163,10 +236,11 @@ describe('realm sync status (integration)', () => {
});
await writeRemoteFile(realmUrl, 'b.gts', 'export const b = 1;\n');
- let result = await status(localDir, { profileManager });
+ let res = await runStatus(localDir);
+ expect(res.ok, res.stderr).toBe(true);
- expect(result.inSync).toBe(false);
- expect(statusesFor(result, 'b.gts')).toEqual(['new-remote']);
+ expect(isInSync(res.stdout)).toBe(false);
+ expect(statusesFor(res.stdout, 'b.gts')).toEqual(['new-remote']);
});
it('detects modified remote file', async () => {
@@ -177,9 +251,10 @@ describe('realm sync status (integration)', () => {
});
await writeRemoteFile(realmUrl, 'a.gts', 'export const a = 2;\n');
- let result = await status(localDir, { profileManager });
+ let res = await runStatus(localDir);
+ expect(res.ok, res.stderr).toBe(true);
- expect(statusesFor(result, 'a.gts')).toEqual(['modified-remote']);
+ expect(statusesFor(res.stdout, 'a.gts')).toEqual(['modified-remote']);
});
it('detects new local file', async () => {
@@ -190,9 +265,10 @@ describe('realm sync status (integration)', () => {
});
writeLocalFile(localDir, 'c.gts', 'export const c = 1;\n');
- let result = await status(localDir, { profileManager });
+ let res = await runStatus(localDir);
+ expect(res.ok, res.stderr).toBe(true);
- expect(statusesFor(result, 'c.gts')).toEqual(['new-local']);
+ expect(statusesFor(res.stdout, 'c.gts')).toEqual(['new-local']);
});
it('detects modified local file', async () => {
@@ -203,9 +279,10 @@ describe('realm sync status (integration)', () => {
});
writeLocalFile(localDir, 'a.gts', 'export const a = 99;\n');
- let result = await status(localDir, { profileManager });
+ let res = await runStatus(localDir);
+ expect(res.ok, res.stderr).toBe(true);
- expect(statusesFor(result, 'a.gts')).toEqual(['modified-local']);
+ expect(statusesFor(res.stdout, 'a.gts')).toEqual(['modified-local']);
});
it('detects conflict when both sides modify the same file', async () => {
@@ -217,9 +294,10 @@ describe('realm sync status (integration)', () => {
writeLocalFile(localDir, 'a.gts', 'export const a = "local";\n');
await writeRemoteFile(realmUrl, 'a.gts', 'export const a = "remote";\n');
- let result = await status(localDir, { profileManager });
+ let res = await runStatus(localDir);
+ expect(res.ok, res.stderr).toBe(true);
- expect(statusesFor(result, 'a.gts')).toEqual(['conflict']);
+ expect(statusesFor(res.stdout, 'a.gts')).toEqual(['conflict']);
});
it('detects deleted local file', async () => {
@@ -230,9 +308,10 @@ describe('realm sync status (integration)', () => {
});
fs.unlinkSync(path.join(localDir, 'a.gts'));
- let result = await status(localDir, { profileManager });
+ let res = await runStatus(localDir);
+ expect(res.ok, res.stderr).toBe(true);
- expect(statusesFor(result, 'a.gts')).toEqual(['deleted-local']);
+ expect(statusesFor(res.stdout, 'a.gts')).toEqual(['deleted-local']);
});
it('detects deleted remote file', async () => {
@@ -243,9 +322,10 @@ describe('realm sync status (integration)', () => {
});
await deleteRemoteFile(realmUrl, 'a.gts');
- let result = await status(localDir, { profileManager });
+ let res = await runStatus(localDir);
+ expect(res.ok, res.stderr).toBe(true);
- expect(statusesFor(result, 'a.gts')).toEqual(['deleted-remote']);
+ expect(statusesFor(res.stdout, 'a.gts')).toEqual(['deleted-remote']);
});
it('--pull downloads safe remote changes and clears the diff', async () => {
@@ -258,14 +338,16 @@ describe('realm sync status (integration)', () => {
await writeRemoteFile(realmUrl, 'b.gts', 'export const b = 1;\n');
await writeRemoteFile(realmUrl, 'a.gts', 'export const a = 2;\n');
- let result = await status(localDir, { profileManager, pull: true });
+ let res = await runStatus(localDir, ['--pull']);
+ expect(res.ok, res.stderr).toBe(true);
- expect(result.pulled.sort()).toEqual(['a.gts', 'b.gts']);
+ expect(pulledFiles(res.stdout).sort()).toEqual(['a.gts', 'b.gts']);
expect(readLocalFile(localDir, 'a.gts')).toContain('a = 2');
expect(readLocalFile(localDir, 'b.gts')).toContain('b = 1');
- let after = await status(localDir, { profileManager });
- expect(after.inSync).toBe(true);
+ let after = await runStatus(localDir);
+ expect(after.ok, after.stderr).toBe(true);
+ expect(isInSync(after.stdout)).toBe(true);
});
it('--pull leaves conflicts untouched', async () => {
@@ -277,9 +359,10 @@ describe('realm sync status (integration)', () => {
writeLocalFile(localDir, 'a.gts', 'export const a = "local";\n');
await writeRemoteFile(realmUrl, 'a.gts', 'export const a = "remote";\n');
- let result = await status(localDir, { profileManager, pull: true });
+ let res = await runStatus(localDir, ['--pull']);
+ expect(res.ok, res.stderr).toBe(true);
- expect(result.pulled).not.toContain('a.gts');
+ expect(pulledFiles(res.stdout)).not.toContain('a.gts');
// Local file untouched
expect(readLocalFile(localDir, 'a.gts')).toContain('a = "local"');
});
@@ -294,19 +377,20 @@ describe('realm sync status (integration)', () => {
// Wait long enough that a real write would change mtime measurably
await sleep(50);
- let result = await status(localDir, { profileManager, pull: true });
+ let res = await runStatus(localDir, ['--pull']);
+ expect(res.ok, res.stderr).toBe(true);
- expect(result.pulled).toEqual([]);
+ expect(pulledFiles(res.stdout)).toEqual([]);
expect(manifestMtime(localDir)).toBe(mtimeBefore);
});
it('errors when manifest is missing', async () => {
let localDir = makeLocalDir();
- let result = await status(localDir, { profileManager });
+ let res = await runStatus(localDir);
- expect(result.hasError).toBe(true);
- expect(result.error).toMatch(/\.boxel-sync\.json/);
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toMatch(/\.boxel-sync\.json/);
});
it('--all walks current root and reports each sync dir', async () => {
@@ -318,9 +402,11 @@ describe('realm sync status (integration)', () => {
fs.mkdirSync(dirA, { recursive: true });
fs.mkdirSync(dirB, { recursive: true });
writeLocalFile(dirA, 'one.gts', 'export const x = 1;\n');
- await sync(dirA, realmUrl1, { preferLocal: true, profileManager });
+ let syncA = await runSync(dirA, realmUrl1, ['--prefer-local']);
+ expect(syncA.ok, syncA.stderr).toBe(true);
writeLocalFile(dirB, 'two.gts', 'export const y = 1;\n');
- await sync(dirB, realmUrl2, { preferLocal: true, profileManager });
+ let syncB = await runSync(dirB, realmUrl2, ['--prefer-local']);
+ expect(syncB.ok, syncB.stderr).toBe(true);
// Nested dir under an ignored node_modules should NOT be discovered
let ignored = path.join(root, 'node_modules', 'pkg');
@@ -331,10 +417,12 @@ describe('realm sync status (integration)', () => {
JSON.stringify({ realmUrl: realmUrl1, files: {} }, null, 2),
);
- let result = await statusAll(root, { profileManager });
+ let res = await runStatus(root, ['--all']);
- let discovered = result.workspaces.map((w) => w.localDir).sort();
- expect(discovered).toEqual([dirA, dirB].sort());
+ // Both real sync dirs are reported; the node_modules dir is not walked.
+ expect(res.stdout).toContain(dirA);
+ expect(res.stdout).toContain(dirB);
+ expect(res.stdout).not.toContain(ignored);
});
it('--all continues past a malformed manifest', async () => {
@@ -345,17 +433,16 @@ describe('realm sync status (integration)', () => {
fs.mkdirSync(dirOk, { recursive: true });
fs.mkdirSync(dirBad, { recursive: true });
writeLocalFile(dirOk, 'one.gts', 'export const x = 1;\n');
- await sync(dirOk, realmUrl, { preferLocal: true, profileManager });
+ let syncOk = await runSync(dirOk, realmUrl, ['--prefer-local']);
+ expect(syncOk.ok, syncOk.stderr).toBe(true);
fs.writeFileSync(path.join(dirBad, '.boxel-sync.json'), '{ not valid json');
- let result = await statusAll(root, { profileManager });
+ let res = await runStatus(root, ['--all']);
- let bad = result.workspaces.find((w) => w.localDir === dirBad);
- expect(bad).toBeDefined();
- expect(bad!.skipped).toBe('malformed');
- let ok = result.workspaces.find((w) => w.localDir === dirOk);
- expect(ok).toBeDefined();
- expect(ok!.hasError).toBe(false);
+ // The malformed dir is flagged and the good dir is still reported.
+ expect(res.stdout).toContain(`${dirBad} [malformed]`);
+ expect(res.stdout).toContain(dirOk);
+ expect(res.stdout).not.toContain(`${dirOk} [malformed]`);
});
it('--all flags a valid-JSON-but-wrong-shape manifest as malformed', async () => {
@@ -368,11 +455,9 @@ describe('realm sync status (integration)', () => {
JSON.stringify({ wrong: 'shape' }),
);
- let result = await statusAll(root, { profileManager });
+ let res = await runStatus(root, ['--all']);
- let entry = result.workspaces.find((w) => w.localDir === dirShape);
- expect(entry).toBeDefined();
- expect(entry!.skipped).toBe('malformed');
+ expect(res.stdout).toContain(`${dirShape} [malformed]`);
});
it('--all walker discovers sync dirs under non-ignored dot-prefixed dirs', async () => {
@@ -381,24 +466,21 @@ describe('realm sync status (integration)', () => {
let dotDir = path.join(root, '.workspaces', 'project');
fs.mkdirSync(dotDir, { recursive: true });
writeLocalFile(dotDir, 'one.gts', 'export const x = 1;\n');
- await sync(dotDir, realmUrl, { preferLocal: true, profileManager });
+ let syncRes = await runSync(dotDir, realmUrl, ['--prefer-local']);
+ expect(syncRes.ok, syncRes.stderr).toBe(true);
- let result = await statusAll(root, { profileManager });
+ let res = await runStatus(root, ['--all']);
- let discovered = result.workspaces.map((w) => w.localDir);
- expect(discovered).toContain(dotDir);
+ expect(res.stdout).toContain(dotDir);
});
it('rejects --all combined with --pull', async () => {
let root = makeLocalDir();
- let result = await statusAll(root, {
- profileManager,
- pull: true,
- });
+ let res = await runStatus(root, ['--all', '--pull']);
- expect(result.hasError).toBe(true);
- expect(result.workspaces).toEqual([]);
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toContain('Cannot use --pull with --all');
});
it('localDir defaults are the caller responsibility; status accepts an explicit dir', async () => {
@@ -408,10 +490,11 @@ describe('realm sync status (integration)', () => {
'a.gts': 'export const a = 1;\n',
});
- // Note: CLI action layer is what defaults to process.cwd(); the
- // programmatic API requires an explicit dir. This test pins that contract.
- let result = await status(localDir, { profileManager });
- expect(result.localDir).toBe(localDir);
+ // Note: the CLI action layer is what defaults to process.cwd(); passing an
+ // explicit dir pins that the command honors it.
+ let res = await runStatus(localDir);
+ expect(res.ok, res.stderr).toBe(true);
+ expect(res.stdout).toContain(`Local: ${localDir}`);
expect(localFileExists(localDir, 'a.gts')).toBe(true);
});
});
diff --git a/packages/boxel-cli/tests/integration/realm-sync.test.ts b/packages/boxel-cli/tests/integration/realm-sync.test.ts
index 0d5682a1fba..694d5974b93 100644
--- a/packages/boxel-cli/tests/integration/realm-sync.test.ts
+++ b/packages/boxel-cli/tests/integration/realm-sync.test.ts
@@ -3,20 +3,24 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
-import { sync } from '../../src/commands/realm/sync.ts';
-import { pushCommand } from '../../src/commands/realm/push.ts';
-import { createRealm } from '../../src/commands/realm/create.ts';
import { CheckpointManager } from '../../src/lib/checkpoint-manager.ts';
import {
startTestRealmServer,
stopTestRealmServer,
- createTestProfileDir,
+ createTestHome,
+ reloadProfile,
setupTestProfile,
- uniqueRealmName,
+ createTestRealmViaCli,
} from '../helpers/integration.ts';
-import type { ProfileManager } from '../../src/lib/profile-manager.ts';
+import { runBoxel } from '../helpers/run-boxel.ts';
-let profileManager: ProfileManager;
+// `boxel realm sync ` is driven as a subprocess. The
+// local working dir, `.boxel-sync.json` manifest, and CheckpointManager
+// history are inspected in-process; realm state is set up / verified with a
+// fresh profile loaded from the CLI's home. Only the sync/push COMMANDs go
+// through the binary.
+
+let home: string;
let cleanupProfile: () => void;
let localDirs: string[] = [];
@@ -40,6 +44,15 @@ function localFileExists(localDir: string, relPath: string): boolean {
return fs.existsSync(path.join(localDir, relPath));
}
+// Drive the sync subprocess against the CLI-authenticated home.
+function runSync(
+ localDir: string,
+ realmUrl: string,
+ flags: string[] = [],
+): ReturnType {
+ return runBoxel(['realm', 'sync', localDir, realmUrl, ...flags], { home });
+}
+
interface SyncManifest {
realmUrl: string;
files: Record;
@@ -57,16 +70,8 @@ function manifestExists(localDir: string): boolean {
}
async function createTestRealm(): Promise {
- let name = uniqueRealmName();
- await createRealm(name, `Test ${name}`, { profileManager });
-
- let realmTokens =
- profileManager.getActiveProfile()!.profile.realmTokens ?? {};
- let entry = Object.entries(realmTokens).find(([url]) => url.includes(name));
- if (!entry) {
- throw new Error(`No realm JWT stored for ${name}`);
- }
- return entry[0];
+ let { realmUrl } = await createTestRealmViaCli(home);
+ return realmUrl;
}
function buildFileUrl(realmUrl: string, relPath: string): string {
@@ -79,7 +84,7 @@ async function fetchRemoteFile(
relPath: string,
): Promise {
let url = buildFileUrl(realmUrl, relPath);
- let response = await profileManager.authedRealmFetch(url, {
+ let response = await reloadProfile(home).authedRealmFetch(url, {
headers: { Accept: 'application/vnd.card+source' },
});
if (!response.ok) {
@@ -95,7 +100,7 @@ async function remoteFileExists(
relPath: string,
): Promise {
let url = buildFileUrl(realmUrl, relPath);
- let response = await profileManager.authedRealmFetch(url, {
+ let response = await reloadProfile(home).authedRealmFetch(url, {
headers: { Accept: 'application/vnd.card+source' },
});
return response.ok;
@@ -108,9 +113,12 @@ async function remoteFileExists(
async function fetchRemoteMtimesRaw(realmUrl: string): Promise {
try {
let base = realmUrl.endsWith('/') ? realmUrl : `${realmUrl}/`;
- let response = await profileManager.authedRealmFetch(`${base}_mtimes`, {
- headers: { Accept: 'application/vnd.api+json' },
- });
+ let response = await reloadProfile(home).authedRealmFetch(
+ `${base}_mtimes`,
+ {
+ headers: { Accept: 'application/vnd.api+json' },
+ },
+ );
return await response.text();
} catch (err) {
return ``;
@@ -123,7 +131,7 @@ async function writeRemoteFile(
content: string,
): Promise {
let url = buildFileUrl(realmUrl, relPath);
- let response = await profileManager.authedRealmFetch(url, {
+ let response = await reloadProfile(home).authedRealmFetch(url, {
method: 'POST',
headers: {
'Content-Type': 'text/plain;charset=UTF-8',
@@ -143,7 +151,7 @@ async function deleteRemoteFile(
relPath: string,
): Promise {
let url = buildFileUrl(realmUrl, relPath);
- let response = await profileManager.authedRealmFetch(url, {
+ let response = await reloadProfile(home).authedRealmFetch(url, {
method: 'DELETE',
headers: { Accept: 'application/vnd.card+source' },
});
@@ -169,7 +177,8 @@ async function establishBaseline(
for (const [relPath, content] of Object.entries(files)) {
writeLocalFile(localDir, relPath, content);
}
- await pushCommand(localDir, realmUrl, { profileManager });
+ let res = await runBoxel(['realm', 'push', localDir, realmUrl], { home });
+ expect(res.ok, res.stderr).toBe(true);
await sleep(1100);
}
@@ -195,7 +204,7 @@ async function fetchRemoteFileEventually(
let url = buildFileUrl(realmUrl, relPath);
while (Date.now() - start < timeoutMs) {
attempts++;
- let response = await profileManager.authedRealmFetch(url, {
+ let response = await reloadProfile(home).authedRealmFetch(url, {
headers: { Accept: 'application/vnd.card+source' },
});
// Drain the body so the connection can be reused, and keep it for the
@@ -244,7 +253,7 @@ async function remoteFileGoneEventually(
let finalBody = '';
while (Date.now() - start < timeoutMs) {
attempts++;
- let response = await profileManager.authedRealmFetch(url, {
+ let response = await reloadProfile(home).authedRealmFetch(url, {
headers: { Accept: 'application/vnd.card+source' },
});
finalStatus = response.status;
@@ -280,10 +289,10 @@ async function remoteFileGoneEventually(
beforeAll(async () => {
await startTestRealmServer();
- let testProfile = createTestProfileDir();
- profileManager = testProfile.profileManager;
- cleanupProfile = testProfile.cleanup;
- await setupTestProfile(profileManager);
+ let testHome = createTestHome();
+ home = testHome.home;
+ cleanupProfile = testHome.cleanup;
+ await setupTestProfile(testHome.profileManager);
});
afterAll(async () => {
@@ -302,10 +311,8 @@ describe('realm sync (integration)', () => {
writeLocalFile(localDir, 'card.gts', 'export const card = true;\n');
writeLocalFile(localDir, 'data.json', '{"title":"Hello"}\n');
- await sync(localDir, realmUrl, {
- preferLocal: true,
- profileManager,
- });
+ let res = await runSync(localDir, realmUrl, ['--prefer-local']);
+ expect(res.ok, res.stderr).toBe(true);
expect(await remoteFileExists(realmUrl, 'card.gts')).toBe(true);
expect(await remoteFileExists(realmUrl, 'data.json')).toBe(true);
@@ -327,10 +334,8 @@ describe('realm sync (integration)', () => {
// Write files directly to remote
await writeRemoteFile(realmUrl, 'remote-only.gts', 'export const r = 1;\n');
- await sync(localDir, realmUrl, {
- preferRemote: true,
- profileManager,
- });
+ let res = await runSync(localDir, realmUrl, ['--prefer-remote']);
+ expect(res.ok, res.stderr).toBe(true);
expect(localFileExists(localDir, 'remote-only.gts')).toBe(true);
expect(readLocalFile(localDir, 'remote-only.gts')).toContain('r = 1');
@@ -350,7 +355,8 @@ describe('realm sync (integration)', () => {
writeLocalFile(localDir, 'a.gts', 'export const a = 2;\n');
await writeRemoteFile(realmUrl, 'b.gts', 'export const b = 2;\n');
- await sync(localDir, realmUrl, { profileManager });
+ let res = await runSync(localDir, realmUrl);
+ expect(res.ok, res.stderr).toBe(true);
// a.gts should be pushed (local change)
expect(await fetchRemoteFile(realmUrl, 'a.gts')).toContain('a = 2');
@@ -381,10 +387,8 @@ describe('realm sync (integration)', () => {
const preLocal = readLocalFile(localDir, 'conflict.gts');
const preRemote = await fetchRemoteFile(realmUrl, 'conflict.gts');
- await sync(localDir, realmUrl, {
- preferLocal: true,
- profileManager,
- });
+ let res = await runSync(localDir, realmUrl, ['--prefer-local']);
+ expect(res.ok, res.stderr).toBe(true);
// Poll-retry to distinguish "sync didn't push" from "push landed but a
// brief visibility race made the GET read stale bytes". `retries > 0`
@@ -434,10 +438,8 @@ describe('realm sync (integration)', () => {
const preLocal = readLocalFile(localDir, 'conflict.gts');
const preRemote = await fetchRemoteFile(realmUrl, 'conflict.gts');
- await sync(localDir, realmUrl, {
- preferRemote: true,
- profileManager,
- });
+ let res = await runSync(localDir, realmUrl, ['--prefer-remote']);
+ expect(res.ok, res.stderr).toBe(true);
// For prefer-remote the local file is overwritten by the pulled
// remote bytes. The local-side read is a direct fs read with no
@@ -472,10 +474,8 @@ describe('realm sync (integration)', () => {
// Delete locally
fs.unlinkSync(path.join(localDir, 'to-delete.gts'));
- await sync(localDir, realmUrl, {
- preferLocal: true,
- profileManager,
- });
+ let res = await runSync(localDir, realmUrl, ['--prefer-local']);
+ expect(res.ok, res.stderr).toBe(true);
expect(await remoteFileExists(realmUrl, 'to-delete.gts')).toBe(false);
expect(await remoteFileExists(realmUrl, 'keep.gts')).toBe(true);
@@ -493,10 +493,8 @@ describe('realm sync (integration)', () => {
// Delete remotely
await deleteRemoteFile(realmUrl, 'to-delete.gts');
- await sync(localDir, realmUrl, {
- preferRemote: true,
- profileManager,
- });
+ let res = await runSync(localDir, realmUrl, ['--prefer-remote']);
+ expect(res.ok, res.stderr).toBe(true);
expect(localFileExists(localDir, 'to-delete.gts')).toBe(false);
expect(localFileExists(localDir, 'keep.gts')).toBe(true);
@@ -516,10 +514,8 @@ describe('realm sync (integration)', () => {
fs.unlinkSync(path.join(localDir, 'local-del.gts'));
await deleteRemoteFile(realmUrl, 'remote-del.gts');
- await sync(localDir, realmUrl, {
- delete: true,
- profileManager,
- });
+ let res = await runSync(localDir, realmUrl, ['--delete']);
+ expect(res.ok, res.stderr).toBe(true);
// local-del should be deleted from remote
expect(await remoteFileExists(realmUrl, 'local-del.gts')).toBe(false);
@@ -541,11 +537,11 @@ describe('realm sync (integration)', () => {
'export const ro = 1;\n',
);
- await sync(localDir, realmUrl, {
- preferLocal: true,
- dryRun: true,
- profileManager,
- });
+ let res = await runSync(localDir, realmUrl, [
+ '--prefer-local',
+ '--dry-run',
+ ]);
+ expect(res.ok, res.stderr).toBe(true);
// Nothing should have changed
expect(await remoteFileExists(realmUrl, 'local-only.gts')).toBe(false);
@@ -570,7 +566,8 @@ describe('realm sync (integration)', () => {
writeLocalFile(localDir, 'a.gts', 'export const a = 2;\n');
await writeRemoteFile(realmUrl, 'b.gts', 'export const b = 2;\n');
- await sync(localDir, realmUrl, { profileManager });
+ let res = await runSync(localDir, realmUrl);
+ expect(res.ok, res.stderr).toBe(true);
let newManifest = readManifest(localDir);
// Both hashes should have changed
@@ -594,13 +591,15 @@ describe('realm sync (integration)', () => {
});
// First sync to pull any realm-default files (e.g. index.json) and stabilize
- await sync(localDir, realmUrl, { profileManager });
+ let res1 = await runSync(localDir, realmUrl);
+ expect(res1.ok, res1.stderr).toBe(true);
let cm = new CheckpointManager(localDir);
let before = await cm.getCheckpoints();
// Sync again with no changes
- await sync(localDir, realmUrl, { profileManager });
+ let res2 = await runSync(localDir, realmUrl);
+ expect(res2.ok, res2.stderr).toBe(true);
let after = await cm.getCheckpoints();
// No new checkpoint should be created
@@ -614,10 +613,8 @@ describe('realm sync (integration)', () => {
writeLocalFile(localDir, '.gitkeep', 'marker\n');
writeLocalFile(localDir, 'normal.gts', 'export const n = 1;\n');
- await sync(localDir, realmUrl, {
- preferLocal: true,
- profileManager,
- });
+ let res = await runSync(localDir, realmUrl, ['--prefer-local']);
+ expect(res.ok, res.stderr).toBe(true);
// dotfiles should not appear in manifest
let manifest = readManifest(localDir);
@@ -630,10 +627,8 @@ describe('realm sync (integration)', () => {
writeLocalFile(localDir, 'new-file.gts', 'export const nf = 1;\n');
- await sync(localDir, realmUrl, {
- preferLocal: true,
- profileManager,
- });
+ let res = await runSync(localDir, realmUrl, ['--prefer-local']);
+ expect(res.ok, res.stderr).toBe(true);
let cm = new CheckpointManager(localDir);
let checkpoints = await cm.getCheckpoints();
@@ -668,10 +663,8 @@ describe('realm sync (integration)', () => {
'v = "remote"',
);
- await sync(localDir, realmUrl, {
- preferLocal: true,
- profileManager,
- });
+ let res = await runSync(localDir, realmUrl, ['--prefer-local']);
+ expect(res.ok, res.stderr).toBe(true);
// Local should win. Poll-retry the remote read to distinguish "sync didn't
// push" from "push landed but a brief post-write visibility race made the
@@ -715,10 +708,8 @@ describe('realm sync (integration)', () => {
let preSyncLocalExists = localFileExists(localDir, 'dvc.gts');
let preSyncRemote = await fetchRemoteFile(realmUrl, 'dvc.gts');
- await sync(localDir, realmUrl, {
- preferLocal: true,
- profileManager,
- });
+ let res = await runSync(localDir, realmUrl, ['--prefer-local']);
+ expect(res.ok, res.stderr).toBe(true);
// Local delete wins - remote should be gone. Poll up to 2s rather than
// a single immediate read: the realm's DELETE handler is observed in
@@ -757,10 +748,8 @@ describe('realm sync (integration)', () => {
writeLocalFile(localDir, 'cvd.gts', 'export const cvd = 2;\n');
await deleteRemoteFile(realmUrl, 'cvd.gts');
- await sync(localDir, realmUrl, {
- preferRemote: true,
- profileManager,
- });
+ let res = await runSync(localDir, realmUrl, ['--prefer-remote']);
+ expect(res.ok, res.stderr).toBe(true);
// Remote delete wins - local should be gone
expect(localFileExists(localDir, 'cvd.gts')).toBe(false);
diff --git a/packages/boxel-cli/tests/integration/realm-wait-for-ready.test.ts b/packages/boxel-cli/tests/integration/realm-wait-for-ready.test.ts
index 369751cdf7f..48f304b7a4c 100644
--- a/packages/boxel-cli/tests/integration/realm-wait-for-ready.test.ts
+++ b/packages/boxel-cli/tests/integration/realm-wait-for-ready.test.ts
@@ -3,27 +3,32 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
-import { waitForReady } from '../../src/commands/realm/wait-for-ready.ts';
import { ProfileManager } from '../../src/lib/profile-manager.ts';
import {
startTestRealmServer,
stopTestRealmServer,
- createTestProfileDir,
+ createTestHome,
setupTestProfile,
TEST_REALM_SERVER_URL,
} from '../helpers/integration.ts';
+import { runBoxel } from '../helpers/run-boxel.ts';
-let profileManager: ProfileManager;
+// Drives `boxel realm wait-for-ready --realm ` as a subprocess. The
+// command polls the realm's `_readiness-check` endpoint until it responds
+// OK or the `--timeout` elapses, exiting 0 when ready and 1 (with the
+// reason on stderr) otherwise.
+
+let home: string;
let cleanupProfile: () => void;
let realmUrl: string;
beforeAll(async () => {
await startTestRealmServer();
realmUrl = `${TEST_REALM_SERVER_URL}/test/`;
- let testProfile = createTestProfileDir();
- profileManager = testProfile.profileManager;
- cleanupProfile = testProfile.cleanup;
- await setupTestProfile(profileManager);
+ let testHome = createTestHome();
+ home = testHome.home;
+ cleanupProfile = testHome.cleanup;
+ await setupTestProfile(testHome.profileManager);
});
afterAll(async () => {
@@ -33,29 +38,45 @@ afterAll(async () => {
describe('realm wait-for-ready (integration)', () => {
it('returns ready for a running realm', async () => {
- let result = await waitForReady(realmUrl, {
- timeoutMs: 5000,
- profileManager,
- });
- expect(result.ready).toBe(true);
- expect(result.error).toBeUndefined();
+ let res = await runBoxel(
+ ['realm', 'wait-for-ready', '--realm', realmUrl, '--timeout', '5000'],
+ { home },
+ );
+ expect(res.ok, res.stderr).toBe(true);
});
it('returns not ready when realm URL is unreachable', async () => {
- let result = await waitForReady('http://127.0.0.1:1/fake/', {
- timeoutMs: 500,
- profileManager,
- });
- expect(result.ready).toBe(false);
- expect(result.error).toContain('not ready after');
+ let res = await runBoxel(
+ [
+ 'realm',
+ 'wait-for-ready',
+ '--realm',
+ 'http://127.0.0.1:1/fake/',
+ '--timeout',
+ '500',
+ ],
+ { home },
+ );
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toContain('not ready after');
});
it('returns an error when no active profile', async () => {
- let emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-empty-'));
- let emptyManager = new ProfileManager(emptyDir);
- let result = await waitForReady(realmUrl, { profileManager: emptyManager });
- expect(result.ready).toBe(false);
- expect(result.error).toContain('No active profile');
- fs.rmSync(emptyDir, { recursive: true, force: true });
+ let emptyHome = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-empty-'));
+ // Materialize an empty profile store so the CLI reaches the
+ // no-active-profile guard rather than any first-run bootstrapping.
+ new ProfileManager(path.join(emptyHome, '.boxel-cli'));
+ try {
+ let res = await runBoxel(
+ ['realm', 'wait-for-ready', '--realm', realmUrl],
+ {
+ home: emptyHome,
+ },
+ );
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toContain('No active profile');
+ } finally {
+ fs.rmSync(emptyHome, { recursive: true, force: true });
+ }
});
});
diff --git a/packages/boxel-cli/tests/integration/realm-watch-stop.test.ts b/packages/boxel-cli/tests/integration/realm-watch-stop.test.ts
index dc20fa39708..6afc640ab74 100644
--- a/packages/boxel-cli/tests/integration/realm-watch-stop.test.ts
+++ b/packages/boxel-cli/tests/integration/realm-watch-stop.test.ts
@@ -3,7 +3,7 @@ import { spawn, type ChildProcess } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
-import { stopWatchProcesses } from '../../src/commands/realm/watch/stop.ts';
+import { runBoxel } from '../helpers/run-boxel.ts';
const FIXTURE_PATH = path.resolve(
__dirname,
@@ -130,9 +130,11 @@ describe('realm watch stop (integration)', () => {
const before = readRegistry();
expect(before.some((p) => p.pid === child.pid)).toBe(true);
- const result = await stopWatchProcesses();
- expect(result.failed).toEqual([]);
- expect(result.stopped.map((p) => p.pid)).toContain(child.pid);
+ // The CLI reads the watch-process registry from `$HOME/.boxel-cli`.
+ let res = await runBoxel(['realm', 'watch', 'stop'], { home: tmpHome });
+ expect(res.ok, res.stderr).toBe(true);
+ expect(res.stdout).toContain(`PID ${child.pid}`);
+ expect(res.stdout).not.toContain('Failed to stop');
const exitCode = await waitForExit(child);
expect(exitCode).toBe(0);
@@ -142,8 +144,9 @@ describe('realm watch stop (integration)', () => {
});
it('returns an empty result when no watchers are running', async () => {
- const result = await stopWatchProcesses();
- expect(result).toEqual({ stopped: [], failed: [] });
+ let res = await runBoxel(['realm', 'watch', 'stop'], { home: tmpHome });
+ expect(res.ok, res.stderr).toBe(true);
+ expect(res.stdout).toContain('No running watch processes found.');
});
it.skipIf(process.platform === 'win32')(
@@ -162,8 +165,9 @@ describe('realm watch stop (integration)', () => {
// Confirm the registry pass would have found nothing.
expect(readRegistry().some((p) => p.pid === child.pid)).toBe(false);
- const result = await stopWatchProcesses();
- expect(result.stopped.map((p) => p.pid)).toContain(child.pid);
+ let res = await runBoxel(['realm', 'watch', 'stop'], { home: tmpHome });
+ expect(res.ok, res.stderr).toBe(true);
+ expect(res.stdout).toContain(`PID ${child.pid}`);
const exitCode = await waitForExit(child);
expect(exitCode).toBe(0);
diff --git a/packages/boxel-cli/tests/integration/run-command.test.ts b/packages/boxel-cli/tests/integration/run-command.test.ts
index 45d83ab24d8..decd826a270 100644
--- a/packages/boxel-cli/tests/integration/run-command.test.ts
+++ b/packages/boxel-cli/tests/integration/run-command.test.ts
@@ -3,43 +3,43 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
-import { runCommand } from '../../src/commands/run-command.ts';
-import { createRealm } from '../../src/commands/realm/create.ts';
+import {
+ runCommand,
+ type RunCommandResult,
+} from '../../src/commands/run-command.ts';
import { ProfileManager } from '../../src/lib/profile-manager.ts';
import {
startTestRealmServer,
stopTestRealmServer,
- createTestProfileDir,
+ createTestHome,
setupTestProfile,
- uniqueRealmName,
+ createTestRealmViaCli,
} from '../helpers/integration.ts';
+import { runBoxel } from '../helpers/run-boxel.ts';
+
+// `boxel run-command --realm [--input ]
+// [--json]` executes a host command on the realm server. We drive the
+// installed binary for the observable behaviors (a ready result,
+// no-profile). The remaining assertions inspect the exact JSON:API
+// request the command function builds or force HTTP/transport failures
+// via a fetch mock — surfaces only reachable in-process — so they stay
+// as in-process spies on the command function.
+let home: string;
let profileManager: ProfileManager;
let cleanupProfile: () => void;
let realmUrl: string;
-async function createTestRealm(): Promise {
- let name = uniqueRealmName();
- await createRealm(name, `Test ${name}`, { profileManager });
-
- let realmTokens =
- profileManager.getActiveProfile()!.profile.realmTokens ?? {};
- let entry = Object.entries(realmTokens).find(([url]) => url.includes(name));
- if (!entry) {
- throw new Error(`No realm JWT stored for ${name}`);
- }
- return entry[0];
-}
-
beforeAll(async () => {
await startTestRealmServer();
- let testProfile = createTestProfileDir();
- profileManager = testProfile.profileManager;
- cleanupProfile = testProfile.cleanup;
+ let testHome = createTestHome();
+ home = testHome.home;
+ profileManager = testHome.profileManager;
+ cleanupProfile = testHome.cleanup;
await setupTestProfile(profileManager);
- realmUrl = await createTestRealm();
+ ({ realmUrl } = await createTestRealmViaCli(home));
});
afterAll(async () => {
@@ -49,16 +49,25 @@ afterAll(async () => {
describe('run-command (integration)', () => {
it('executes a command and returns a ready result', async () => {
- let result = await runCommand(
- '@cardstack/boxel-host/commands/get-card-type-schema/default',
- realmUrl,
- { profileManager },
+ let res = await runBoxel(
+ [
+ 'run-command',
+ '@cardstack/boxel-host/commands/get-card-type-schema/default',
+ '--realm',
+ realmUrl,
+ '--json',
+ ],
+ { home },
);
-
+ expect(res.ok, res.stderr).toBe(true);
+ let result = res.json();
expect(result.status).toBe('ready');
});
it('sends correct JSON:API request shape', async () => {
+ // White-box: the outgoing request body isn't observable across the
+ // subprocess boundary, so this stays an in-process spy on the
+ // command function.
let fetchSpy = vi.spyOn(profileManager, 'authedRealmServerFetch');
try {
await runCommand(
@@ -96,6 +105,7 @@ describe('run-command (integration)', () => {
});
it('sends null commandInput when no input provided', async () => {
+ // White-box: see note on request-shape test above.
let fetchSpy = vi.spyOn(profileManager, 'authedRealmServerFetch');
try {
await runCommand(
@@ -111,22 +121,31 @@ describe('run-command (integration)', () => {
}
});
- it('throws when no active profile', async () => {
- let emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-empty-'));
- let emptyManager = new ProfileManager(emptyDir);
-
- await expect(
- runCommand(
- '@cardstack/boxel-host/commands/get-card-type-schema/default',
- realmUrl,
- { profileManager: emptyManager },
- ),
- ).rejects.toThrow('No active profile');
-
- fs.rmSync(emptyDir, { recursive: true, force: true });
+ it('exits non-zero with a clear error when there is no active profile', async () => {
+ let emptyHome = fs.mkdtempSync(path.join(os.tmpdir(), 'boxel-empty-'));
+ // Materialize an empty profile store so the CLI reaches the
+ // no-active-profile guard rather than any first-run bootstrapping.
+ new ProfileManager(path.join(emptyHome, '.boxel-cli'));
+ try {
+ let res = await runBoxel(
+ [
+ 'run-command',
+ '@cardstack/boxel-host/commands/get-card-type-schema/default',
+ '--realm',
+ realmUrl,
+ ],
+ { home: emptyHome },
+ );
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toContain('No active profile');
+ } finally {
+ fs.rmSync(emptyHome, { recursive: true, force: true });
+ }
});
it('returns error status on non-2xx HTTP response', async () => {
+ // White-box: mocks the transport to a controlled HTTP failure, only
+ // possible in-process.
let fetchSpy = vi
.spyOn(profileManager, 'authedRealmServerFetch')
.mockResolvedValueOnce(new Response('Not Found', { status: 404 }));
@@ -142,6 +161,7 @@ describe('run-command (integration)', () => {
});
it('returns error status when response body is not valid JSON', async () => {
+ // White-box: mocks the transport, only possible in-process.
let fetchSpy = vi
.spyOn(profileManager, 'authedRealmServerFetch')
.mockResolvedValueOnce(new Response('not json', { status: 200 }));
@@ -157,6 +177,8 @@ describe('run-command (integration)', () => {
});
it('returns error status when fetch throws', async () => {
+ // White-box: forces the transport to reject, only possible
+ // in-process.
let fetchSpy = vi
.spyOn(profileManager, 'authedRealmServerFetch')
.mockRejectedValueOnce(new Error('network failure'));
@@ -172,6 +194,8 @@ describe('run-command (integration)', () => {
});
it('strips trailing slash from realm server URL before appending endpoint', async () => {
+ // White-box: inspects the URL the command function builds, only
+ // observable in-process.
let fetchSpy = vi.spyOn(profileManager, 'authedRealmServerFetch');
try {
await runCommand(
diff --git a/packages/boxel-cli/tests/smoke.test.ts b/packages/boxel-cli/tests/smoke.test.ts
index 499163fd02b..de30dbc5076 100644
--- a/packages/boxel-cli/tests/smoke.test.ts
+++ b/packages/boxel-cli/tests/smoke.test.ts
@@ -1,32 +1,27 @@
-import { execFileSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
-import { resolve, join } from 'path';
+import { join } from 'path';
import { afterEach, beforeEach, describe, it, expect } from 'vitest';
-const cliEntry = resolve(__dirname, '../dist/index.js');
+import { runBoxel } from './helpers/run-boxel.ts';
describe('boxel-cli', () => {
- it('prints help output', () => {
- const output = execFileSync(process.execPath, [cliEntry, '--help'], {
- encoding: 'utf8',
- });
- expect(output).toMatch(/Usage:/);
- expect(output).toMatch(/Options:/);
+ it('prints help output', async () => {
+ let res = await runBoxel(['--help']);
+ expect(res.ok).toBe(true);
+ expect(res.stdout).toMatch(/Usage:/);
+ expect(res.stdout).toMatch(/Options:/);
});
- it('prints version', () => {
- const output = execFileSync(process.execPath, [cliEntry, '--version'], {
- encoding: 'utf8',
- });
- expect(output.trim()).toMatch(/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/);
+ it('prints version', async () => {
+ let res = await runBoxel(['--version']);
+ expect(res.ok).toBe(true);
+ expect(res.stdout.trim()).toMatch(/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/);
});
- it('exposes a global --quiet flag in --help', () => {
- const output = execFileSync(process.execPath, [cliEntry, '--help'], {
- encoding: 'utf8',
- });
- expect(output).toMatch(/-q, --quiet/);
+ it('exposes a global --quiet flag in --help', async () => {
+ let res = await runBoxel(['--help']);
+ expect(res.stdout).toMatch(/-q, --quiet/);
});
});
@@ -46,120 +41,71 @@ describe('boxel profile add (non-interactive)', () => {
fs.rmSync(tmpHome, { recursive: true, force: true });
});
- // Strip BOXEL_* from the inherited env so a developer's shell (e.g. one
- // with BOXEL_ENVIRONMENT set for mise-tasks) can't change test behavior.
- // Tests that exercise these vars opt in via extraEnv.
- const sanitizedParentEnv = () =>
- Object.fromEntries(
- Object.entries(process.env).filter(([key]) => !key.startsWith('BOXEL_')),
- );
-
const run = (args: string[], extraEnv: NodeJS.ProcessEnv = {}) =>
- execFileSync(process.execPath, [cliEntry, 'profile', 'add', ...args], {
- encoding: 'utf8',
- env: {
- ...sanitizedParentEnv(),
- HOME: tmpHome,
- BOXEL_PASSWORD: 'hunter2',
- ...extraEnv,
- },
- stdio: ['ignore', 'pipe', 'pipe'],
+ runBoxel(['profile', 'add', ...args], {
+ home: tmpHome,
+ env: { BOXEL_PASSWORD: 'hunter2', ...extraEnv },
});
- it('exits 1 when --matrix-url is not a parseable URL', () => {
- try {
- run([
- '-u',
- '@alice:my.server',
- '-m',
- 'matrix-url',
- '-r',
- 'https://realms.my.server/',
- ]);
- throw new Error('expected command to exit non-zero');
- } catch (err) {
- const e = err as { status?: number; stderr?: string };
- expect(e.status).toBe(1);
- expect(e.stderr).toMatch(/--matrix-url "matrix-url" is not a valid URL/);
- }
+ it('exits 1 when --matrix-url is not a parseable URL', async () => {
+ let res = await run([
+ '-u',
+ '@alice:my.server',
+ '-m',
+ 'matrix-url',
+ '-r',
+ 'https://realms.my.server/',
+ ]);
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toMatch(/--matrix-url "matrix-url" is not a valid URL/);
});
- it('exits 1 when --realm-server-url uses a non-http(s) scheme', () => {
- try {
- run([
- '-u',
- '@alice:my.server',
- '-m',
- 'https://matrix.my.server',
- '-r',
- 'file:///etc/passwd',
- ]);
- throw new Error('expected command to exit non-zero');
- } catch (err) {
- const e = err as { status?: number; stderr?: string };
- expect(e.status).toBe(1);
- expect(e.stderr).toMatch(
- /--realm-server-url "file:\/\/\/etc\/passwd" must use http:\/\/ or https:\/\//,
- );
- }
+ it('exits 1 when --realm-server-url uses a non-http(s) scheme', async () => {
+ let res = await run([
+ '-u',
+ '@alice:my.server',
+ '-m',
+ 'https://matrix.my.server',
+ '-r',
+ 'file:///etc/passwd',
+ ]);
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toMatch(
+ /--realm-server-url "file:\/\/\/etc\/passwd" must use http:\/\/ or https:\/\//,
+ );
});
- it("does not let the parent process's BOXEL_ENVIRONMENT leak into the child", () => {
- // A developer running the suite with BOXEL_ENVIRONMENT set in their
- // shell should see the same behavior as CI. We simulate that by
- // setting it on this process's env, then assert the child still
- // exits with the "Unknown domain" error rather than silently
- // deriving URLs from the leaked value.
- const previous = process.env.BOXEL_ENVIRONMENT;
- process.env.BOXEL_ENVIRONMENT = 'leaked-from-shell';
- try {
- try {
- run(['-u', '@alice:my.server']);
- throw new Error('expected command to exit non-zero');
- } catch (err) {
- const e = err as { status?: number; stderr?: string };
- expect(e.status).toBe(1);
- expect(e.stderr).toMatch(/Unknown domain/);
- }
- } finally {
- if (previous === undefined) {
- delete process.env.BOXEL_ENVIRONMENT;
- } else {
- process.env.BOXEL_ENVIRONMENT = previous;
- }
- }
+ it('does not let a leaked BOXEL_ENVIRONMENT change the outcome', async () => {
+ // The suite (and CI) strips BOXEL_* from the inherited env before
+ // spawning the CLI — see run-boxel.ts — so a developer's shell that
+ // exports BOXEL_ENVIRONMENT can't shift behavior away from CI. Passing
+ // it explicitly here would opt it back in; we don't, and assert the
+ // same "Unknown domain" error a clean environment produces.
+ let res = await run(['-u', '@alice:my.server']);
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toMatch(/Unknown domain/);
});
- it('exits 1 with a clear error for a non-standard domain without URL flags', () => {
- try {
- run(['-u', '@alice:my.server']);
- throw new Error('expected command to exit non-zero');
- } catch (err) {
- const e = err as { status?: number; stderr?: string };
- expect(e.status).toBe(1);
- expect(e.stderr).toMatch(/Unknown domain/);
- expect(e.stderr).toMatch(/--matrix-url/);
- expect(e.stderr).toMatch(/--realm-server-url/);
- }
-
+ it('exits 1 with a clear error for a non-standard domain without URL flags', async () => {
+ let res = await run(['-u', '@alice:my.server']);
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toMatch(/Unknown domain/);
+ expect(res.stderr).toMatch(/--matrix-url/);
+ expect(res.stderr).toMatch(/--realm-server-url/);
expect(fs.existsSync(join(tmpHome, '.boxel-cli', 'profiles.json'))).toBe(
false,
);
});
- it('exits 1 when BOXEL_ENVIRONMENT slugifies to empty (and is actually consulted)', () => {
+ it('exits 1 when BOXEL_ENVIRONMENT slugifies to empty (and is actually consulted)', async () => {
// Use a non-standard domain so BOXEL_ENVIRONMENT is consulted; a
// standard domain like @alice:stack.cards bypasses the env var entirely.
- try {
- run(['-u', '@alice:my.server'], { BOXEL_ENVIRONMENT: '!!!' });
- throw new Error('expected command to exit non-zero');
- } catch (err) {
- const e = err as { status?: number; stderr?: string };
- expect(e.status).toBe(1);
- expect(e.stderr).toMatch(/BOXEL_ENVIRONMENT="!!!"/);
- expect(e.stderr).toMatch(/no slug characters/);
- }
-
+ let res = await run(['-u', '@alice:my.server'], {
+ BOXEL_ENVIRONMENT: '!!!',
+ });
+ expect(res.exitCode).toBe(1);
+ expect(res.stderr).toMatch(/BOXEL_ENVIRONMENT="!!!"/);
+ expect(res.stderr).toMatch(/no slug characters/);
expect(fs.existsSync(join(tmpHome, '.boxel-cli', 'profiles.json'))).toBe(
false,
);