var shop = use('shop') var fd = use('fd') var time = use('time') var json = use('json') var utf8 = use('utf8') if (!args) args = [] var target_pkg = null // null = current, otherwise canonical path var all_pkgs = false // Actor test support def ACTOR_TEST_TIMEOUT = 30000 // 30 seconds timeout for actor tests var pending_actor_tests = [] var actor_test_results = [] if (args.length > 0) { if (args[0] == 'package') { if (args.length < 2) { log.console(`Usage: cell test package `) $stop() return } var name = args[1] var resolved = shop.resolve_alias(name, null) if (resolved) { target_pkg = resolved.pkg log.console(`Testing package: ${resolved.alias} (${resolved.pkg})`) } else { log.console(`Package not found: ${name}`) $stop() return } } else if (args[0] == 'all') { all_pkgs = true log.console(`Testing all packages...`) } else { log.console(`Usage: cell test [package | all]`) $stop() return } } function ensure_dir(path) { if (fd.is_dir(path)) return true var parts = path.split('/') var current = '' for (var i = 0; i < parts.length; i++) { if (parts[i] == '') continue current += `${parts[i]}/` if (!fd.is_dir(current)) { fd.mkdir(current) } } return true } // Collect .ce actor tests from a package function collect_actor_tests(pkg) { var prefix = pkg ? shop.get_shop_path() + '/modules/' + pkg : (shop.get_current_package() || '.') var tests_dir = prefix + '/tests' if (!fd.is_dir(tests_dir)) return [] var files = shop.list_files(pkg) var actor_tests = [] for (var i = 0; i < files.length; i++) { var f = files[i] // Check if file is in tests/ folder and is a .ce actor if (f.startsWith("tests/") && f.endsWith(".ce")) { actor_tests.push({ package: pkg || "local", file: f, path: pkg ? `${prefix}/${f}` : f }) } } return actor_tests } // Spawn an actor test and track it function spawn_actor_test(test_info) { var test_name = test_info.file.substring(6, test_info.file.length - 3) // remove "tests/" and ".ce" log.console(` [ACTOR] ${test_info.file}`) var entry = { package: test_info.package, file: test_info.file, test: test_name, status: "running", start_time: time.number(), actor: null } try { // Spawn the actor test - it should send back results // The actor receives $parent which it can use to send results var actor_path = test_info.path.substring(0, test_info.path.length - 3) // remove .ce entry.actor = $start(actor_path) pending_actor_tests.push(entry) } catch (e) { entry.status = "failed" entry.error = { message: `Failed to spawn actor: ${e}` } entry.duration_ns = 0 actor_test_results.push(entry) log.console(` FAIL ${test_name}: `) log.error(e) } } function run_tests(pkg) { var prefix = pkg ? shop.get_shop_path() + '/modules/' + pkg : (shop.get_current_package() || '.') var tests_dir = prefix + '/tests' var pkg_result = { package: pkg || "local", files: [], total: 0, passed: 0, failed: 0 } if (!fd.is_dir(tests_dir)) return pkg_result var files = shop.list_files(pkg) var test_files = [] for (var i = 0; i < files.length; i++) { var f = files[i] // Check if file is in tests/ folder and is a .cm module (not .ce - those are actor tests) if (f.startsWith("tests/") && f.endsWith(".cm")) { test_files.push(f) } } if (test_files.length > 0) { if (pkg) log.console(`Running tests for ${pkg}`) else log.console(`Running tests for local package`) } for (var i = 0; i < test_files.length; i++) { var f = test_files[i] var mod_path = f.substring(0, f.length - 3) // remove .cm var file_result = { name: f, tests: [], passed: 0, failed: 0 } try { var test_mod if (pkg) { test_mod = shop.use(mod_path, pkg) } else { test_mod = globalThis.use(mod_path) } var tests = [] if (typeof test_mod == 'function') { tests.push({name: 'main', fn: test_mod}) } else if (typeof test_mod == 'object') { for (var k in test_mod) { if (typeof test_mod[k] == 'function') { tests.push({name: k, fn: test_mod[k]}) } } } if (tests.length > 0) { log.console(` ${f}`) for (var j = 0; j < tests.length; j++) { var t = tests[j] var test_entry = { package: pkg_result.package, test: t.name, status: "pending", duration_ns: 0 } var start_time = time.number() try { var ret = t.fn() if (typeof ret == 'string') { throw new Error(ret) } else if (ret && (typeof ret.message == 'string' || ret instanceof Error)) { throw ret } test_entry.status = "passed" log.console(` PASS ${t.name}`) pkg_result.passed++ file_result.passed++ } catch (e) { test_entry.status = "failed" test_entry.error = { message: e.toString(), stack: e.stack || "" } if (e.name) test_entry.error.name = e.name // If it's an object but not a native Error, try to extract more info if (typeof e == 'object' && e.message) { test_entry.error.message = e.message } log.console(` FAIL ${t.name} ${test_entry.error.message}`) if (test_entry.error.stack) { log.console(` ${test_entry.error.stack.split('\n').join('\n ')}`) } pkg_result.failed++ file_result.failed++ } var end_time = time.number() test_entry.duration_ns = number.round((end_time - start_time) * 1000000000) file_result.tests.push(test_entry) pkg_result.total++ } } } catch (e) { log.console(` Error loading ${f}: ${e}`) // Treat load error as a file failure? // Or maybe add a dummy test entry for "load" var test_entry = { package: pkg_result.package, test: "load_module", status: "failed", duration_ns: 0, error: { message: `Error loading module: ${e}` } } file_result.tests.push(test_entry) pkg_result.failed++ file_result.failed++ pkg_result.total++ } pkg_result.files.push(file_result) } return pkg_result } var all_results = [] var all_actor_tests = [] if (all_pkgs) { // Run local first all_results.push(run_tests(null)) all_actor_tests = all_actor_tests.concat(collect_actor_tests(null)) // Then all dependencies var deps = shop.list_packages(null) for (var i = 0; i < deps.length; i++) { all_results.push(run_tests(deps[i])) all_actor_tests = all_actor_tests.concat(collect_actor_tests(deps[i])) } } else { all_results.push(run_tests(target_pkg)) all_actor_tests = all_actor_tests.concat(collect_actor_tests(target_pkg)) } // Spawn actor tests if any if (all_actor_tests.length > 0) { log.console(`Running ${all_actor_tests.length} actor test(s)...`) for (var i = 0; i < all_actor_tests.length; i++) { spawn_actor_test(all_actor_tests[i]) } } // Handle messages from actor tests // Actor tests should send either a single result `{ type: "test_result", ... }` // or an array / object containing multiple results (e.g. {results:[...]}) function handle_actor_message(msg) { // Find the pending test from this sender var sender = msg.$sender var found_idx = -1 for (var i = 0; i < pending_actor_tests.length; i++) { if (pending_actor_tests[i].actor == sender) { found_idx = i break } } if (found_idx == -1) return // Unknown sender var base_entry = pending_actor_tests[found_idx] pending_actor_tests.splice(found_idx, 1) var end_time = time.number() var duration_ns = number.round((end_time - base_entry.start_time) * 1000000000) // Normalize to an array of result objects var results = [] if (isa(msg, array)) { results = msg } else if (msg && isa(msg.results, array)) { results = msg.results } else { results = [msg] } for (var i = 0; i < results.length; i++) { var res = results[i] || {} var entry = { package: base_entry.package, file: base_entry.file, test: res.test || base_entry.test + (results.length > 1 ? `#${i+1}` : ""), status: "failed", duration_ns: duration_ns } if (res.type && res.type != "test_result") { entry.error = { message: `Unexpected message type: ${res.type}` } log.console(` FAIL ${entry.test}: unexpected message`) } else if (res.passed) { entry.status = "passed" log.console(` PASS ${entry.test}`) } else { entry.error = { message: res.error || "Test failed" } if (res.stack) entry.error.stack = res.stack log.console(` FAIL ${entry.test}: ${entry.error.message}`) } actor_test_results.push(entry) } check_completion() } // Check for timed out actor tests function check_timeouts() { var now = time.number() var timed_out = [] for (var i = pending_actor_tests.length - 1; i >= 0; i--) { var entry = pending_actor_tests[i] var elapsed_ms = (now - entry.start_time) * 1000 if (elapsed_ms > ACTOR_TEST_TIMEOUT) { timed_out.push(i) } } for (var i = 0; i < timed_out.length; i++) { var idx = timed_out[i] var entry = pending_actor_tests[idx] pending_actor_tests.splice(idx, 1) entry.status = "failed" entry.error = { message: "Test timed out" } entry.duration_ns = ACTOR_TEST_TIMEOUT * 1000000 actor_test_results.push(entry) log.console(` TIMEOUT ${entry.test}`) } if (pending_actor_tests.length > 0) { $delay(check_timeouts, 1000) } check_completion() } // Check if all tests are complete and finalize var finalized = false function check_completion() { if (finalized) return if (pending_actor_tests.length > 0) return finalized = true finalize_results() } function finalize_results() { // Add actor test results to all_results for (var i = 0; i < actor_test_results.length; i++) { var r = actor_test_results[i] // Find or create package result var pkg_result = null for (var j = 0; j < all_results.length; j++) { if (all_results[j].package == r.package) { pkg_result = all_results[j] break } } if (!pkg_result) { pkg_result = { package: r.package, files: [], total: 0, passed: 0, failed: 0 } all_results.push(pkg_result) } // Find or create file result var file_result = null for (var j = 0; j < pkg_result.files.length; j++) { if (pkg_result.files[j].name == r.file) { file_result = pkg_result.files[j] break } } if (!file_result) { file_result = { name: r.file, tests: [], passed: 0, failed: 0 } pkg_result.files.push(file_result) } file_result.tests.push(r) pkg_result.total++ if (r.status == "passed") { pkg_result.passed++ file_result.passed++ } else { pkg_result.failed++ file_result.failed++ } } // Calculate totals var totals = { total: 0, passed: 0, failed: 0 } for (var i = 0; i < all_results.length; i++) { totals.total += all_results[i].total totals.passed += all_results[i].passed totals.failed += all_results[i].failed } log.console(`----------------------------------------`) log.console(`Tests: ${totals.passed} passed, ${totals.failed} failed, ${totals.total} total`) generate_reports(totals) $stop() } // If no actor tests, finalize immediately if (all_actor_tests.length == 0) { // Calculate totals var totals = { total: 0, passed: 0, failed: 0 } for (var i = 0; i < all_results.length; i++) { totals.total += all_results[i].total totals.passed += all_results[i].passed totals.failed += all_results[i].failed } log.console(`----------------------------------------`) log.console(`Tests: ${totals.passed} passed, ${totals.failed} failed, ${totals.total} total`) } else { // Start timeout checker $delay(check_timeouts, 1000) } // Generate Reports function function generate_reports(totals) { var timestamp = number.floor(time.number()).toString() var report_dir = shop.get_reports_dir() + '/test_' + timestamp ensure_dir(report_dir) // Generate test.txt var txt_report = `TEST REPORT Date: ${time.text(time.number())} Total: ${totals.total}, Passed: ${totals.passed}, Failed: ${totals.failed} === SUMMARY === ` for (var i = 0; i < all_results.length; i++) { var pkg_res = all_results[i] if (pkg_res.total == 0) continue txt_report += `Package: ${pkg_res.package}\n` for (var j = 0; j < pkg_res.files.length; j++) { var f = pkg_res.files[j] var status = f.failed == 0 ? "PASS" : "FAIL" txt_report += ` [${status}] ${f.name} (${f.passed}/${f.tests.length})\n` } } txt_report += `\n=== FAILURES ===\n` var has_failures = false for (var i = 0; i < all_results.length; i++) { var pkg_res = all_results[i] for (var j = 0; j < pkg_res.files.length; j++) { var f = pkg_res.files[j] for (var k = 0; k < f.tests.length; k++) { var t = f.tests[k] if (t.status == "failed") { has_failures = true txt_report += `FAIL: ${pkg_res.package} :: ${f.name} :: ${t.test}\n` if (t.error) { txt_report += ` Message: ${t.error.message}\n` if (t.error.stack) { txt_report += ` Stack:\n${t.error.stack.split('\n').map(function(l){return ` ${l}`}).join('\n')}\n` } } txt_report += `\n` } } } } if (!has_failures) txt_report += `None\n` txt_report += `\n=== DETAILED RESULTS ===\n` for (var i = 0; i < all_results.length; i++) { var pkg_res = all_results[i] if (pkg_res.total == 0) continue for (var j = 0; j < pkg_res.files.length; j++) { var f = pkg_res.files[j] for (var k = 0; k < f.tests.length; k++) { var t = f.tests[k] var dur = `${t.duration_ns || 0}ns` var status = t.status == "passed" ? "PASS" : "FAIL" txt_report += `[${status}] ${pkg_res.package} ${t.test} (${dur})\n` } } } fd.slurpwrite(`${report_dir}/test.txt`, utf8.encode(txt_report)) log.console(`Report written to ${report_dir}/test.txt`) // Generate JSON per package for (var i = 0; i < all_results.length; i++) { var pkg_res = all_results[i] if (pkg_res.total == 0) continue var pkg_tests = [] for (var j = 0; j < pkg_res.files.length; j++) { var f = pkg_res.files[j] for (var k = 0; k < f.tests.length; k++) { pkg_tests.push(f.tests[k]) } } var json_path = `${report_dir}/${pkg_res.package.replace(/\//g, '_')}.json` fd.slurpwrite(json_path, utf8.encode(json.encode(pkg_tests))) } } // If no actor tests, generate reports and stop immediately if (all_actor_tests.length == 0) { generate_reports(totals) $stop() } else { // Set up portal to receive messages from actor tests $portal(function(msg) { handle_actor_message(msg) }) }