frida-test 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,138 @@
1
+ # frida-test Documentation
2
+
3
+ This is a small test framework which runs on the target. It is used to unit test Frida code running on actual devices. It was originaly developed to test the Frida agent code used in [frooky](https://github.com/cpholguera/frooky).
4
+
5
+ The following chapters explain how to write and run tests.
6
+
7
+ ## Installation
8
+
9
+ ```sh
10
+ npm install --save-dev frida-test
11
+ ```
12
+
13
+ ## Writing Tests
14
+
15
+ Tests follow the Behavior-Driven Development (BDD) pattern. They use the describe-it-expect structure to describe the expected behavior.
16
+
17
+ The basic syntax is:
18
+
19
+ - `describe()`: Defines a test suite or a specific component's behavior.
20
+ - `test()` or `it()`: Describes a specific requirement or expected outcome.
21
+ - `expect()`: Validates that the actual output matches the expected behavior.
22
+
23
+ ```typescript
24
+ describe('Classloader', () => {
25
+ it('should throw an exception if the class is not available.', () => {
26
+ expect(() => {
27
+ ClassLoader.loadSync('badClass')
28
+ }).toThrow(new Error("Class 'badClass' is not available."));
29
+ })
30
+ });
31
+ ```
32
+
33
+ Tests can be nested to any depth and can be synchronous or asynchronous.
34
+
35
+ ### Test discovery
36
+
37
+ The framework collects every file matching `*.test.ts` in the directories passed on the command line, recursively.
38
+
39
+ A good practice is to create test files next to the source code file as shown here:
40
+
41
+ ```text
42
+ myProject/
43
+ ├── android/
44
+ │ ├── classLoader.ts
45
+ │ └── classLoader.test.ts
46
+ ├── ios/
47
+ │ ├── objcRuntime.ts
48
+ │ └── objcRuntime.test.ts
49
+ └── shared/
50
+ ├── helper.ts
51
+ └── helper.test.ts
52
+ ```
53
+
54
+ ## Matchers
55
+
56
+ `expect(actualValue)` returns a `Matcher` which we can use to test for the expected value. Use the following functions to do that:
57
+
58
+ | Matcher | Description |
59
+ | --- | --- |
60
+ | `.toBe(value)` | Strict equality (`===`) |
61
+ | `.toEqual(value)` | Deep equality |
62
+ | `.toBeTruthy()` | Value is truthy |
63
+ | `.toBeFalsy()` | Value is falsy |
64
+ | `.toBeNull()` | Value is strictly `null` |
65
+ | `.toBeDefined()` | Value is not `undefined` |
66
+ | `.toBeUndefined()` | Value is `undefined` |
67
+ | `.toBeGreaterThan(value)` | Numeric value is greater than `value` |
68
+ | `.toBeLessThan(value)` | Numeric value is less than `value` |
69
+ | `.toContain(value)` | Value (array, string, etc.) contains `value` |
70
+ | `.toThrow(errorMatch)` | Exception thrown; optionally matches a string message or `Error` instance |
71
+ | `.toReject(errorMatch)` | Returned promise rejects; optionally matches a string message or `Error` instance (must be `await`ed) |
72
+ | `.toHaveBeenCalled()` | Spy target function was called at least once |
73
+ | `.toHaveBeenCalledWith(...expected)` | Spy target function was called with expected arguments |
74
+ | `.not.<matcher>` | Inverts the assertion result |
75
+
76
+ > [!NOTE]
77
+ > `frida-test` test itself. So for examples for all Matches and more, have a look a the `*.test.ts` located in the [test folder](./tests/)
78
+
79
+ ## Running Tests
80
+
81
+ `frida-test` takes one or more directories, collects every `*.test.ts` file below them, compiles them together with the framework agent, and runs the resulting agent on the target.
82
+
83
+ ```sh
84
+ frida-test [options] <dir...>
85
+ ```
86
+
87
+ ### Options
88
+
89
+ | Option | Description |
90
+ | --- | --- |
91
+ | `-i, --id <id>` | Bundle/package id or app name (spawns the app) |
92
+ | `-p, --pid <id>` | Attach to a running process instead |
93
+ | `-U, --usb` | Use the USB device |
94
+ | `-D, --device <id>` | Use a specific device by id |
95
+ | `-o, --out <path>` | Path of the output file for JSON reporter (default: disabled) |
96
+ | `-t, --timeout <s>` | Abort the run after this many seconds (default: `600`, `0` disables) |
97
+ | `-d, --delay <s>` | Start running the test suites after this many seconds (default: `0`) |
98
+ | `-k, --keep` | Keep the generated agent in `.frida-test/agent.js` |
99
+ | `-v, --verbose` | Enable verbose logging |
100
+ | `-h, --help` | Shows the help message |
101
+
102
+ ### `frida-test` Examples
103
+
104
+ ```sh
105
+ # Android app on a USB device, tests in ./tests/android and ./tests/shared
106
+ frida-test -i org.owasp.mastestapp -U ./tests/android ./tests/shared
107
+
108
+ # Attach to a running process by pid
109
+ frida-test --pid 4926 -u ./tests/android
110
+
111
+ # iOS app in the local simulator
112
+ frida-test -i org.owasp.mastestapp.MASTestApp-iOS ./tests/ios ./tests/shared
113
+ ```
114
+
115
+ ## Compile Agent
116
+
117
+ `frida-test` automatically bundles the test suites, and compiles them together with the testing framework into a Frida agent.
118
+
119
+ If you only want to compile this agent, use `frida-test-compiler`:
120
+
121
+ ```sh
122
+ frida-test-compiler [options] <dir...>
123
+ ```
124
+
125
+ | Option | Description |
126
+ | --- | --- |
127
+ | `-o, --out <path>` | Path of the output file for JSON reporter (default: disabled) |
128
+ | `-h, --help` | Shows the help message |
129
+
130
+ ### `frida-test-compiler` Examples
131
+
132
+ ```sh
133
+ # Collects all tests in ./tests, compiles the frida-test agent, and print it to stdout
134
+ frida-test-compiler ./tests
135
+
136
+ # Collects all tests in ./tests, compile the frida-test agent, and stores it in the path ./frida-test-agent.js
137
+ frida-test-compiler ./tests -o ./frida-test-agent.js
138
+ ```
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+
3
+ import("../dist/cli-frida-test-compiler.js").catch((err) => {
4
+ console.error("Failed to load the CLI module. Make sure the project is built.");
5
+ console.error(err);
6
+ process.exit(1);
7
+ });
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+
3
+ import("../dist/cli-frida-test.js").catch((err) => {
4
+ console.error("Failed to load the CLI module. Make sure the project is built.");
5
+ console.error(err);
6
+ process.exit(1);
7
+ });
@@ -0,0 +1,132 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { copyFile, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { logger } from "./logger.js";
7
+ const IMPORT_MARKER = "/// IMPORT TESTS SUITES ///";
8
+ const execFileAsync = promisify(execFile);
9
+ const AGENT_ENTRYPOINT_FILENAME = "agentRuntime.ts";
10
+ const AGENT_ENTRYPOINT_BASENAME = path.basename(AGENT_ENTRYPOINT_FILENAME, path.extname(AGENT_ENTRYPOINT_FILENAME));
11
+ const AGENT_BUNDLE_FILENAME = `${AGENT_ENTRYPOINT_BASENAME}.bundle.js`;
12
+ function resolveProjectRoot(startDir = process.cwd()) {
13
+ let dir = startDir;
14
+ while (true) {
15
+ if (existsSync(path.join(dir, "package.json")))
16
+ return dir;
17
+ const parent = path.dirname(dir);
18
+ if (parent === dir)
19
+ return startDir;
20
+ dir = parent;
21
+ }
22
+ }
23
+ function resolveAgentRuntimePath(projectRoot) {
24
+ return path.join(projectRoot, "src", "agent-runtime");
25
+ }
26
+ function resolveFridaCompileBin(projectRoot) {
27
+ const binName = process.platform === "win32" ? "frida-compile.cmd" : "frida-compile";
28
+ return path.join(projectRoot, "node_modules", ".bin", binName);
29
+ }
30
+ let cachedProjectRoot;
31
+ let cachedAgentRuntimeSrcDir;
32
+ let cachedFridaCompileBin;
33
+ function getProjectRoot() {
34
+ cachedProjectRoot ??= resolveProjectRoot();
35
+ return cachedProjectRoot;
36
+ }
37
+ function getAgentRuntimeSrcDir(projectRoot) {
38
+ if (!cachedAgentRuntimeSrcDir) {
39
+ const dir = resolveAgentRuntimePath(projectRoot);
40
+ if (!existsSync(dir)) {
41
+ throw new Error(`Agent runtime source directory not found at "${dir}".`);
42
+ }
43
+ cachedAgentRuntimeSrcDir = dir;
44
+ }
45
+ return cachedAgentRuntimeSrcDir;
46
+ }
47
+ function getFridaCompileBin(projectRoot) {
48
+ if (!cachedFridaCompileBin) {
49
+ const localBin = resolveFridaCompileBin(projectRoot);
50
+ cachedFridaCompileBin = existsSync(localBin) ? { path: localBin, useLocal: true } : { path: "npx", useLocal: false };
51
+ }
52
+ return cachedFridaCompileBin;
53
+ }
54
+ async function createWorkDir(projectRoot) {
55
+ const cacheRoot = path.join(projectRoot, ".frida-test-cache");
56
+ await mkdir(cacheRoot, { recursive: true });
57
+ const gitignorePath = path.join(cacheRoot, ".gitignore");
58
+ if (!existsSync(gitignorePath)) {
59
+ await writeFile(gitignorePath, "*\n", "utf8");
60
+ }
61
+ return mkdtemp(cacheRoot + path.sep);
62
+ }
63
+ async function deleteWorkDir(workDir) {
64
+ try {
65
+ return await rm(workDir, { recursive: true, force: true });
66
+ }
67
+ catch (err) {
68
+ logger.warn(`Failed to remove temporary work dir "${workDir}": ${err.message}`);
69
+ }
70
+ }
71
+ async function copyDirRecursive(srcDir, destDir) {
72
+ await mkdir(destDir, { recursive: true });
73
+ const entries = await readdir(srcDir, { withFileTypes: true });
74
+ await Promise.all(entries.map(async (entry) => {
75
+ const srcPath = path.join(srcDir, entry.name);
76
+ const destPath = path.join(destDir, entry.name);
77
+ if (entry.isDirectory()) {
78
+ await copyDirRecursive(srcPath, destPath);
79
+ return;
80
+ }
81
+ await copyFile(srcPath, destPath);
82
+ }));
83
+ }
84
+ export async function bundleAgent(testSuitePaths, keep = false) {
85
+ if (testSuitePaths.length === 0) {
86
+ throw new Error("bundleAgent requires at least one test suite path.");
87
+ }
88
+ const projectRoot = getProjectRoot();
89
+ const workDir = await createWorkDir(projectRoot);
90
+ try {
91
+ const agentRuntimeSrcDir = getAgentRuntimeSrcDir(projectRoot);
92
+ await copyDirRecursive(agentRuntimeSrcDir, workDir);
93
+ const entrypointPath = path.join(workDir, AGENT_ENTRYPOINT_FILENAME);
94
+ const agentSource = await readFile(entrypointPath, "utf8");
95
+ if (!agentSource.includes(IMPORT_MARKER)) {
96
+ throw new Error(`Marker "${IMPORT_MARKER}" not found in ${entrypointPath}`);
97
+ }
98
+ const importStatements = testSuitePaths
99
+ .map((suitePath) => {
100
+ const absolute = path.resolve(suitePath).replace(/\\/g, "/");
101
+ const specifier = absolute.replace(/\.tsx?$/, "");
102
+ return `import ${JSON.stringify(specifier)};`;
103
+ })
104
+ .join("\n");
105
+ await rm(entrypointPath, { force: true });
106
+ await writeFile(entrypointPath, agentSource.replace(IMPORT_MARKER, importStatements), "utf8");
107
+ const outfilePath = path.join(workDir, AGENT_BUNDLE_FILENAME);
108
+ const fridaCompile = getFridaCompileBin(projectRoot);
109
+ let stdout;
110
+ let stderr;
111
+ try {
112
+ const args = fridaCompile.useLocal ? [entrypointPath, "-o", outfilePath] : ["frida-compile", entrypointPath, "-o", outfilePath];
113
+ ({ stdout, stderr } = await execFileAsync(fridaCompile.path, args, {
114
+ cwd: projectRoot,
115
+ shell: process.platform === "win32",
116
+ }));
117
+ }
118
+ catch (err) {
119
+ throw new Error(`frida-compile failed for entrypoint "${entrypointPath}" with suites [${testSuitePaths.join(", ")}]: ${err.message}`, { cause: err });
120
+ }
121
+ if (stdout.trim())
122
+ logger.log(`[frida-compile] ${stdout.trim()}`);
123
+ if (stderr.trim())
124
+ logger.warn(`[frida-compile] ${stderr.trim()}`);
125
+ return await readFile(outfilePath, "utf8");
126
+ }
127
+ finally {
128
+ if (!keep) {
129
+ await deleteWorkDir(workDir);
130
+ }
131
+ }
132
+ }
@@ -0,0 +1,38 @@
1
+ const usage = `
2
+ Usage: frida-test-compiler [options] <src_path>...
3
+
4
+ Options:
5
+ -o, --out <path> Path of the output file for JSON reporter (default: disabled)
6
+ -h, --help Show this help message
7
+ `;
8
+ import { writeFile } from "node:fs/promises";
9
+ import { bundleAgent } from "./bundler.js";
10
+ import { collectTestSuitePaths } from "./collector.js";
11
+ const args = process.argv.slice(2);
12
+ let outPath;
13
+ const srcPaths = [];
14
+ for (let i = 0; i < args.length; i++) {
15
+ const arg = args[i];
16
+ if (arg === "-o" || arg === "--out") {
17
+ outPath = args[++i];
18
+ }
19
+ else if (arg === "-h" || arg === "--help") {
20
+ console.log(usage);
21
+ process.exit(0);
22
+ }
23
+ else {
24
+ srcPaths.push(arg);
25
+ }
26
+ }
27
+ if (srcPaths.length === 0) {
28
+ console.error(usage);
29
+ process.exit(1);
30
+ }
31
+ const testSuitePaths = await collectTestSuitePaths(srcPaths);
32
+ const bundle = await bundleAgent(testSuitePaths);
33
+ if (outPath) {
34
+ await writeFile(outPath, bundle, "utf8");
35
+ }
36
+ else {
37
+ console.log(bundle);
38
+ }
@@ -0,0 +1,133 @@
1
+ import chalk from "chalk";
2
+ import { setTimeout as sleep } from "node:timers/promises";
3
+ import { parseArgs } from "node:util";
4
+ import { bundleAgent } from "./bundler.js";
5
+ import { collectTestSuitePaths } from "./collector.js";
6
+ import { resolveDevice, resolveTarget } from "./device.js";
7
+ import { logger } from "./logger.js";
8
+ import { writeRunSummaryJson } from "./reporter/json.js";
9
+ import { printSummary } from "./reporter/summary.js";
10
+ import { TestRunner } from "./runner.js";
11
+ const usage = `
12
+ Usage: frida-test [options] <src_path>...
13
+
14
+ Options:
15
+ -i, --id <id> Bundle/package id or app name (spawns the app)
16
+ -p, --pid <id> Attach to a running process instead
17
+ -U, --usb Use the USB device
18
+ -D, --device <id> Use a specific device by id
19
+ -o, --out <path> Path of the output file for JSON reporter (default: disabled)
20
+ -t, --timeout <s> Abort the run after this many seconds (default: 600, 0 disables)
21
+ -d, --delay <s> Start running the test suites after this many seconds (default: 0)
22
+ -k, --keep Keep the generated agent in .frida-test/agent.js
23
+ -v, --verbose Enable verbose logging
24
+ -h, --help Show this help message
25
+
26
+ Examples:
27
+ frida-test -U -i org.owasp.mastestapp ./test -r json -o ./testing/reports/out.json
28
+ frida-test -U --pid 4926 ./src/hooks.test.ts ./lib/utils
29
+ `;
30
+ class CliError extends Error {
31
+ }
32
+ function fail(message) {
33
+ throw new CliError(message);
34
+ }
35
+ function parseNonNegativeInt(value, name) {
36
+ const n = Number(value);
37
+ if (!Number.isInteger(n) || n < 0) {
38
+ throw new Error(`${name} must be a non-negative integer`);
39
+ }
40
+ return n;
41
+ }
42
+ async function main() {
43
+ const { values, positionals } = parseArgs({
44
+ allowPositionals: true,
45
+ options: {
46
+ id: { type: "string", short: "i" },
47
+ pid: { type: "string", short: "p" },
48
+ usb: { type: "boolean", short: "U", default: false },
49
+ device: { type: "string", short: "D" },
50
+ out: { type: "string", short: "o" },
51
+ delay: { type: "string", short: "d", default: "0" },
52
+ timeout: { type: "string", short: "t", default: "600" },
53
+ keep: { type: "boolean", short: "k", default: false },
54
+ verbose: { type: "boolean", short: "v", default: false },
55
+ help: { type: "boolean", short: "h", default: false },
56
+ },
57
+ });
58
+ if (values.help) {
59
+ console.log(usage);
60
+ return;
61
+ }
62
+ logger.setVerbose(values.verbose);
63
+ if (positionals.length === 0) {
64
+ fail("Missing required argument <src_path>...");
65
+ }
66
+ if (Boolean(values.id) === Boolean(values.pid)) {
67
+ fail("Must provide either --id (-i) or --pid (-p), but not both");
68
+ }
69
+ let targetDef;
70
+ if (values.id) {
71
+ targetDef = { id: values.id };
72
+ }
73
+ else {
74
+ const parsedPid = parseNonNegativeInt(values.pid, "pid");
75
+ if (parsedPid === 0)
76
+ fail("Invalid pid: must be greater than 0");
77
+ targetDef = { pid: parsedPid };
78
+ }
79
+ const delay = parseNonNegativeInt(values.delay, "delay");
80
+ const timeout = parseNonNegativeInt(values.timeout, "timeout");
81
+ const deviceSelector = values.device !== undefined ? { id: values.device } : values.usb ? "usb" : "local";
82
+ logger.info(`Collecting tests from ${positionals.join(", ")}...`);
83
+ const testSuitePaths = await collectTestSuitePaths(positionals);
84
+ logger.info(`Bundling testSuites with frida-test agent...`);
85
+ const agentBundle = await bundleAgent(testSuitePaths, values.keep);
86
+ logger.info(`Resolving devices...`);
87
+ const device = await resolveDevice(deviceSelector);
88
+ logger.info(`Resolving target...`);
89
+ const target = await resolveTarget(device, targetDef);
90
+ const runner = new TestRunner(device, target, agentBundle, values.verbose);
91
+ try {
92
+ logger.info(`Starting frida-test agent on the remote device...`);
93
+ await runner.initialize();
94
+ if (delay > 0) {
95
+ logger.info(`Waiting ${delay} second(s) before starting tests...`);
96
+ await sleep(delay * 1000);
97
+ }
98
+ let timerId;
99
+ const timeoutPromise = new Promise((_, reject) => {
100
+ if (timeout > 0) {
101
+ timerId = globalThis.setTimeout(() => {
102
+ reject(new Error(`Test execution timed out after ${timeout} second(s)`));
103
+ }, timeout * 1000);
104
+ }
105
+ });
106
+ let runSummary;
107
+ try {
108
+ runSummary = await Promise.race([runner.runTests(), timeoutPromise]);
109
+ }
110
+ finally {
111
+ if (timerId)
112
+ globalThis.clearTimeout(timerId);
113
+ }
114
+ if (values.out) {
115
+ await writeRunSummaryJson(runSummary, values.out);
116
+ }
117
+ printSummary(runSummary);
118
+ }
119
+ finally {
120
+ await runner.dispose();
121
+ }
122
+ }
123
+ main().catch((error) => {
124
+ if (error instanceof CliError) {
125
+ logger.error(error.message);
126
+ console.log(chalk.dim(usage));
127
+ }
128
+ else {
129
+ const msg = error instanceof Error ? error.message : String(error);
130
+ logger.error(`Fatal error: ${msg}`);
131
+ }
132
+ process.exitCode = 1;
133
+ });
@@ -0,0 +1,27 @@
1
+ import { readdir, stat } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
+ const TEST_FILE_SUFFIX = ".test.ts";
4
+ async function findTestFiles(absolutePath) {
5
+ let fileInfo;
6
+ try {
7
+ fileInfo = await stat(absolutePath);
8
+ }
9
+ catch (err) {
10
+ throw new Error(`Path does not exist: ${absolutePath}`, { cause: err });
11
+ }
12
+ if (fileInfo.isDirectory()) {
13
+ const entries = await readdir(absolutePath, {
14
+ recursive: true,
15
+ withFileTypes: true,
16
+ });
17
+ return entries.filter((entry) => entry.isFile() && entry.name.endsWith(TEST_FILE_SUFFIX)).map((entry) => join(entry.parentPath, entry.name));
18
+ }
19
+ if (absolutePath.endsWith(TEST_FILE_SUFFIX)) {
20
+ return [absolutePath];
21
+ }
22
+ throw new Error(`Not a *${TEST_FILE_SUFFIX} file: ${absolutePath}`);
23
+ }
24
+ export async function collectTestSuitePaths(srcPaths) {
25
+ const results = await Promise.all(srcPaths.map((p) => findTestFiles(resolve(p))));
26
+ return [...new Set(results.flat())].sort();
27
+ }
package/dist/device.js ADDED
@@ -0,0 +1,38 @@
1
+ import frida from "frida";
2
+ export async function resolveDevice(selector = "local") {
3
+ if (selector === "usb")
4
+ return frida.getUsbDevice();
5
+ if (selector === "local")
6
+ return frida.getLocalDevice();
7
+ return frida.getDevice(selector.id);
8
+ }
9
+ export async function resolveTarget(device, targetDef) {
10
+ if ("pid" in targetDef)
11
+ return { pid: targetDef.pid, wasSpawned: false };
12
+ const { id } = targetDef;
13
+ if (!id.trim())
14
+ throw new Error("Target id must not be empty");
15
+ try {
16
+ const pid = await device.spawn(id);
17
+ return { pid, wasSpawned: true };
18
+ }
19
+ catch (spawnError) {
20
+ const needle = id.toLowerCase();
21
+ const processes = await device.enumerateProcesses();
22
+ const tiers = [
23
+ processes.filter((p) => p.name === id),
24
+ processes.filter((p) => p.name.toLowerCase() === needle),
25
+ processes.filter((p) => p.name.toLowerCase().includes(needle)),
26
+ ];
27
+ const matches = tiers.find((tier) => tier.length > 0) ?? [];
28
+ if (matches.length === 0) {
29
+ const reason = spawnError instanceof Error ? spawnError.message : String(spawnError);
30
+ throw new Error(`Unable to spawn '${id}' and no matching running process was found (spawn failed: ${reason}).`);
31
+ }
32
+ if (matches.length > 1) {
33
+ const names = matches.map((p) => `${p.name} (pid ${p.pid})`).join(", ");
34
+ throw new Error(`Ambiguous target '${id}', matches: ${names}`);
35
+ }
36
+ return { pid: matches[0].pid, wasSpawned: false };
37
+ }
38
+ }
package/dist/logger.js ADDED
@@ -0,0 +1,15 @@
1
+ import chalk from "chalk";
2
+ let verbose = false;
3
+ export const logger = {
4
+ setVerbose: (value) => {
5
+ verbose = value;
6
+ },
7
+ info: (msg) => {
8
+ if (verbose)
9
+ console.log(`${chalk.blue("[i]")} ${msg}`);
10
+ },
11
+ log: (msg) => console.log(`${chalk.blue("[i]")} ${msg}`),
12
+ warn: (msg) => console.log(`${chalk.yellow("[!]")} ${msg}`),
13
+ error: (msg) => console.error(`${chalk.red("[!]")} ${msg}`),
14
+ success: (msg) => console.log(`${chalk.green("[✓]")} ${msg}`),
15
+ };
@@ -0,0 +1,6 @@
1
+ export function isAgentMessage(value) {
2
+ if (typeof value !== "object" || value === null)
3
+ return false;
4
+ const { type } = value;
5
+ return type === "agent-ready" || type === "test-suite-started" || type === "test-suite-finished" || type === "run-finished";
6
+ }
@@ -0,0 +1,38 @@
1
+ import { logger } from "../logger.js";
2
+ import {} from "../protocol.js";
3
+ const STATUS_SYMBOLS = {
4
+ passed: "✅",
5
+ failed: "❌",
6
+ skipped: "➖",
7
+ };
8
+ function printTestResult(node, depth = 0) {
9
+ const indent = " ".repeat(depth);
10
+ const symbol = STATUS_SYMBOLS[node.status] || "?";
11
+ console.log(`${indent}${symbol} ${node.name} (${node.durationMs}ms)`);
12
+ if (node.error) {
13
+ const errorIndent = " ".repeat(depth + 1);
14
+ console.error(`${errorIndent}Error: ${node.error.message}`);
15
+ if (node.error.stack) {
16
+ console.error(`${errorIndent}${node.error.stack.replace(/\n/g, `\n${errorIndent}`)}`);
17
+ }
18
+ }
19
+ if (node.children && node.children.length > 0) {
20
+ for (const child of node.children) {
21
+ printTestResult(child, depth + 1);
22
+ }
23
+ }
24
+ }
25
+ export function printTestSuiteResult(suite) {
26
+ if (suite.status == "failed") {
27
+ logger.warn(`Test suite "${suite.name}" failed:`);
28
+ }
29
+ else if (suite.status == "passed") {
30
+ logger.success(`Test suite "${suite.name}" passed:`);
31
+ }
32
+ if (suite.testResult) {
33
+ printTestResult(suite.testResult, 1);
34
+ }
35
+ else {
36
+ logger.warn(`No results for test suite "${suite.name}".`);
37
+ }
38
+ }
@@ -0,0 +1,6 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import { logger } from "../logger.js";
3
+ export async function writeRunSummaryJson(result, outPath) {
4
+ await writeFile(outPath, JSON.stringify(result, null, 2), "utf-8");
5
+ logger.info(`JSON report saved to ${outPath}`);
6
+ }
@@ -0,0 +1,41 @@
1
+ import chalk from "chalk";
2
+ function formatDuration(ms) {
3
+ return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(2)}s`;
4
+ }
5
+ function formatCount(passed, failed, total) {
6
+ const parts = [];
7
+ if (passed > 0)
8
+ parts.push(chalk.green(`${passed} passed`));
9
+ if (failed > 0)
10
+ parts.push(chalk.red(`${failed} failed`));
11
+ parts.push(`${total} total`);
12
+ return parts.join(", ");
13
+ }
14
+ export function printSummary(runSummary) {
15
+ const { total, passed, failed, durationMs, testSuitesResults } = runSummary;
16
+ const suitePassed = testSuitesResults.filter((s) => s.status === "passed").length;
17
+ const suiteFailed = testSuitesResults.length - suitePassed;
18
+ console.log();
19
+ console.log("------------------------------------------------------------------------------");
20
+ console.log();
21
+ console.log(chalk.bold("Test Suites"));
22
+ for (const suite of testSuitesResults) {
23
+ const isPassed = suite.status === "passed";
24
+ console.log(` ${isPassed ? chalk.green("✓") : chalk.red("✗")} ${suite.name}`);
25
+ if (!isPassed && suite.testResult?.name) {
26
+ for (const line of suite.testResult.name.split("\n")) {
27
+ console.log(chalk.red(` ${line}`));
28
+ }
29
+ }
30
+ }
31
+ console.log();
32
+ console.log(chalk.bold("Summary"));
33
+ console.log(` Suites: ${formatCount(suitePassed, suiteFailed, testSuitesResults.length)}`);
34
+ console.log(` Tests: ${formatCount(passed, failed, total)}`);
35
+ console.log(` Duration: ${formatDuration(durationMs)}`);
36
+ console.log();
37
+ const badge = failed > 0 ? chalk.bgRed.black.bold(" FAIL ") : chalk.bgGreen.black.bold(" PASS ");
38
+ const tail = failed > 0 ? chalk.red(`${failed} test(s) failed`) : chalk.green("All tests passed");
39
+ console.log(`${badge} ${tail}`);
40
+ console.log();
41
+ }