diff --git a/README.md b/README.md index 9db852f..edd100b 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ ensemble pull ensemble release ensemble add ensemble enable +ensemble test ensemble update ``` @@ -36,6 +37,7 @@ ensemble update | `ensemble release` | Manage releases (snapshots) of your app (interactive menu or subcommands) | | `ensemble add` | Add a new screen, widget, script, action, translation, or asset | | `ensemble enable` | Enable starter modules (camera, location, google_maps, etc.) in a Flutter app | +| `ensemble test` | Run declarative YAML tests in a Flutter starter project | | `ensemble update` | Update the CLI to the latest version | ### Options @@ -91,6 +93,10 @@ ensemble update - After `pubspec.yaml` changes, run `flutter pub get` - Team architecture notes: [docs/ensemble-enable.md](docs/ensemble-enable.md) +### `ensemble test` + +Run YAML tests in a Flutter starter project (starter root or `ensemble/apps/`). Other flags pass through to the runner. See [docs/ensemble-test.md](docs/ensemble-test.md). + ### `ensemble add` `ensemble add` scaffolds common app artifacts in your project and updates `.manifest.json` when needed. diff --git a/docs/ensemble-test.md b/docs/ensemble-test.md new file mode 100644 index 0000000..f5f07b0 --- /dev/null +++ b/docs/ensemble-test.md @@ -0,0 +1,43 @@ +# `ensemble test` + +Thin wrapper around [`ensemble_test_runner`](https://github.com/EnsembleUI/ensemble/tree/support-test-cases/packages/ensemble_test_runner). Splices a temporary dev dependency, runs tests, restores `pubspec.yaml`. + +```bash +ensemble test [--project ] [runner flags...] +``` + +- Run from **starter root** or **`ensemble/apps/`** (not subdirs). From an app dir, starter root is 2 parents up (`apps` → `ensemble` → starter). +- `--project` sets starter root explicitly. All other flags pass through (`--timeout=`, `--verbose`, `--doctor`, etc.). +- Uses `fvm dart` when `.fvmrc` exists. Requires Flutter **≥ 3.35**. + +--- + +## Architecture + +``` +test.ts + ├── starterProject.ts starter root or ensemble/apps/ + ├── pubspecTestRunner.ts splice/restore ensemble_test_runner dev_dep + └── dartToolchain.ts fvm dart when .fvmrc exists +``` + +```mermaid +sequenceDiagram + participant CLI + participant Runner as ensemble_test_runner + + CLI->>CLI: resolve starter root + CLI->>CLI: backup pubspec, splice dev_dep if missing + CLI->>Runner: dart run ensemble_test_runner:ensemble_test [flags] + Note over Runner: resolves deps if needed + Runner-->>CLI: exit code + CLI->>CLI: restore pubspec in finally +``` + +| Module | Role | +| ---------------------- | ------------------------------------------------------------------------ | +| `starterProject.ts` | Resolve starter root from cwd or 2 parents up from `ensemble/apps/` | +| `pubspecTestRunner.ts` | Insert fixed git dev_dep block; restore in `finally` | +| `dartToolchain.ts` | `fvm dart` vs `dart` | + +**Not duplicated in CLI:** test discovery, asset patching, `flutter test`, doctor/validate — owned by the runtime package. diff --git a/src/commands/test.ts b/src/commands/test.ts new file mode 100644 index 0000000..f32d643 --- /dev/null +++ b/src/commands/test.ts @@ -0,0 +1,31 @@ +import { assertDartAvailable, resolveDartInvocation } from '../core/dartToolchain.js'; +import { runDartWithExitCode, withTemporaryTestRunnerDep } from '../core/pubspecTestRunner.js'; +import { resolveStarterProjectRootWithWalkUp } from '../core/starterProject.js'; + +function collectPassthroughArgs(argv: readonly string[] = process.argv): string[] { + const testIndex = argv.indexOf('test'); + if (testIndex === -1) return []; + + const passthrough: string[] = []; + for (let i = testIndex + 1; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--project') { + i += 1; + continue; + } + if (!arg.startsWith('--project=')) passthrough.push(arg); + } + return passthrough; +} + +export async function testCommand(options: { project?: string } = {}): Promise { + const projectRoot = await resolveStarterProjectRootWithWalkUp(options.project); + const dart = await resolveDartInvocation(projectRoot); + await assertDartAvailable(dart); + + const dartArgs = ['run', 'ensemble_test_runner:ensemble_test', ...collectPassthroughArgs()]; + + await withTemporaryTestRunnerDep(projectRoot, async () => { + process.exitCode = await runDartWithExitCode(dart, dartArgs, projectRoot); + }); +} diff --git a/src/core/pubspecTestRunner.ts b/src/core/pubspecTestRunner.ts new file mode 100644 index 0000000..e2c8bc0 --- /dev/null +++ b/src/core/pubspecTestRunner.ts @@ -0,0 +1,73 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { spawn } from 'node:child_process'; + +import { type DartInvocation } from './dartToolchain.js'; +import { ui } from './ui.js'; + +const ENSEMBLE_GIT_URL = 'https://github.com/EnsembleUI/ensemble.git'; +const ENSEMBLE_TEST_RUNNER_REF = 'support-test-cases'; +const ENSEMBLE_TEST_RUNNER_PATH = 'packages/ensemble_test_runner'; + +const TEST_RUNNER_DEV_DEP_BLOCK = ` ensemble_test_runner: + git: + url: ${ENSEMBLE_GIT_URL} + ref: ${ENSEMBLE_TEST_RUNNER_REF} + path: ${ENSEMBLE_TEST_RUNNER_PATH} +`; + +function hasEnsembleTestRunnerDep(pubspecContent: string): boolean { + return /^\s*ensemble_test_runner\s*:/m.test(pubspecContent); +} + +export function spliceTestRunnerDevDependency(pubspecContent: string): string { + if (hasEnsembleTestRunnerDep(pubspecContent)) return pubspecContent; + + const match = pubspecContent.match(/^dev_dependencies:\s*$/m); + if (!match || match.index === undefined) { + throw new Error('pubspec.yaml has no dev_dependencies section.'); + } + + const insertAt = match.index + match[0].length; + return `${pubspecContent.slice(0, insertAt)}\n${TEST_RUNNER_DEV_DEP_BLOCK}${pubspecContent.slice(insertAt)}`; +} + +export async function runDartWithExitCode( + dart: DartInvocation, + args: string[], + cwd: string +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(dart.command, [...dart.prefixArgs, ...args], { cwd, stdio: 'inherit' }); + child.on('error', reject); + child.on('close', (code) => resolve(code ?? 1)); + }); +} + +export async function withTemporaryTestRunnerDep( + projectRoot: string, + fn: () => Promise +): Promise { + const pubspecPath = path.join(projectRoot, 'pubspec.yaml'); + const original = await fs.readFile(pubspecPath, 'utf8'); + let modified = false; + + try { + if (!hasEnsembleTestRunnerDep(original)) { + await fs.writeFile(pubspecPath, spliceTestRunnerDevDependency(original), 'utf8'); + modified = true; + } + + return await fn(); + } finally { + if (modified) { + try { + await fs.writeFile(pubspecPath, original, 'utf8'); + } catch (error) { + ui.warn( + `Failed to restore pubspec.yaml after tests: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + } +} diff --git a/src/core/starterProject.ts b/src/core/starterProject.ts index 7fc1909..a77a907 100644 --- a/src/core/starterProject.ts +++ b/src/core/starterProject.ts @@ -17,6 +17,12 @@ async function isStarterProjectRoot(dir: string): Promise { ); } +function isEnsembleAppRoot(dir: string): boolean { + const parent = path.dirname(path.resolve(dir)); + const grandparent = path.dirname(parent); + return path.basename(parent) === 'apps' && path.basename(grandparent) === 'ensemble'; +} + export async function resolveStarterProjectRoot(explicitPath?: string): Promise { const root = path.resolve(explicitPath ?? process.cwd()); @@ -28,3 +34,20 @@ export async function resolveStarterProjectRoot(explicitPath?: string): Promise< return root; } + +const TEST_CWD_HINT = + 'Run ensemble test from the starter root or an ensemble app directory (ensemble/apps/). Or pass --project .'; + +export async function resolveStarterProjectRootWithWalkUp(explicitPath?: string): Promise { + if (explicitPath) return resolveStarterProjectRoot(explicitPath); + + const cwd = path.resolve(process.cwd()); + if (await isStarterProjectRoot(cwd)) return cwd; + + if (!isEnsembleAppRoot(cwd)) throw new Error(TEST_CWD_HINT); + + const starterRoot = path.resolve(cwd, '..', '..', '..'); + if (await isStarterProjectRoot(starterRoot)) return starterRoot; + + throw new Error(TEST_CWD_HINT); +} diff --git a/src/index.ts b/src/index.ts index b6a3d9d..4c5b9c9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,7 @@ import { } from './commands/release.js'; import { updateCommand } from './commands/update.js'; import { enableCommand } from './commands/enable.js'; +import { testCommand } from './commands/test.js'; import { isUpdateCommand } from './core/cliArgs.js'; import { printCliError, resolveDebugFlag } from './core/cliError.js'; import { ui } from './core/ui.js'; @@ -238,6 +239,15 @@ program }); }); +program + .command('test') + .description('Run declarative YAML tests from the app tests directory.') + .option('--project ', 'Starter project root (default: starter root or ensemble/apps/)') + .allowUnknownOption() + .action(async (options: { project?: string }) => { + await testCommand({ project: options.project }); + }); + function checkForUpdates(): void { // Skip update checks in CI or when explicitly disabled. const ci = process.env.CI; diff --git a/tests/core/pubspecTestRunner.test.ts b/tests/core/pubspecTestRunner.test.ts new file mode 100644 index 0000000..86288fb --- /dev/null +++ b/tests/core/pubspecTestRunner.test.ts @@ -0,0 +1,68 @@ +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + spliceTestRunnerDevDependency, + withTemporaryTestRunnerDep, +} from '../../src/core/pubspecTestRunner.js'; + +const SAMPLE_PUBSPEC = `name: demo +dev_dependencies: + flutter_test: + sdk: flutter +`; + +describe('pubspecTestRunner', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ensemble-pubspec-test-runner-')); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('splices test runner under dev_dependencies', () => { + const updated = spliceTestRunnerDevDependency(SAMPLE_PUBSPEC); + expect(updated).toContain('ensemble_test_runner:'); + expect(updated).toContain('ref: support-test-cases'); + }); + + it('restores pubspec after callback', async () => { + await fs.writeFile(path.join(tmpDir, 'pubspec.yaml'), SAMPLE_PUBSPEC, 'utf8'); + + await withTemporaryTestRunnerDep(tmpDir, async () => undefined); + + expect(await fs.readFile(path.join(tmpDir, 'pubspec.yaml'), 'utf8')).toBe(SAMPLE_PUBSPEC); + }); + + it('restores pubspec when callback throws', async () => { + await fs.writeFile(path.join(tmpDir, 'pubspec.yaml'), SAMPLE_PUBSPEC, 'utf8'); + + await expect( + withTemporaryTestRunnerDep(tmpDir, async () => { + throw new Error('test failed'); + }) + ).rejects.toThrow(/test failed/i); + + expect(await fs.readFile(path.join(tmpDir, 'pubspec.yaml'), 'utf8')).toBe(SAMPLE_PUBSPEC); + }); + + it('skips splice when ensemble_test_runner is already in pubspec', async () => { + const pubspec = `${SAMPLE_PUBSPEC} ensemble_test_runner: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: support-test-cases + path: packages/ensemble_test_runner +`; + await fs.writeFile(path.join(tmpDir, 'pubspec.yaml'), pubspec, 'utf8'); + + await withTemporaryTestRunnerDep(tmpDir, async () => undefined); + + expect(await fs.readFile(path.join(tmpDir, 'pubspec.yaml'), 'utf8')).toBe(pubspec); + }); +}); diff --git a/tests/core/starterProject.test.ts b/tests/core/starterProject.test.ts index a5c5129..112d887 100644 --- a/tests/core/starterProject.test.ts +++ b/tests/core/starterProject.test.ts @@ -4,7 +4,10 @@ import path from 'path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { resolveStarterProjectRoot } from '../../src/core/starterProject.js'; +import { + resolveStarterProjectRoot, + resolveStarterProjectRootWithWalkUp, +} from '../../src/core/starterProject.js'; describe('starterProject', () => { let tmpDir: string; @@ -67,4 +70,25 @@ describe('starterProject', () => { const root = await resolveStarterProjectRoot(tmpDir); expect(await fs.realpath(root)).toBe(await fs.realpath(tmpDir)); }); + + it('resolves starter root from ensemble/apps/', async () => { + await writeStarterLayout(tmpDir); + const appRoot = path.join(tmpDir, 'ensemble', 'apps', 'kpnApp'); + await fs.mkdir(appRoot, { recursive: true }); + process.chdir(appRoot); + + const root = await resolveStarterProjectRootWithWalkUp(); + expect(await fs.realpath(root)).toBe(await fs.realpath(tmpDir)); + }); + + it('rejects nested paths inside an ensemble app', async () => { + await writeStarterLayout(tmpDir); + const nested = path.join(tmpDir, 'ensemble', 'apps', 'kpnApp', 'tests'); + await fs.mkdir(nested, { recursive: true }); + process.chdir(nested); + + await expect(resolveStarterProjectRootWithWalkUp()).rejects.toThrow( + /starter root or an ensemble app directory/i + ); + }); });