Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ ensemble pull
ensemble release
ensemble add
ensemble enable
ensemble test
ensemble update
```

Expand All @@ -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
Expand Down Expand Up @@ -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/<app>`). 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.
Expand Down
43 changes: 43 additions & 0 deletions docs/ensemble-test.md
Original file line number Diff line number Diff line change
@@ -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 <path>] [runner flags...]
```

- Run from **starter root** or **`ensemble/apps/<app>`** (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/<app>
├── 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/<app>` |
| `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.
31 changes: 31 additions & 0 deletions src/commands/test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
});
}
73 changes: 73 additions & 0 deletions src/core/pubspecTestRunner.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
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<T>(
projectRoot: string,
fn: () => Promise<T>
): Promise<T> {
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)}`
);
}
}
}
}
23 changes: 23 additions & 0 deletions src/core/starterProject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ async function isStarterProjectRoot(dir: string): Promise<boolean> {
);
}

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<string> {
const root = path.resolve(explicitPath ?? process.cwd());

Expand All @@ -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/<app>). Or pass --project <path>.';

export async function resolveStarterProjectRootWithWalkUp(explicitPath?: string): Promise<string> {
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);
}
10 changes: 10 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -238,6 +239,15 @@ program
});
});

program
.command('test')
.description('Run declarative YAML tests from the app tests directory.')
.option('--project <path>', 'Starter project root (default: starter root or ensemble/apps/<app>)')
.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;
Expand Down
68 changes: 68 additions & 0 deletions tests/core/pubspecTestRunner.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
26 changes: 25 additions & 1 deletion tests/core/starterProject.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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/<app>', 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
);
});
});
Loading