@react-native-harness/jest 1.6.0 → 1.7.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 (82) hide show
  1. package/dist/cli-args.d.ts +3 -4
  2. package/dist/cli-args.d.ts.map +1 -1
  3. package/dist/cli-args.js +3 -8
  4. package/dist/diagnostics/index.d.ts +4 -1
  5. package/dist/diagnostics/index.d.ts.map +1 -1
  6. package/dist/diagnostics/index.js +2 -1
  7. package/dist/diagnostics/report.d.ts +81 -1
  8. package/dist/diagnostics/report.d.ts.map +1 -1
  9. package/dist/diagnostics/report.js +225 -3
  10. package/dist/diagnostics/telemetry.d.ts +42 -0
  11. package/dist/diagnostics/telemetry.d.ts.map +1 -0
  12. package/dist/diagnostics/telemetry.js +120 -0
  13. package/dist/errors.d.ts +8 -2
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js +29 -3
  16. package/dist/execute-run.d.ts.map +1 -1
  17. package/dist/execute-run.js +73 -21
  18. package/dist/format-harness-error.d.ts.map +1 -1
  19. package/dist/format-harness-error.js +3 -0
  20. package/dist/harness-session.d.ts +8 -1
  21. package/dist/harness-session.d.ts.map +1 -1
  22. package/dist/harness-session.js +106 -22
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +7 -7
  25. package/dist/jest-preset.d.ts +3 -0
  26. package/dist/jest-preset.d.ts.map +1 -1
  27. package/dist/jest-preset.js +18 -0
  28. package/dist/relative-path.d.ts +8 -0
  29. package/dist/relative-path.d.ts.map +1 -0
  30. package/dist/relative-path.js +8 -0
  31. package/dist/resource-lock.d.ts.map +1 -1
  32. package/dist/resource-lock.js +47 -27
  33. package/dist/run.d.ts.map +1 -1
  34. package/dist/run.js +4 -4
  35. package/dist/watch-session.d.ts +6 -0
  36. package/dist/watch-session.d.ts.map +1 -0
  37. package/dist/watch-session.js +37 -0
  38. package/package.json +8 -8
  39. package/src/cli-args.ts +5 -13
  40. package/src/diagnostics/index.ts +12 -1
  41. package/src/diagnostics/report.ts +326 -5
  42. package/src/diagnostics/telemetry.ts +186 -0
  43. package/src/errors.ts +42 -5
  44. package/src/execute-run.ts +93 -23
  45. package/src/format-harness-error.ts +5 -0
  46. package/src/harness-session.ts +139 -21
  47. package/src/index.ts +8 -7
  48. package/src/jest-preset.ts +18 -0
  49. package/src/relative-path.ts +10 -0
  50. package/src/resource-lock.ts +64 -33
  51. package/src/run.ts +4 -4
  52. package/src/watch-session.ts +65 -0
  53. package/dist/crash-supervisor.d.ts +0 -21
  54. package/dist/crash-supervisor.d.ts.map +0 -1
  55. package/dist/crash-supervisor.js +0 -210
  56. package/dist/event-mapper.d.ts +0 -16
  57. package/dist/event-mapper.d.ts.map +0 -1
  58. package/dist/event-mapper.js +0 -120
  59. package/dist/global-setup.d.ts +0 -3
  60. package/dist/global-setup.d.ts.map +0 -1
  61. package/dist/global-setup.js +0 -60
  62. package/dist/global-teardown.d.ts +0 -3
  63. package/dist/global-teardown.d.ts.map +0 -1
  64. package/dist/global-teardown.js +0 -11
  65. package/dist/harness-reporter.d.ts +0 -8
  66. package/dist/harness-reporter.d.ts.map +0 -1
  67. package/dist/harness-reporter.js +0 -12
  68. package/dist/harness.d.ts +0 -47
  69. package/dist/harness.d.ts.map +0 -1
  70. package/dist/harness.js +0 -619
  71. package/dist/jest-options.d.ts +0 -2
  72. package/dist/jest-options.d.ts.map +0 -1
  73. package/dist/jest-options.js +0 -1
  74. package/dist/setup.d.ts +0 -3
  75. package/dist/setup.d.ts.map +0 -1
  76. package/dist/setup.js +0 -80
  77. package/dist/teardown.d.ts +0 -3
  78. package/dist/teardown.d.ts.map +0 -1
  79. package/dist/teardown.js +0 -10
  80. package/dist/types.d.ts +0 -17
  81. package/dist/types.d.ts.map +0 -1
  82. package/dist/types.js +0 -1
@@ -1,6 +1,5 @@
1
- export type HarnessCliArgs = {
2
- harnessRunner?: string;
3
- metroPort?: number;
4
- };
1
+ import { harnessCliOptions } from '@react-native-harness/config';
2
+ import { type InferredOptionTypes } from 'yargs';
3
+ export type HarnessCliArgs = InferredOptionTypes<typeof harnessCliOptions>;
5
4
  export declare const getAdditionalCliArgs: () => HarnessCliArgs;
6
5
  //# sourceMappingURL=cli-args.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"cli-args.d.ts","sourceRoot":"","sources":["../src/cli-args.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,cAAc,GAAG;IAC3B,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,eAAO,MAAM,oBAAoB,QAAO,cAoBvC,CAAC"}
