From 2a718d11714da4a61e3c3baaecb07985383d4b37 Mon Sep 17 00:00:00 2001 From: Buck Doyle Date: Fri, 17 Jul 2026 14:31:10 -0500 Subject: [PATCH 1/2] test: exercise the npm-installed boxel-cli in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The boxel-cli suite only ever called command functions in-process inside the monorepo, where pnpm's nested node_modules masks how the package behaves once installed from npm. `boxel parse` shipped broken — glint silently resolved nothing under npm's hoisted node_modules layout — despite a fully green suite. Add a subprocess-driven harness whose only per-context variable is the binary under test (BOXEL_CLI_BIN): - tests/helpers/run-boxel.ts drives the CLI as a subprocess (argv + env + stdin -> stdout/stderr/exit). - tests/helpers/integration.ts seeds a JWT profile on disk so a spawned CLI authenticates via a temp HOME with no Matrix round-trip. - scripts/run-cli-suite.ts installs the target and runs a test command against it: `pnpm pack` (resolves catalog:/workspace: specifiers) then `npm install` the tarball for real hoisting, or `npm install` from the registry for the published artifact. Wire two contexts into CI: the PR gate runs the suite against the packed npm install (test:cli:tarball), and the publish workflow smoke-tests the just-published registry artifact (verify-unstable / verify-stable). Add parse fixtures covering the reported resolution gaps plus a deliberate type error that proves glint actually ran, and port smoke.test.ts onto the harness. This is the test/CI half of CS-12083. The parse fix is a follow-up, so the parse fixtures are expected to fail against the installed binary until it lands. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/boxel-cli-publish.yml | 52 +++++ .github/workflows/ci.yaml | 13 +- packages/boxel-cli/.eslintignore | 7 + packages/boxel-cli/package.json | 2 + packages/boxel-cli/scripts/run-cli-suite.ts | 197 ++++++++++++++++++ .../commands/parse-glimmer-types.test.ts | 73 ------- .../fixtures/parse/boxel-host-tools/saver.gts | 18 ++ .../parse/deliberate-type-error/broken.gts | 18 ++ .../parse/helpers-and-fields/event.gts | 27 +++ .../fixtures/parse/plain-glimmer/counter.gts | 22 ++ .../fixtures/parse/runtime-common/home.gts | 20 ++ .../parse/tracked-format-class/toggle.gts | 28 +++ .../boxel-cli/tests/helpers/integration.ts | 37 ++++ packages/boxel-cli/tests/helpers/run-boxel.ts | 144 +++++++++++++ .../boxel-cli/tests/integration/parse.test.ts | 84 ++++++++ packages/boxel-cli/tests/smoke.test.ts | 180 ++++++---------- 16 files changed, 730 insertions(+), 192 deletions(-) create mode 100644 packages/boxel-cli/scripts/run-cli-suite.ts delete mode 100644 packages/boxel-cli/tests/commands/parse-glimmer-types.test.ts create mode 100644 packages/boxel-cli/tests/fixtures/parse/boxel-host-tools/saver.gts create mode 100644 packages/boxel-cli/tests/fixtures/parse/deliberate-type-error/broken.gts create mode 100644 packages/boxel-cli/tests/fixtures/parse/helpers-and-fields/event.gts create mode 100644 packages/boxel-cli/tests/fixtures/parse/plain-glimmer/counter.gts create mode 100644 packages/boxel-cli/tests/fixtures/parse/runtime-common/home.gts create mode 100644 packages/boxel-cli/tests/fixtures/parse/tracked-format-class/toggle.gts create mode 100644 packages/boxel-cli/tests/helpers/run-boxel.ts create mode 100644 packages/boxel-cli/tests/integration/parse.test.ts 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 `; - 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/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(