@midscene/test 1.12.5 → 1.12.6-beta-20260909034232.0

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.
Files changed (40) hide show
  1. package/dist/es/1~891.mjs +1 -3
  2. package/dist/es/1~891.mjs.map +1 -1
  3. package/dist/es/android/index.mjs +71 -4
  4. package/dist/es/android/index.mjs.map +1 -1
  5. package/dist/es/cli/index.mjs +499 -25
  6. package/dist/es/cli/index.mjs.map +1 -1
  7. package/dist/es/harmony/index.mjs +71 -4
  8. package/dist/es/harmony/index.mjs.map +1 -1
  9. package/dist/es/index.mjs +478 -25
  10. package/dist/es/index.mjs.map +1 -1
  11. package/dist/es/ios/index.mjs +71 -4
  12. package/dist/es/ios/index.mjs.map +1 -1
  13. package/dist/es/midscene/index.mjs +71 -4
  14. package/dist/es/midscene/index.mjs.map +1 -1
  15. package/dist/es/test-cli.mjs +500 -25
  16. package/dist/es/test-cli.mjs.map +1 -1
  17. package/dist/lib/0~458.js +1 -3
  18. package/dist/lib/0~458.js.map +1 -1
  19. package/dist/lib/android/index.js +72 -4
  20. package/dist/lib/android/index.js.map +1 -1
  21. package/dist/lib/cli/index.js +499 -25
  22. package/dist/lib/cli/index.js.map +1 -1
  23. package/dist/lib/harmony/index.js +72 -4
  24. package/dist/lib/harmony/index.js.map +1 -1
  25. package/dist/lib/index.js +494 -26
  26. package/dist/lib/index.js.map +1 -1
  27. package/dist/lib/ios/index.js +72 -4
  28. package/dist/lib/ios/index.js.map +1 -1
  29. package/dist/lib/midscene/index.js +72 -4
  30. package/dist/lib/midscene/index.js.map +1 -1
  31. package/dist/lib/test-cli.js +500 -25
  32. package/dist/lib/test-cli.js.map +1 -1
  33. package/dist/types/android/index.d.ts +13 -0
  34. package/dist/types/cli/index.d.ts +25 -1
  35. package/dist/types/harmony/index.d.ts +13 -0
  36. package/dist/types/index.d.ts +91 -0
  37. package/dist/types/ios/index.d.ts +13 -0
  38. package/dist/types/midscene/index.d.ts +20 -1
  39. package/dist/types/playwright/index.d.ts +13 -0
  40. package/package.json +11 -10
@@ -6,8 +6,10 @@ import { z } from "zod/v4";
6
6
  import { createHash, randomUUID } from "node:crypto";
7
7
  import { setMaxListeners } from "node:events";
8
8
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
9
+ import { TestRunReportAssembler } from "@midscene/core/report";
9
10
  import { globSync } from "tinyglobby";
10
11
  import { JSON_SCHEMA, load } from "js-yaml";