1
+ {"version":3,"file":"cli-args.d.ts","sourceRoot":"","sources":["../src/cli-args.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,8BAA8B,CAAC;AACjE,OAAc,EAAE,KAAK,mBAAmB,EAAE,MAAM,OAAO,CAAC;AAGxD,MAAM,MAAM,cAAc,GAAG,mBAAmB,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAE3E,eAAO,MAAM,oBAAoB,QAAO,cAcvC,CAAC"}
package/dist/cli-args.js CHANGED
@@ -1,19 +1,14 @@
1
+ import { harnessCliOptions } from '@react-native-harness/config';
1
2
  import yargs from 'yargs';
2
3
  import { hideBin } from 'yargs/helpers';
3
4
  export const getAdditionalCliArgs = () => {
4
5
  const argv = yargs(hideBin(process.argv))
5
- .option('harnessRunner', {
6
- type: 'string',
7
- description: 'Specify which Harness runner to use',
8
- })
9
- .option('metroPort', {
10
- type: 'number',
11
- description: 'Override the Metro bundler port',
12
- })
6
+ .options(harnessCliOptions)
13
7
  .strict(false)
14
8
  .help(false)
15
9
  .version(false)
16
10
  .exitProcess(false)
11
+ .fail(false)
17
12
  .parseSync();
18
13
  return {
19
14
  harnessRunner: argv.harnessRunner,
@@ -1,3 +1,6 @@
1
1
  export { observeMetroEvents, observeBridgeEvents } from './derivers.js';
2
- export { writeTraceFile, printSummary } from './report.js';
2
+ export { writeTraceFile, appendToTraceFile, printSummary, createDiagnosticsAppender, summarizeRunKpis, writeSummaryFile, appendStepSummary, } from './report.js';
3
+ export type { DiagnosticsAppender, RunKpis } from './report.js';
4
+ export { startSessionTelemetry } from './telemetry.js';
5
+ export type { SessionTelemetry } from './telemetry.js';
3
6
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/diagnostics/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AACxE,OAAO,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/diagnostics/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AACxE,OAAO,EACL,cAAc,EACd,iBAAiB,EACjB,YAAY,EACZ,yBAAyB,EACzB,gBAAgB,EAChB,gBAAgB,EAChB,iBAAiB,GAClB,MAAM,aAAa,CAAC;AACrB,YAAY,EAAE,mBAAmB,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAChE,OAAO,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAC;AACvD,YAAY,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC"}
@@ -1,2 +1,3 @@
1
1
  export { observeMetroEvents, observeBridgeEvents } from './derivers.js';
2
- export { writeTraceFile, printSummary } from './report.js';
2
+ export { writeTraceFile, appendToTraceFile, printSummary, createDiagnosticsAppender, summarizeRunKpis, writeSummaryFile, appendStepSummary, } from './report.js';
3
+ export { startSessionTelemetry } from './telemetry.js';
@@ -1,7 +1,87 @@
1
- import type { DiagnosticEntry } from '@react-native-harness/tools';
1
+ import type { DiagnosticEntry, Diagnostics } from '@react-native-harness/tools';
2
+ type TraceEvent = {
3
+ name: string;
4
+ ph: 'X' | 'i' | 'C';
5
+ ts: number;
6
+ dur?: number;
7
+ s?: 'g';
8
+ pid: number;
9
+ tid: number;
10
+ args?: Record<string, unknown>;
11
+ };
12
+ export declare const toTraceEvent: (entry: DiagnosticEntry) => TraceEvent;
2
13
  export declare const writeTraceFile: (entries: DiagnosticEntry[], options: {
3
14
  projectRoot: string;
4
15
  runId: string;
5
16
  }) => Promise<string>;
17
+ export type DiagnosticsAppender = {
18
+ readonly filePath: string;
19
+ /** Synchronously writes everything not yet on disk. For `exit` handlers. */
20
+ flush(): void;
21
+ /** Waits for in-flight writes, then writes the rest and detaches. */
22
+ dispose(): Promise<void>;
23
+ };
24
+ /**
25
+ * Persists diagnostics entries as they are recorded, one JSON object per line
26
+ * in `.harness/diagnostics/<id>.jsonl`. The final trace is only written when a
27
+ * run finishes, so a job that is cancelled or killed mid-run (the case where
28
+ * the data matters most) would otherwise leave nothing behind.
29
+ *
30
+ * Entries are batched (every `flushIntervalMs` or `maxBufferedEntries`) and
31
+ * appended asynchronously, one write at a time, so recording an entry never
32
+ * blocks on disk, which matters most when the machine is overloaded. Only the
33
+ * `exit` handler writes synchronously: a process that is going away cannot
34
+ * wait for the event loop. The session already owns SIGINT/SIGTERM and leaves
35
+ * through `process.exit`, which emits `exit`; installing signal handlers here
36
+ * would depend on listener order and fight other handlers about who re-raises.
37
+ *
38
+ * Entries recorded before the appender was created are replayed, so the log
39
+ * starts at the beginning of the session.
40
+ */
41
+ export declare const createDiagnosticsAppender: (diagnostics: Diagnostics, options: {
42
+ projectRoot: string;
43
+ id: string;
44
+ flushIntervalMs?: number;
45
+ maxBufferedEntries?: number;
46
+ }) => DiagnosticsAppender;
47
+ export type RunKpis = {
48
+ bootWaitMs: number | null;
49
+ timeToReadyMs: number | null;
50
+ peakHostCompressedMb: number | null;
51
+ peakLoad1: number | null;
52
+ minHostFreeMb: number | null;
53
+ /** Memory swapped out during the run, in MB. */
54
+ swapoutsDelta: number | null;
55
+ maxEventLoopLagMs: number | null;
56
+ testFileCount: number;
57
+ result: 'passed' | 'failed' | null;
58
+ };
59
+ /**
60
+ * Reduces a run's entries to the handful of numbers worth comparing between
61
+ * runs. Anything that cannot be derived from the entries is null, never a
62
+ * guess, so a missing probe shows up as "n/a" rather than as a misleading 0.
63
+ */
64
+ export declare const summarizeRunKpis: (entries: DiagnosticEntry[]) => RunKpis;
65
+ export declare const writeSummaryFile: (kpis: RunKpis, options: {
66
+ projectRoot: string;
67
+ runId: string;
68
+ sessionId?: string;
69
+ }) => Promise<string>;
70
+ /** The KPIs as label/value pairs, in display order. */
71
+ export declare const formatRunKpis: (kpis: RunKpis) => Array<[string, string]>;
72
+ /**
73
+ * Appends the KPIs as a markdown table to the GitHub Actions job summary when
74
+ * running there. Best effort: a failure here must never fail the run.
75
+ */
76
+ export declare const appendStepSummary: (kpis: RunKpis, env?: NodeJS.ProcessEnv) => Promise<void>;
77
+ /**
78
+ * Adds entries recorded after a run's trace was written (session teardown) to
79
+ * that trace, or writes a new trace if there is none yet.
80
+ */
81
+ export declare const appendToTraceFile: (entries: DiagnosticEntry[], options: {
82
+ projectRoot: string;
83
+ runId: string;
84
+ }) => Promise<string>;
6
85
  export declare const printSummary: (entries: DiagnosticEntry[], log: (line: string) => void) => void;
86
+ export {};
7
87
  //# sourceMappingURL=report.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"report.d.ts","sourceRoot":"","sources":["../../src/diagnostics/report.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,6BAA6B,CAAC;AAyCnE,eAAO,MAAM,cAAc,GACzB,SAAS,eAAe,EAAE,EAC1B,SAAS;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,KAC9C,OAAO,CAAC,MAAM,CAUhB,CAAC;AAkBF,eAAO,MAAM,YAAY,GACvB,SAAS,eAAe,EAAE,EAC1B,KAAK,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,KAC1B,IA8CF,CAAC"}
1
+ {"version":3,"file":"report.d.ts","sourceRoot":"","sources":["../../src/diagnostics/report.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,6BAA6B,CAAC;AAKhF,KAAK,UAAU,GAAG;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,CAAC;IACpB,EAAE,EAAE,MAAM,CAAC;IACX,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,CAAC,CAAC,EAAE,GAAG,CAAC;IACR,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAChC,CAAC;AAKF,eAAO,MAAM,YAAY,GAAI,OAAO,eAAe,KAAG,UAmCrD,CAAC;AAEF,eAAO,MAAM,cAAc,GACzB,SAAS,eAAe,EAAE,EAC1B,SAAS;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,KAC9C,OAAO,CAAC,MAAM,CAUhB,CAAC;AAKF,MAAM,MAAM,mBAAmB,GAAG;IAChC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,4EAA4E;IAC5E,KAAK,IAAI,IAAI,CAAC;IACd,qEAAqE;IACrE,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1B,CAAC;AAEF;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,yBAAyB,GACpC,aAAa,WAAW,EACxB,SAAS;IACP,WAAW,EAAE,MAAM,CAAC;IACpB,EAAE,EAAE,MAAM,CAAC;IACX,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC7B,KACA,mBAyFF,CAAC;AAEF,MAAM,MAAM,OAAO,GAAG;IACpB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,oBAAoB,EAAE,MAAM,GAAG,IAAI,CAAC;IACpC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,gDAAgD;IAChD,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,aAAa,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,QAAQ,GAAG,QAAQ,GAAG,IAAI,CAAC;CACpC,CAAC;AAQF;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,GAAI,SAAS,eAAe,EAAE,KAAG,OA2C7D,CAAC;AAEF,eAAO,MAAM,gBAAgB,GAC3B,MAAM,OAAO,EACb,SAAS;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,KAClE,OAAO,CAAC,MAAM,CAahB,CAAC;AAQF,uDAAuD;AACvD,eAAO,MAAM,aAAa,GAAI,MAAM,OAAO,KAAG,KAAK,CAAC,CAAC,MAAM,EAAE,MAAM,CAAC,CAUnE,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,iBAAiB,GAC5B,MAAM,OAAO,EACb,MAAK,MAAM,CAAC,UAAwB,KACnC,OAAO,CAAC,IAAI,CAsBd,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,iBAAiB,GAC5B,SAAS,eAAe,EAAE,EAC1B,SAAS;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,KAC9C,OAAO,CAAC,MAAM,CAwBhB,CAAC;AAkBF,eAAO,MAAM,YAAY,GACvB,SAAS,eAAe,EAAE,EAC1B,KAAK,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,KAC1B,IAsDF,CAAC"}
@@ -1,9 +1,23 @@
1
+ import fsSync from 'node:fs';
1
2
  import fs from 'node:fs/promises';
2
3
  import path from 'node:path';
3
- import { getHarnessRootPath } from '@react-native-harness/tools';
4
+ import { getHarnessRootPath, logger, unrefTimer } from '@react-native-harness/tools';
5
+ const reportLogger = logger.child('diagnostics');
4
6
  const TRACE_PID = 1;
5
7
  const TRACE_TID = 1;
6
- const toTraceEvent = (entry) => {
8
+ export const toTraceEvent = (entry) => {
9
+ if (entry.kind === 'gauge') {
10
+ // Counter events become graphed tracks in Perfetto / chrome://tracing,
11
+ // one per distinct name, so each gauge label is its own series.
12
+ return {
13
+ name: entry.label,
14
+ ph: 'C',
15
+ ts: entry.startTime * 1000,
16
+ pid: TRACE_PID,
17
+ tid: TRACE_TID,
18
+ args: { [entry.label]: entry.value ?? 0 },
19
+ };
20
+ }
7
21
  if (entry.kind === 'mark') {
8
22
  return {
9
23
  name: entry.label,
@@ -26,13 +40,213 @@ const toTraceEvent = (entry) => {
26
40
  };
27
41
  };
28
42
  export const writeTraceFile = async (entries, options) => {
29
- const dir = path.join(getHarnessRootPath(options.projectRoot), 'diagnostics');
43
+ const dir = getDiagnosticsDir(options.projectRoot);
30
44
  await fs.mkdir(dir, { recursive: true });
31
45
  const filePath = path.join(dir, `${options.runId}.json`);
32
46
  const trace = { traceEvents: entries.map(toTraceEvent) };
33
47
  await fs.writeFile(filePath, JSON.stringify(trace), 'utf-8');
34
48
  return filePath;
35
49
  };
50
+ const getDiagnosticsDir = (projectRoot) => path.join(getHarnessRootPath(projectRoot), 'diagnostics');
51
+ /**
52
+ * Persists diagnostics entries as they are recorded, one JSON object per line
53
+ * in `.harness/diagnostics/<id>.jsonl`. The final trace is only written when a
54
+ * run finishes, so a job that is cancelled or killed mid-run (the case where
55
+ * the data matters most) would otherwise leave nothing behind.
56
+ *
57
+ * Entries are batched (every `flushIntervalMs` or `maxBufferedEntries`) and
58
+ * appended asynchronously, one write at a time, so recording an entry never
59
+ * blocks on disk, which matters most when the machine is overloaded. Only the
60
+ * `exit` handler writes synchronously: a process that is going away cannot
61
+ * wait for the event loop. The session already owns SIGINT/SIGTERM and leaves
62
+ * through `process.exit`, which emits `exit`; installing signal handlers here
63
+ * would depend on listener order and fight other handlers about who re-raises.
64
+ *
65
+ * Entries recorded before the appender was created are replayed, so the log
66
+ * starts at the beginning of the session.
67
+ */
68
+ export const createDiagnosticsAppender = (diagnostics, options) => {
69
+ const filePath = path.join(getDiagnosticsDir(options.projectRoot), `${options.id}.jsonl`);
70
+ if (!diagnostics.enabled) {
71
+ return {
72
+ filePath,
73
+ flush: () => undefined,
74
+ dispose: async () => undefined,
75
+ };
76
+ }
77
+ const maxBufferedEntries = options.maxBufferedEntries ?? 100;
78
+ let buffer = [];
79
+ // Chunks handed to the async writer and not yet confirmed on disk, kept so
80
+ // the `exit` handler can write them if the process leaves first. In the
81
+ // rare race where one was also completed by the writer, a chunk ends up in
82
+ // the file twice; losing it would be worse for a log whose purpose is
83
+ // surviving abrupt termination.
84
+ let pending = [];
85
+ let writeChain = Promise.resolve();
86
+ let directoryReady = false;
87
+ const render = (lines) => `${lines.join('\n')}\n`;
88
+ const flushAsync = () => {
89
+ if (buffer.length === 0) {
90
+ return;
91
+ }
92
+ const lines = buffer;
93
+ buffer = [];
94
+ pending.push(lines);
95
+ writeChain = writeChain.then(async () => {
96
+ try {
97
+ if (!directoryReady) {
98
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
99
+ directoryReady = true;
100
+ }
101
+ await fs.appendFile(filePath, render(lines), 'utf-8');
102
+ }
103
+ catch (error) {
104
+ reportLogger.debug('failed to append diagnostics: %s', error);
105
+ }
106
+ finally {
107
+ pending = pending.filter((chunk) => chunk !== lines);
108
+ }
109
+ });
110
+ };
111
+ const flushSync = () => {
112
+ const lines = [...pending.flat(), ...buffer];
113
+ pending = [];
114
+ buffer = [];
115
+ if (lines.length === 0) {
116
+ return;
117
+ }
118
+ try {
119
+ fsSync.mkdirSync(path.dirname(filePath), { recursive: true });
120
+ fsSync.appendFileSync(filePath, render(lines), 'utf-8');
121
+ }
122
+ catch (error) {
123
+ reportLogger.debug('failed to append diagnostics: %s', error);
124
+ }
125
+ };
126
+ const unsubscribe = diagnostics.subscribe((entry) => {
127
+ buffer.push(JSON.stringify(entry));
128
+ if (buffer.length >= maxBufferedEntries) {
129
+ flushAsync();
130
+ }
131
+ }, { replay: true });
132
+ const timer = setInterval(flushAsync, options.flushIntervalMs ?? 2000);
133
+ unrefTimer(timer);
134
+ process.on('exit', flushSync);
135
+ const dispose = async () => {
136
+ unsubscribe();
137
+ clearInterval(timer);
138
+ process.off('exit', flushSync);
139
+ flushAsync();
140
+ await writeChain;
141
+ };
142
+ return { filePath, flush: flushSync, dispose };
143
+ };
144
+ const maxOf = (values) => values.length > 0 ? Math.max(...values) : null;
145
+ const minOf = (values) => values.length > 0 ? Math.min(...values) : null;
146
+ /**
147
+ * Reduces a run's entries to the handful of numbers worth comparing between
148
+ * runs. Anything that cannot be derived from the entries is null, never a
149
+ * guess, so a missing probe shows up as "n/a" rather than as a misleading 0.
150
+ */
151
+ export const summarizeRunKpis = (entries) => {
152
+ const gaugeValues = (label) => entries
153
+ .filter((entry) => entry.kind === 'gauge' && entry.label === label)
154
+ .map((entry) => entry.value)
155
+ .filter((value) => typeof value === 'number');
156
+ const spans = (label) => entries.filter((entry) => entry.kind === 'span' && entry.label === label);
157
+ const bootSpans = entries.filter((entry) => entry.kind === 'span' && entry.label.endsWith('.waitForBoot'));
158
+ const sessionStart = spans('session.config.load')[0]?.startTime;
159
+ const firstReady = spans('app.ready.total').find((entry) => entry.status === 'ok');
160
+ const swapouts = gaugeValues('host.mem.swapouts');
161
+ const runStatus = spans('run.total')[0]?.attrs?.status;
162
+ return {
163
+ bootWaitMs: bootSpans.length > 0
164
+ ? bootSpans.reduce((total, entry) => total + entry.duration, 0)
165
+ : null,
166
+ timeToReadyMs: sessionStart !== undefined && firstReady
167
+ ? firstReady.startTime + firstReady.duration - sessionStart
168
+ : null,
169
+ peakHostCompressedMb: maxOf(gaugeValues('host.mem.compressed')),
170
+ peakLoad1: maxOf(gaugeValues('host.load1')),
171
+ minHostFreeMb: minOf(gaugeValues('host.mem.free')),
172
+ swapoutsDelta: swapouts.length >= 2
173
+ ? swapouts[swapouts.length - 1] - swapouts[0]
174
+ : null,
175
+ maxEventLoopLagMs: maxOf(gaugeValues('node.event_loop.max_ms')),
176
+ testFileCount: spans('run.file').length,
177
+ result: runStatus === 'passed' || runStatus === 'failed' ? runStatus : null,
178
+ };
179
+ };
180
+ export const writeSummaryFile = async (kpis, options) => {
181
+ const dir = getDiagnosticsDir(options.projectRoot);
182
+ await fs.mkdir(dir, { recursive: true });
183
+ const filePath = path.join(dir, `${options.runId}.summary.json`);
184
+ const summary = {
185
+ runId: options.runId,
186
+ ...(options.sessionId ? { sessionId: options.sessionId } : {}),
187
+ ...kpis,
188
+ };
189
+ await fs.writeFile(filePath, JSON.stringify(summary, null, 2), 'utf-8');
190
+ return filePath;
191
+ };
192
+ const formatNumber = (value, unit, digits = 0) => value === null ? 'n/a' : `${value.toFixed(digits)}${unit}`;
193
+ const formatKpiDuration = (ms) => ms === null ? 'n/a' : formatDuration(ms);
194
+ /** The KPIs as label/value pairs, in display order. */
195
+ export const formatRunKpis = (kpis) => [
196
+ ['Result', kpis.result ?? 'n/a'],
197
+ ['Test files', String(kpis.testFileCount)],
198
+ ['Boot wait', formatKpiDuration(kpis.bootWaitMs)],
199
+ ['Time to ready', formatKpiDuration(kpis.timeToReadyMs)],
200
+ ['Peak host load (1m)', formatNumber(kpis.peakLoad1, '', 1)],
201
+ ['Peak compressed memory', formatNumber(kpis.peakHostCompressedMb, ' MB')],
202
+ ['Min free memory', formatNumber(kpis.minHostFreeMb, ' MB')],
203
+ ['Swapped out during run', formatNumber(kpis.swapoutsDelta, ' MB')],
204
+ ['Max event loop lag', formatNumber(kpis.maxEventLoopLagMs, ' ms')],
205
+ ];
206
+ /**
207
+ * Appends the KPIs as a markdown table to the GitHub Actions job summary when
208
+ * running there. Best effort: a failure here must never fail the run.
209
+ */
210
+ export const appendStepSummary = async (kpis, env = process.env) => {
211
+ const summaryPath = env.GITHUB_STEP_SUMMARY;
212
+ if (!summaryPath) {
213
+ return;
214
+ }
215
+ const rows = formatRunKpis(kpis).map(([label, value]) => `| ${label} | ${value} |`);
216
+ const markdown = [
217
+ '### Harness run KPIs',
218
+ '',
219
+ '| KPI | Value |',
220
+ '| --- | --- |',
221
+ ...rows,
222
+ '',
223
+ '',
224
+ ].join('\n');
225
+ try {
226
+ await fs.appendFile(summaryPath, markdown, 'utf-8');
227
+ }
228
+ catch (error) {
229
+ reportLogger.debug('failed to write GitHub step summary: %s', error);
230
+ }
231
+ };
232
+ /**
233
+ * Adds entries recorded after a run's trace was written (session teardown) to
234
+ * that trace, or writes a new trace if there is none yet.
235
+ */
236
+ export const appendToTraceFile = async (entries, options) => {
237
+ const filePath = path.join(getDiagnosticsDir(options.projectRoot), `${options.runId}.json`);
238
+ let existing = [];
239
+ try {
240
+ const parsed = JSON.parse(await fs.readFile(filePath, 'utf-8'));
241
+ existing = parsed.traceEvents ?? [];
242
+ }
243
+ catch {
244
+ // No trace yet (or an unreadable one): start a fresh file.
245
+ }
246
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
247
+ await fs.writeFile(filePath, JSON.stringify({ traceEvents: [...existing, ...entries.map(toTraceEvent)] }), 'utf-8');
248
+ return filePath;
249
+ };
36
250
  const formatDuration = (ms) => {
37
251
  if (ms >= 1000) {
38
252
  return `${(ms / 1000).toFixed(1)}s`;
@@ -46,6 +260,10 @@ export const printSummary = (entries, log) => {
46
260
  }
47
261
  const statsByLabel = new Map();
48
262
  for (const entry of entries) {
263
+ // Gauges are samples, not timings; they are summarised as KPIs below.
264
+ if (entry.kind === 'gauge') {
265
+ continue;
266
+ }
49
267
  const existing = statsByLabel.get(entry.label);
50
268
  if (existing) {
51
269
  existing.count += 1;
@@ -81,4 +299,8 @@ export const printSummary = (entries, log) => {
81
299
  log(` ${stats.label}: count=${stats.count} total=${formatDuration(stats.totalMs)} max=${formatDuration(stats.maxMs)}`);
82
300
  }
83
301
  }
302
+ log(' KPIs');
303
+ for (const [label, value] of formatRunKpis(summarizeRunKpis(entries))) {
304
+ log(` ${label}: ${value}`);
305
+ }
84
306
  };
@@ -0,0 +1,42 @@
1
+ import { createLifecycleEmitter, type Diagnostics, type DiagnosticAttrs, type LifecyclePhase, type HostPressure } from '@react-native-harness/tools';
2
+ import { type Config as HarnessConfig } from '@react-native-harness/config';
3
+ import type { HarnessPlatformRunner } from '@react-native-harness/platforms';
4
+ export type SessionTelemetry = {
5
+ /** Identifies the session; names the incremental `.jsonl` log. */
6
+ readonly sessionId: string;
7
+ /** Session code emits phases here; probes listen. */
8
+ readonly lifecycle: ReturnType<typeof createLifecycleEmitter>;
9
+ /** Emits a lifecycle phase. */
10
+ phase(phase: LifecyclePhase, attrs?: DiagnosticAttrs): void;
11
+ /** Makes the platform runner available to the platform collector probe. */
12
+ attachPlatform(platform: HarnessPlatformRunner): void;
13
+ /** Records the run that is current, so teardown data can join its trace. */
14
+ trackRun(runId: string): void;
15
+ /**
16
+ * Host pressure observed by the host-resources probe so far: peak load,
17
+ * mean idle CPU, lowest free memory and memory swapped out since the first
18
+ * sample. Null
19
+ * when no host sample was taken (diagnostics or the host probe are off).
20
+ */
21
+ hostPressure(): HostPressure | null;
22
+ /** Resolves once lifecycle driven collection started so far has finished. */
23
+ settled(): Promise<void>;
24
+ /**
25
+ * Stops probes and the incremental log. Entries recorded since the last
26
+ * run's report was written (the end-of-run snapshot, teardown spans) are
27
+ * added to that run's trace. Safe to call more than once.
28
+ */
29
+ dispose(): Promise<void>;
30
+ };
31
+ /**
32
+ * The single composition point for run telemetry: it wires the incremental
33
+ * log and the probes to the one diagnostics sink. Session code only emits
34
+ * lifecycle phases; it never calls a probe. Everything is a no-op, with no
35
+ * timers, listeners or spawned processes, when diagnostics are disabled.
36
+ */
37
+ export declare const startSessionTelemetry: ({ diagnostics, config, projectRoot, }: {
38
+ diagnostics: Diagnostics;
39
+ config: Pick<HarnessConfig, "diagnostics">;
40
+ projectRoot: string;
41
+ }) => SessionTelemetry;
42
+ //# sourceMappingURL=telemetry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"telemetry.d.ts","sourceRoot":"","sources":["../../src/diagnostics/telemetry.ts"],"names":[],"mappings":"AAEA,OAAO,EAGL,sBAAsB,EAGtB,KAAK,WAAW,EAChB,KAAK,eAAe,EACpB,KAAK,cAAc,EACnB,KAAK,YAAY,EAClB,MAAM,6BAA6B,CAAC;AACrC,OAAO,EAEL,KAAK,MAAM,IAAI,aAAa,EAC7B,MAAM,8BAA8B,CAAC;AACtC,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,iCAAiC,CAAC;AAK7E,MAAM,MAAM,gBAAgB,GAAG;IAC7B,kEAAkE;IAClE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,qDAAqD;IACrD,QAAQ,CAAC,SAAS,EAAE,UAAU,CAAC,OAAO,sBAAsB,CAAC,CAAC;IAC9D,+BAA+B;IAC/B,KAAK,CAAC,KAAK,EAAE,cAAc,EAAE,KAAK,CAAC,EAAE,eAAe,GAAG,IAAI,CAAC;IAC5D,2EAA2E;IAC3E,cAAc,CAAC,QAAQ,EAAE,qBAAqB,GAAG,IAAI,CAAC;IACtD,4EAA4E;IAC5E,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B;;;;;OAKG;IACH,YAAY,IAAI,YAAY,GAAG,IAAI,CAAC;IACpC,6EAA6E;IAC7E,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACzB;;;;OAIG;IACH,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1B,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,GAAI,uCAInC;IACD,WAAW,EAAE,WAAW,CAAC;IACzB,MAAM,EAAE,IAAI,CAAC,aAAa,EAAE,aAAa,CAAC,CAAC;IAC3C,WAAW,EAAE,MAAM,CAAC;CACrB,KAAG,gBAyHH,CAAC"}
@@ -0,0 +1,120 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import os from 'node:os';
3
+ import { logger, createDefaultProbes, createLifecycleEmitter, DEFAULT_SAMPLE_INTERVAL_MS, startTelemetryProbes, } from '@react-native-harness/tools';
4
+ import { resolveDiagnosticsOptions, } from '@react-native-harness/config';
5
+ import { appendToTraceFile, createDiagnosticsAppender } from './report.js';
6
+ const telemetryLogger = logger.child('diagnostics');
7
+ /**
8
+ * The single composition point for run telemetry: it wires the incremental
9
+ * log and the probes to the one diagnostics sink. Session code only emits
10
+ * lifecycle phases; it never calls a probe. Everything is a no-op, with no
11
+ * timers, listeners or spawned processes, when diagnostics are disabled.
12
+ */
13
+ export const startSessionTelemetry = ({ diagnostics, config, projectRoot, }) => {
14
+ const sessionId = randomUUID();
15
+ const lifecycle = createLifecycleEmitter();
16
+ const abortController = new AbortController();
17
+ const options = resolveDiagnosticsOptions(config);
18
+ let platform;
19
+ let lastRunId;
20
+ const appender = createDiagnosticsAppender(diagnostics, {
21
+ projectRoot,
22
+ id: sessionId,
23
+ });
24
+ const run = startTelemetryProbes(createDefaultProbes({
25
+ host: options.probes.host,
26
+ node: options.probes.node,
27
+ snapshots: options.probes.snapshots,
28
+ platform: options.probes.platform
29
+ ? { getCollector: () => platform?.createTelemetryCollector?.() }
30
+ : undefined,
31
+ }), {
32
+ diagnostics,
33
+ signal: abortController.signal,
34
+ lifecycle,
35
+ sampleIntervalMs: options.sampleIntervalMs ?? DEFAULT_SAMPLE_INTERVAL_MS,
36
+ });
37
+ // Running aggregates rather than stored samples: a watch-mode session can
38
+ // live for hours, and only the extremes are needed.
39
+ let peakLoad1 = null;
40
+ let idlePctSum = 0;
41
+ let idlePctSamples = 0;
42
+ let minHostFreeMb = null;
43
+ let firstSwapoutsMb = null;
44
+ let lastSwapoutsMb = null;
45
+ const unsubscribePressure = diagnostics.subscribe(({ kind, label, value }) => {
46
+ if (kind !== 'gauge' || typeof value !== 'number') {
47
+ return;
48
+ }
49
+ if (label === 'host.load1') {
50
+ peakLoad1 = Math.max(peakLoad1 ?? value, value);
51
+ }
52
+ else if (label === 'host.cpu.idle_pct') {
53
+ idlePctSum += value;
54
+ idlePctSamples += 1;
55
+ }
56
+ else if (label === 'host.mem.free') {
57
+ minHostFreeMb = Math.min(minHostFreeMb ?? value, value);
58
+ }
59
+ else if (label === 'host.mem.swapouts') {
60
+ firstSwapoutsMb ??= value;
61
+ lastSwapoutsMb = value;
62
+ }
63
+ }, { replay: true });
64
+ const hostPressure = () => peakLoad1 === null && minHostFreeMb === null && lastSwapoutsMb === null
65
+ ? null
66
+ : {
67
+ peakLoad1,
68
+ meanIdlePct: idlePctSamples > 0 ? idlePctSum / idlePctSamples : null,
69
+ minHostFreeMb,
70
+ swapoutsDeltaMb: firstSwapoutsMb !== null && lastSwapoutsMb !== null
71
+ ? lastSwapoutsMb - firstSwapoutsMb
72
+ : null,
73
+ cpuCount: os.availableParallelism(),
74
+ };
75
+ const phase = (name, attrs) => {
76
+ if (!diagnostics.enabled) {
77
+ return;
78
+ }
79
+ lifecycle.emit({ type: 'phase', phase: name, attrs });
80
+ };
81
+ let disposed = null;
82
+ return {
83
+ sessionId,
84
+ lifecycle,
85
+ phase,
86
+ attachPlatform: (runner) => {
87
+ platform = runner;
88
+ },
89
+ trackRun: (runId) => {
90
+ lastRunId = runId;
91
+ },
92
+ hostPressure,
93
+ settled: run.settled,
94
+ dispose: () => {
95
+ disposed ??= (async () => {
96
+ unsubscribePressure();
97
+ try {
98
+ abortController.abort();
99
+ await run.dispose();
100
+ if (diagnostics.enabled && lastRunId) {
101
+ const leftover = diagnostics.flush();
102
+ if (leftover.length > 0) {
103
+ await appendToTraceFile(leftover, {
104
+ projectRoot,
105
+ runId: lastRunId,
106
+ });
107
+ }
108
+ }
109
+ }
110
+ catch (error) {
111
+ telemetryLogger.debug('failed to finalize diagnostics trace: %s', error);
112
+ }
113
+ finally {
114
+ await appender.dispose();
115
+ }
116
+ })();
117
+ return disposed;
118
+ },
119
+ };
120
+ };
package/dist/errors.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { HarnessError } from '@react-native-harness/tools';
1
+ import { HarnessError, type HostPressure } from '@react-native-harness/tools';
2
2
  import type { AppCrashDetails } from '@react-native-harness/platforms';
3
3
  export { StartupStallError, type StartupStallCode, type StartupStallDetails, } from '@react-native-harness/bundler-metro';
4
4
  export declare class NoRunnerSpecifiedError extends HarnessError {
@@ -13,7 +13,9 @@ export declare class InitializationTimeoutError extends HarnessError {
13
13
  }
14
14
  export declare class PlatformReadyTimeoutError extends HarnessError {
15
15
  readonly timeout: number;
16
- constructor(timeout: number);
16
+ readonly hostPressure: HostPressure | null;
17
+ constructor(timeout: number, hostPressure?: HostPressure | null);
18
+ get hostStarved(): boolean;
17
19
  }
18
20
  export declare class MetroPortRangeExhaustedError extends HarnessError {
19
21
  readonly initialPort: number;
@@ -40,5 +42,9 @@ export declare class RuntimeDisconnectError extends HarnessError {
40
42
  constructor(testFilePath: string, details: RuntimeDisconnectDetails);
41
43
  get phase(): NativeCrashPhase;
42
44
  }
45
+ export declare class HostSurfaceReplacedError extends HarnessError {
46
+ readonly testFilePath: string;
47
+ constructor(testFilePath: string, cause: Error);
48
+ }
43
49
  export type HarnessRuntimeFailure = NativeCrashError | RuntimeDisconnectError;
44
50
  //# sourceMappingURL=errors.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,YAAY,EAAE,MAAM,6BAA6B,CAAC;AACvE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAC;AACvE,OAAO,EACL,iBAAiB,EACjB,KAAK,gBAAgB,EACrB,KAAK,mBAAmB,GACzB,MAAM,qCAAqC,CAAC;AAE7C,qBAAa,sBAAuB,SAAQ,YAAY;;CAKvD;AAED,qBAAa,mBAAoB,SAAQ,YAAY;aACvB,UAAU,EAAE,MAAM;gBAAlB,UAAU,EAAE,MAAM;CAI/C;AAED,qBAAa,0BAA2B,SAAQ,YAAY;;CAK3D;AAED,qBAAa,yBAA0B,SAAQ,YAAY;aAC7B,OAAO,EAAE,MAAM;gBAAf,OAAO,EAAE,MAAM;CAM5C;AAED,qBAAa,4BAA6B,SAAQ,YAAY;aAE1C,WAAW,EAAE,MAAM;aACnB,QAAQ,EAAE,MAAM;gBADhB,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,MAAM;CAQnC;AAED,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,WAAW,CAAC;AAEvD,MAAM,MAAM,kBAAkB,GAAG,eAAe,GAAG;IACjD,KAAK,EAAE,gBAAgB,CAAC;CACzB,CAAC;AAEF,MAAM,MAAM,wBAAwB,GAAG,eAAe,GAAG;IACvD,KAAK,EAAE,gBAAgB,CAAC;CACzB,CAAC;AAmEF,qBAAa,gBAAiB,SAAQ,YAAY;aAE9B,YAAY,EAAE,MAAM;aACpB,OAAO,EAAE,kBAAkB;aAC3B,aAAa,CAAC,EAAE,MAAM;gBAFtB,YAAY,EAAE,MAAM,EACpB,OAAO,EAAE,kBAAkB,EAC3B,aAAa,CAAC,EAAE,MAAM,YAAA;IAOxC,IAAI,KAAK,qBAER;CACF;AA0BD,qBAAa,sBAAuB,SAAQ,YAAY;aAEpC,YAAY,EAAE,MAAM;aACpB,OAAO,EAAE,wBAAwB;gBADjC,YAAY,EAAE,MAAM,EACpB,OAAO,EAAE,wBAAwB;IAOnD,IAAI,KAAK,qBAER;CACF;AAED,MAAM,MAAM,qBAAqB,GAAG,gBAAgB,GAAG,sBAAsB,CAAC"}
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,YAAY,EAEZ,KAAK,YAAY,EAClB,MAAM,6BAA6B,CAAC;AACrC,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAC;AACvE,OAAO,EACL,iBAAiB,EACjB,KAAK,gBAAgB,EACrB,KAAK,mBAAmB,GACzB,MAAM,qCAAqC,CAAC;AAE7C,qBAAa,sBAAuB,SAAQ,YAAY;;CAKvD;AAED,qBAAa,mBAAoB,SAAQ,YAAY;aACvB,UAAU,EAAE,MAAM;gBAAlB,UAAU,EAAE,MAAM;CAI/C;AAED,qBAAa,0BAA2B,SAAQ,YAAY;;CAK3D;AAkBD,qBAAa,yBAA0B,SAAQ,YAAY;aAEvC,OAAO,EAAE,MAAM;aACf,YAAY,EAAE,YAAY,GAAG,IAAI;gBADjC,OAAO,EAAE,MAAM,EACf,YAAY,GAAE,YAAY,GAAG,IAAW;IAM1D,IAAI,WAAW,IAAI,OAAO,CAEzB;CACF;AAED,qBAAa,4BAA6B,SAAQ,YAAY;aAE1C,WAAW,EAAE,MAAM;aACnB,QAAQ,EAAE,MAAM;gBADhB,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,MAAM;CAQnC;AAED,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,WAAW,CAAC;AAEvD,MAAM,MAAM,kBAAkB,GAAG,eAAe,GAAG;IACjD,KAAK,EAAE,gBAAgB,CAAC;CACzB,CAAC;AAEF,MAAM,MAAM,wBAAwB,GAAG,eAAe,GAAG;IACvD,KAAK,EAAE,gBAAgB,CAAC;CACzB,CAAC;AAmEF,qBAAa,gBAAiB,SAAQ,YAAY;aAE9B,YAAY,EAAE,MAAM;aACpB,OAAO,EAAE,kBAAkB;aAC3B,aAAa,CAAC,EAAE,MAAM;gBAFtB,YAAY,EAAE,MAAM,EACpB,OAAO,EAAE,kBAAkB,EAC3B,aAAa,CAAC,EAAE,MAAM,YAAA;IAOxC,IAAI,KAAK,qBAER;CACF;AA0BD,qBAAa,sBAAuB,SAAQ,YAAY;aAEpC,YAAY,EAAE,MAAM;aACpB,OAAO,EAAE,wBAAwB;gBADjC,YAAY,EAAE,MAAM,EACpB,OAAO,EAAE,wBAAwB;IAOnD,IAAI,KAAK,qBAER;CACF;AAID,qBAAa,wBAAyB,SAAQ,YAAY;aAC5B,YAAY,EAAE,MAAM;gBAApB,YAAY,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK;CAK/D;AAED,MAAM,MAAM,qBAAqB,GAAG,gBAAgB,GAAG,sBAAsB,CAAC"}