12
+ import { getDebug } from "@midscene/shared/logger";
11
13
  function _define_property(obj, key, value) {
12
14
  if (key in obj) Object.defineProperty(obj, key, {
13
15
  value: value,
@@ -779,6 +781,30 @@ async function executeNode(step, execute, phase, stepIndex, options) {
779
781
  parentSignal?.removeEventListener('abort', abortFromParent);
780
782
  }
781
783
  }
784
+ function createNodeReportCollector(node) {
785
+ const traces = new Map();
786
+ let accepting = true;
787
+ return {
788
+ collector: Object.freeze({
789
+ addTrace (trace) {
790
+ if (!accepting) throw new NodeExecutionError(node, new Error('Report traces can only be added while the current Step is running.'));
791
+ if (!trace || 'object' != typeof trace || 'midscene-execution' !== trace.type) throw new NodeExecutionError(node, new TypeError('Report trace type must be "midscene-execution".'));
792
+ if ('string' != typeof trace.executionId || 0 === trace.executionId.trim().length) throw new NodeExecutionError(node, new TypeError('Report trace executionId must be non-empty.'));
793
+ const normalized = Object.freeze({
794
+ type: 'midscene-execution',
795
+ executionId: trace.executionId
796
+ });
797
+ traces.set(`${normalized.type}:${normalized.executionId}`, normalized);
798
+ }
799
+ }),
800
+ close () {
801
+ accepting = false;
802
+ return Object.freeze([
803
+ ...traces.values()
804
+ ]);
805
+ }
806
+ };
807
+ }
782
808
  async function parseNodeInput(node, input) {
783
809
  if (!node.inputSchema) return input;
784
810
  const parsed = await node.inputSchema.safeParseAsync(input);
@@ -788,32 +814,47 @@ async function parseNodeInput(node, input) {
788
814
  async function executeStep(step, node, target, context, execution = {}) {
789
815
  const phase = 'case' === target.scope ? target.case.phase : target.document.phase;
790
816
  const stepIndex = 'case' === target.scope ? target.case.stepIndex : target.document.stepIndex;
791
- return executeNode(step, async (signal)=>{
792
- const input = await parseNodeInput(node, step.input);
793
- signal.throwIfAborted();
794
- const common = {
795
- input,
796
- $: step.meta,
797
- signal,
798
- context,
799
- onTeardown: (teardown)=>{
800
- if (!execution.onTeardown) throw new NodeExecutionError(step.node, new Error('The current execution scope cannot register teardown.'));
801
- execution.onTeardown(step.node, teardown);
817
+ const reportCollector = createNodeReportCollector(step.node);
818
+ let result;
819
+ try {
820
+ result = await executeNode(step, async (signal)=>{
821
+ const input = await parseNodeInput(node, step.input);
822
+ signal.throwIfAborted();
823
+ const common = {
824
+ input,
825
+ $: step.meta,
826
+ signal,
827
+ context,
828
+ report: reportCollector.collector,
829
+ onTeardown: (teardown)=>{
830
+ if (!execution.onTeardown) throw new NodeExecutionError(step.node, new Error('The current execution scope cannot register teardown.'));
831
+ execution.onTeardown(step.node, teardown);
832
+ }
833
+ };
834
+ return 'case' === target.scope ? node.execute({
835
+ ...common,
836
+ scope: 'case',
837
+ case: target.case
838
+ }) : node.execute({
839
+ ...common,
840
+ scope: 'document',
841
+ document: target.document
842
+ });
843
+ }, phase, stepIndex, {
844
+ parentSignal: execution.signal,
845
+ defaultTimeoutMs: execution.defaultTimeoutMs
846
+ });
847
+ } finally{
848
+ const traces = reportCollector.close();
849
+ if (traces.length > 0 && result) result = {
850
+ ...result,
851
+ report: {
852
+ traces
802
853
  }
803
854
  };
804
- return 'case' === target.scope ? node.execute({
805
- ...common,
806
- scope: 'case',
807
- case: target.case
808
- }) : node.execute({
809
- ...common,
810
- scope: 'document',
811
- document: target.document
812
- });
813
- }, phase, stepIndex, {
814
- parentSignal: execution.signal,
815
- defaultTimeoutMs: execution.defaultTimeoutMs
816
- });
855
+ }
856
+ if (!result) throw new NodeExecutionError(step.node, new Error('Step execution completed without a result.'));
857
+ return result;
817
858
  }
818
859
  const reportPathsFromTeardown = (result)=>{
819
860
  if (void 0 === result) return [];
@@ -1448,6 +1489,419 @@ function collectWorkflowDocument(source, options) {
1448
1489
  cases
1449
1490
  };
1450
1491
  }
1492
+ const MAX_DEPTH = 6;
1493
+ const MAX_STRING_LENGTH = 2000;
1494
+ const MAX_ARRAY_ITEMS = 50;
1495
+ const MAX_OBJECT_KEYS = 50;
1496
+ const MAX_SERIALIZED_BYTES = 65536;
1497
+ const TRUNCATED_VALUE = '[Truncated: display value exceeded 64 KiB]';
1498
+ const normalizedSecretKeys = [
1499
+ 'password',
1500
+ 'passwd',
1501
+ 'secret',
1502
+ 'token',
1503
+ 'authorization',
1504
+ 'cookie',
1505
+ 'apikey'
1506
+ ];
1507
+ const normalizedKey = (key)=>key.toLowerCase().replace(/[^a-z0-9]/g, '');
1508
+ const isSecretKey = (key)=>{
1509
+ const normalized = normalizedKey(key);
1510
+ return normalizedSecretKeys.some((secret)=>normalized.includes(secret));
1511
+ };
1512
+ const objectPath = (parent, key)=>/^[A-Za-z_$][\w$]*$/.test(key) ? `${parent}.${key}` : `${parent}[${JSON.stringify(key)}]`;
1513
+ const stringByteLength = (value)=>Buffer.byteLength(value, 'utf8');
1514
+ function sanitizeReportText(value, maximumLength = MAX_STRING_LENGTH) {
1515
+ return value.length <= maximumLength ? value : `${value.slice(0, maximumLength)}… [truncated]`;
1516
+ }
1517
+ function sanitizeReportValue(value) {
1518
+ const truncatedPaths = new Set();
1519
+ const redactedPaths = new Set();
1520
+ const ancestors = new WeakSet();
1521
+ let remainingBytes = MAX_SERIALIZED_BYTES;
1522
+ const consume = (path, text)=>{
1523
+ remainingBytes -= stringByteLength(text);
1524
+ if (remainingBytes >= 0) return true;
1525
+ truncatedPaths.add(path);
1526
+ return false;
1527
+ };
1528
+ const visit = (current, path, depth)=>{
1529
+ if (remainingBytes <= 0) {
1530
+ truncatedPaths.add(path);
1531
+ return '[Truncated: size limit]';
1532
+ }
1533
+ if (null === current || 'boolean' == typeof current) {
1534
+ consume(path, String(current));
1535
+ return current;
1536
+ }
1537
+ if ('string' == typeof current) {
1538
+ if (current.length > MAX_STRING_LENGTH) {
1539
+ truncatedPaths.add(path);
1540
+ const shortened = `${current.slice(0, MAX_STRING_LENGTH)}… [truncated]`;
1541
+ consume(path, shortened);
1542
+ return shortened;
1543
+ }
1544
+ consume(path, current);
1545
+ return current;
1546
+ }
1547
+ if ('number' == typeof current) {
1548
+ if (Number.isFinite(current)) {
1549
+ consume(path, String(current));
1550
+ return current;
1551
+ }
1552
+ truncatedPaths.add(path);
1553
+ return `[Unsupported number: ${String(current)}]`;
1554
+ }
1555
+ if ('bigint' == typeof current) {
1556
+ truncatedPaths.add(path);
1557
+ return `[Unsupported bigint: ${String(current)}]`;
1558
+ }
1559
+ if (void 0 === current || 'function' == typeof current || 'symbol' == typeof current) {
1560
+ truncatedPaths.add(path);
1561
+ return `[Unsupported ${typeof current}]`;
1562
+ }
1563
+ if (depth >= MAX_DEPTH) {
1564
+ truncatedPaths.add(path);
1565
+ return '[Truncated: maximum depth]';
1566
+ }
1567
+ if (current instanceof Date) {
1568
+ const date = Number.isNaN(current.getTime()) ? '[Invalid Date]' : current.toISOString();
1569
+ consume(path, date);
1570
+ return date;
1571
+ }
1572
+ if (ancestors.has(current)) {
1573
+ truncatedPaths.add(path);
1574
+ return '[Unsupported circular value]';
1575
+ }
1576
+ if (Array.isArray(current)) {
1577
+ ancestors.add(current);
1578
+ const output = [];
1579
+ const itemCount = Math.min(current.length, MAX_ARRAY_ITEMS);
1580
+ for(let index = 0; index < itemCount; index += 1){
1581
+ if (remainingBytes <= 0) break;
1582
+ output.push(visit(current[index], `${path}[${index}]`, depth + 1));
1583
+ }
1584
+ if (current.length > output.length) truncatedPaths.add(path);
1585
+ ancestors.delete(current);
1586
+ return output;
1587
+ }
1588
+ const prototype = Object.getPrototypeOf(current);
1589
+ if (prototype !== Object.prototype && null !== prototype) {
1590
+ truncatedPaths.add(path);
1591
+ const constructorName = current.constructor?.name ?? 'object';
1592
+ return `[Unsupported object: ${constructorName}]`;
1593
+ }
1594
+ ancestors.add(current);
1595
+ const output = {};
1596
+ const keys = Object.keys(current);
1597
+ for (const key of keys.slice(0, MAX_OBJECT_KEYS)){
1598
+ const childPath = objectPath(path, key);
1599
+ if (!consume(childPath, key)) break;
1600
+ if (isSecretKey(key)) {
1601
+ output[key] = '[REDACTED]';
1602
+ redactedPaths.add(childPath);
1603
+ } else output[key] = visit(current[key], childPath, depth + 1);
1604
+ if (remainingBytes <= 0) break;
1605
+ }
1606
+ if (keys.length > Object.keys(output).length) truncatedPaths.add(path);
1607
+ ancestors.delete(current);
1608
+ return output;
1609
+ };
1610
+ const sanitized = visit(value, '$', 0);
1611
+ const serialized = JSON.stringify(sanitized);
1612
+ if (void 0 === serialized || stringByteLength(serialized) > MAX_SERIALIZED_BYTES) return {
1613
+ value: TRUNCATED_VALUE,
1614
+ truncatedPaths: [
1615
+ '$'
1616
+ ]
1617
+ };
1618
+ return {
1619
+ value: sanitized,
1620
+ ...truncatedPaths.size > 0 ? {
1621
+ truncatedPaths: [
1622
+ ...truncatedPaths
1623
+ ]
1624
+ } : {},
1625
+ ...redactedPaths.size > 0 ? {
1626
+ redactedPaths: [
1627
+ ...redactedPaths
1628
+ ]
1629
+ } : {}
1630
+ };
1631
+ }
1632
+ function sanitizeReportError(error) {
1633
+ if (error instanceof Error) {
1634
+ const workflowLike = error;
1635
+ return {
1636
+ name: sanitizeReportText(error.name || 'Error', 200),
1637
+ message: sanitizeReportText(error.message || String(error)),
1638
+ ...'string' == typeof workflowLike.code ? {
1639
+ code: sanitizeReportText(workflowLike.code, 200)
1640
+ } : {},
1641
+ ...void 0 === workflowLike.details ? {} : {
1642
+ details: sanitizeReportValue(workflowLike.details)
1643
+ }
1644
+ };
1645
+ }
1646
+ return {
1647
+ name: 'Error',
1648
+ message: sanitizeReportText(String(error))
1649
+ };
1650
+ }
1651
+ const warnReport = getDebug('test-runner:report-assembler', {
1652
+ console: true
1653
+ });
1654
+ const exactMidsceneDetailNodes = new Set([
1655
+ 'aiAct',
1656
+ 'aiAssert',
1657
+ 'recordToReport'
1658
+ ]);
1659
+ const scopedExecutionKey = (scopeId, executionId)=>`${scopeId}\u0000${executionId}`;
1660
+ const reportSourcesForScope = (context, scopeId)=>context.sourcesByScope.get(scopeId) ?? [];
1661
+ const addDiagnostic = (context, diagnostic)=>{
1662
+ context.diagnostics.push(diagnostic);
1663
+ const key = JSON.stringify([
1664
+ diagnostic.code,
1665
+ diagnostic.scopeId,
1666
+ diagnostic.executionId
1667
+ ]);
1668
+ if (!context.warnedDiagnostics.has(key)) {
1669
+ context.warnedDiagnostics.add(key);
1670
+ warnReport(diagnostic.message);
1671
+ }
1672
+ };
1673
+ const buildStep = (step, scopeId, context)=>{
1674
+ const details = [];
1675
+ const unresolvedExecutionIds = [];
1676
+ for (const trace of step.report?.traces ?? []){
1677
+ const previousScope = context.tracedScopeByExecution.get(trace.executionId);
1678
+ if (previousScope && previousScope !== scopeId) throw new Error(`Agent execution ${trace.executionId} was traced by multiple test scopes (${previousScope}, ${scopeId}).`);
1679
+ context.tracedScopeByExecution.set(trace.executionId, scopeId);
1680
+ const detail = context.detailsByScopeAndExecution.get(scopedExecutionKey(scopeId, trace.executionId));
1681
+ if (detail) details.push(detail);
1682
+ else unresolvedExecutionIds.push(trace.executionId);
1683
+ }
1684
+ let agentDetailDiagnostic;
1685
+ if (unresolvedExecutionIds.length > 0) {
1686
+ agentDetailDiagnostic = `Agent detail could not be resolved for ${unresolvedExecutionIds.join(', ')}.`;
1687
+ const sources = reportSourcesForScope(context, scopeId);
1688
+ for (const executionId of unresolvedExecutionIds)addDiagnostic(context, {
1689
+ level: 'warning',
1690
+ code: 'agent-detail-unresolved',
1691
+ message: `Agent detail could not be resolved for execution ${executionId} in test scope ${scopeId}.`,
1692
+ scopeId,
1693
+ executionId,
1694
+ ...sources.length > 0 ? {
1695
+ sourcePaths: sources.map((source)=>source.sourcePath)
1696
+ } : {}
1697
+ });
1698
+ } else if (0 === details.length && exactMidsceneDetailNodes.has(step.node)) {
1699
+ agentDetailDiagnostic = 'This Agent did not expose a stable execution reference for the Step.';
1700
+ addDiagnostic(context, {
1701
+ level: 'warning',
1702
+ code: 'agent-detail-unavailable',
1703
+ message: `No stable Agent execution reference was captured for ${step.node} in test scope ${scopeId}.`,
1704
+ scopeId,
1705
+ ...reportSourcesForScope(context, scopeId).length > 0 ? {
1706
+ sourcePaths: reportSourcesForScope(context, scopeId).map((source)=>source.sourcePath)
1707
+ } : {}
1708
+ });
1709
+ }
1710
+ const prompt = 'string' == typeof step.input?.prompt ? step.input.prompt : void 0;
1711
+ return {
1712
+ id: `${scopeId}:${step.phase}:${step.stepIndex}`,
1713
+ phase: step.phase,
1714
+ stepIndex: step.stepIndex,
1715
+ node: step.node,
1716
+ ...prompt ? {
1717
+ title: sanitizeReportText(prompt, 240)
1718
+ } : {},
1719
+ status: step.status,
1720
+ continuedAfterError: step.continuedAfterError,
1721
+ startedAt: step.startedAt,
1722
+ endedAt: step.endedAt,
1723
+ durationMs: step.durationMs,
1724
+ input: sanitizeReportValue(step.input),
1725
+ ...step.output ? {
1726
+ output: {
1727
+ ...void 0 === step.output.summary ? {} : {
1728
+ summary: sanitizeReportText(step.output.summary)
1729
+ },
1730
+ ...void 0 === step.output.data ? {} : {
1731
+ data: sanitizeReportValue(step.output.data)
1732
+ }
1733
+ }
1734
+ } : {},
1735
+ ...step.error ? {
1736
+ error: sanitizeReportError(step.error)
1737
+ } : {},
1738
+ ...details.length > 0 ? {
1739
+ agentDetails: details
1740
+ } : {},
1741
+ ...agentDetailDiagnostic ? {
1742
+ agentDetailDiagnostic
1743
+ } : {}
1744
+ };
1745
+ };
1746
+ const buildAttempt = (attempt, context)=>({
1747
+ attemptId: attempt.runId,
1748
+ attemptIndex: attempt.attemptIndex,
1749
+ status: attempt.status,
1750
+ startedAt: attempt.startedAt,
1751
+ endedAt: attempt.endedAt,
1752
+ durationMs: attempt.durationMs,
1753
+ beforeEach: attempt.beforeEach.map((step)=>buildStep(step, attempt.runId, context)),
1754
+ steps: attempt.steps.map((step)=>buildStep(step, attempt.runId, context)),
1755
+ afterEach: attempt.afterEach.map((step)=>buildStep(step, attempt.runId, context)),
1756
+ ...attempt.teardownErrors?.length ? {
1757
+ teardownErrors: attempt.teardownErrors.map(sanitizeReportError)
1758
+ } : {},
1759
+ ...reportSourcesForScope(context, attempt.runId).length > 0 ? {
1760
+ scopeReportIds: reportSourcesForScope(context, attempt.runId).map((source)=>source.reportId)
1761
+ } : {}
1762
+ });
1763
+ const buildCase = (outcome, context)=>({
1764
+ caseId: outcome.caseId,
1765
+ name: outcome.name,
1766
+ caseIndex: outcome.caseIndex,
1767
+ status: outcome.status,
1768
+ ...outcome.notRunReason ? {
1769
+ notRunReason: outcome.notRunReason
1770
+ } : {},
1771
+ attempts: (outcome.attempts ?? (outcome.run ? [
1772
+ outcome.run
1773
+ ] : [])).map((attempt)=>buildAttempt(attempt, context))
1774
+ });
1775
+ const buildDocument = (documentId, document, cases, context)=>{
1776
+ const sourcePath = document?.sourcePath ?? cases[0]?.sourcePath ?? '<unknown>';
1777
+ const status = document?.status ?? (cases.some((item)=>'success' !== item.status) ? 'failed' : 'success');
1778
+ return {
1779
+ documentId,
1780
+ sourcePath,
1781
+ status,
1782
+ ...document ? {
1783
+ startedAt: document.startedAt,
1784
+ endedAt: document.endedAt,
1785
+ durationMs: document.durationMs
1786
+ } : {},
1787
+ beforeAll: (document?.beforeAll ?? []).map((step)=>buildStep(step, document.documentRunId, context)),
1788
+ cases: cases.map((outcome)=>buildCase(outcome, context)),
1789
+ afterAll: (document?.afterAll ?? []).map((step)=>buildStep(step, document.documentRunId, context)),
1790
+ ...document?.teardownErrors?.length ? {
1791
+ teardownErrors: document.teardownErrors.map(sanitizeReportError)
1792
+ } : {},
1793
+ ...document && reportSourcesForScope(context, document.documentRunId).length > 0 ? {
1794
+ scopeReportIds: reportSourcesForScope(context, document.documentRunId).map((source)=>source.reportId)
1795
+ } : {}
1796
+ };
1797
+ };
1798
+ const buildProject = (project, context)=>{
1799
+ const documentById = new Map(project.documents.map((document)=>[
1800
+ document.documentId,
1801
+ document
1802
+ ]));
1803
+ const casesByDocumentId = new Map();
1804
+ for (const outcome of project.cases){
1805
+ const current = casesByDocumentId.get(outcome.documentId) ?? [];
1806
+ current.push(outcome);
1807
+ casesByDocumentId.set(outcome.documentId, current);
1808
+ }
1809
+ const documentIds = new Set([
1810
+ ...project.documents.map((document)=>document.documentId),
1811
+ ...project.cases.map((outcome)=>outcome.documentId)
1812
+ ]);
1813
+ return {
1814
+ projectId: project.projectId,
1815
+ name: project.name,
1816
+ platform: project.platform,
1817
+ status: project.status,
1818
+ retry: project.retry,
1819
+ ...project.lifecycle ? {
1820
+ lifecycle: {
1821
+ status: project.lifecycle.status,
1822
+ startedAt: project.lifecycle.startedAt,
1823
+ endedAt: project.lifecycle.endedAt,
1824
+ durationMs: project.lifecycle.durationMs,
1825
+ ...project.lifecycle.setupError ? {
1826
+ setupError: sanitizeReportError(project.lifecycle.setupError)
1827
+ } : {},
1828
+ ...project.lifecycle.teardownErrors?.length ? {
1829
+ teardownErrors: project.lifecycle.teardownErrors.map(sanitizeReportError)
1830
+ } : {}
1831
+ }
1832
+ } : {},
1833
+ documents: [
1834
+ ...documentIds
1835
+ ].map((documentId)=>buildDocument(documentId, documentById.get(documentId), casesByDocumentId.get(documentId) ?? [], context)),
1836
+ collectionErrors: project.collectionErrors.map((collectionError)=>({
1837
+ sourcePath: collectionError.sourcePath,
1838
+ error: sanitizeReportError(collectionError.error)
1839
+ }))
1840
+ };
1841
+ };
1842
+ function collectTestRunReportSources(result) {
1843
+ const sources = [];
1844
+ for (const project of result.projects){
1845
+ for (const document of project.documents)for (const sourcePath of document.reportPaths ?? [])sources.push({
1846
+ scopeId: document.documentRunId,
1847
+ sourcePath
1848
+ });
1849
+ for (const outcome of project.cases)for (const attempt of outcome.attempts ?? (outcome.run ? [
1850
+ outcome.run
1851
+ ] : []))for (const sourcePath of attempt.reportPaths ?? [])sources.push({
1852
+ scopeId: attempt.runId,
1853
+ sourcePath
1854
+ });
1855
+ }
1856
+ return sources;
1857
+ }
1858
+ function buildTestRunReportDump(result, index) {
1859
+ const sourcesByScope = new Map();
1860
+ const detailsByScopeAndExecution = new Map();
1861
+ for (const source of index.sources){
1862
+ const scopeSources = sourcesByScope.get(source.scopeId) ?? [];
1863
+ scopeSources.push(source);
1864
+ sourcesByScope.set(source.scopeId, scopeSources);
1865
+ for (const executionId of source.executionIds)detailsByScopeAndExecution.set(scopedExecutionKey(source.scopeId, executionId), {
1866
+ reportId: source.reportId,
1867
+ executionId
1868
+ });
1869
+ }
1870
+ const context = {
1871
+ diagnostics: [],
1872
+ sourcesByScope,
1873
+ detailsByScopeAndExecution,
1874
+ tracedScopeByExecution: new Map(),
1875
+ warnedDiagnostics: new Set()
1876
+ };
1877
+ for (const source of index.sources)if (0 === source.executionIds.length) addDiagnostic(context, {
1878
+ level: 'warning',
1879
+ code: 'source-without-executions',
1880
+ message: `Agent report contains no executions: ${source.sourcePath}`,
1881
+ scopeId: source.scopeId,
1882
+ sourcePaths: [
1883
+ source.sourcePath
1884
+ ]
1885
+ });
1886
+ const projects = result.projects.map((project)=>buildProject(project, context));
1887
+ return {
1888
+ schemaVersion: 1,
1889
+ kind: 'test-runner',
1890
+ runId: result.runId,
1891
+ status: result.status,
1892
+ startedAt: result.startedAt,
1893
+ endedAt: result.endedAt,
1894
+ durationMs: result.durationMs,
1895
+ summary: {
1896
+ ...result.summary
1897
+ },
1898
+ metrics: index.metrics,
1899
+ projects,
1900
+ ...context.diagnostics.length > 0 ? {
1901
+ diagnostics: context.diagnostics
1902
+ } : {}
1903
+ };
1904
+ }
1451
1905
  const writeJson = (path, value)=>{
1452
1906
  mkdirSync(dirname(path), {
1453
1907
  recursive: true
@@ -1503,6 +1957,9 @@ const writeTestProjectRunResult = (options)=>{
1503
1957
  } : {},
1504
1958
  factsRoot: '.',
1505
1959
  reportDir: relativeToSummary(result.summaryPath, result.reportDir),
1960
+ ...result.reportPath ? {
1961
+ report: relativeToSummary(result.summaryPath, result.reportPath)
1962
+ } : {},
1506
1963
  summary: result.summary,
1507
1964
  projects: result.projects.map((project)=>({
1508
1965
  projectId: project.projectId,
@@ -1990,7 +2447,24 @@ async function runTestProject(options = {}) {
1990
2447
  } : {},
1991
2448
  result
1992
2449
  });
1993
- return result;
2450
+ const reportPath = new TestRunReportAssembler().assemble({
2451
+ outputDir: reportDir,
2452
+ reportFileName: `test-run-${runId}`,
2453
+ sources: collectTestRunReportSources(result),
2454
+ buildRunnerDump: (index)=>buildTestRunReportDump(result, index)
2455
+ });
2456
+ const completedResult = {
2457
+ ...result,
2458
+ reportPath
2459
+ };
2460
+ writeTestProjectRunResult({
2461
+ projectRoot,
2462
+ ...configPath ? {
2463
+ configPath
2464
+ } : {},
2465
+ result: completedResult
2466
+ });
2467
+ return completedResult;
1994
2468
  }
1995
2469
  export { DEFAULT_TEST_FILE_SELECTION, createTestRunId, defineProjectSetup, defineTestProject, discoverTestConfig, discoverTestFiles, loadTestProject, runTestProject, validateTestFileSelection };
1996
2470