Skip to content

Commit 6bcb73b

Browse files
author
John Doe
committed
refactor: wip
1 parent 1f6e326 commit 6bcb73b

File tree

3 files changed

+613
-34
lines changed

3 files changed

+613
-34
lines changed
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import * as fs from 'node:fs';
2+
import * as os from 'node:os';
3+
import * as path from 'node:path';
4+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
5+
import { teardownTestFolder } from '@code-pushup/test-utils';
6+
import { TraceFileSink } from './file-sink-json-trace.js';
7+
import type { TraceEvent } from './trace-file.type';
8+
9+
describe('TraceFileSink integration', () => {
10+
const baseDir = path.join(os.tmpdir(), 'file-sink-json-trace-int-tests');
11+
const traceJsonPath = path.join(baseDir, 'test-data.json');
12+
const traceJsonlPath = path.join(baseDir, 'test-data.jsonl');
13+
14+
beforeAll(async () => {
15+
await fs.promises.mkdir(baseDir, { recursive: true });
16+
});
17+
18+
beforeEach(async () => {
19+
try {
20+
await fs.promises.unlink(traceJsonPath);
21+
} catch {
22+
// File doesn't exist, which is fine
23+
}
24+
try {
25+
await fs.promises.unlink(traceJsonlPath);
26+
} catch {
27+
// File doesn't exist, which is fine
28+
}
29+
});
30+
31+
afterAll(async () => {
32+
await teardownTestFolder(baseDir);
33+
});
34+
35+
describe('file operations', () => {
36+
const testEvents: TraceEvent[] = [
37+
{ name: 'navigationStart', ts: 100, ph: 'I', cat: 'blink.user_timing' },
38+
{
39+
name: 'loadEventStart',
40+
ts: 200,
41+
ph: 'I',
42+
cat: 'blink.user_timing',
43+
args: { data: { url: 'https://example.com' } },
44+
},
45+
{
46+
name: 'loadEventEnd',
47+
ts: 250,
48+
ph: 'I',
49+
cat: 'blink.user_timing',
50+
args: { detail: { duration: 50 } },
51+
},
52+
];
53+
54+
it('should write and read trace events', async () => {
55+
const sink = new TraceFileSink({
56+
filename: 'test-data',
57+
directory: baseDir,
58+
});
59+
60+
// Open and write data
61+
sink.open();
62+
testEvents.forEach(event => sink.write(event as any));
63+
sink.finalize();
64+
65+
expect(fs.existsSync(traceJsonPath)).toBe(true);
66+
expect(fs.existsSync(traceJsonlPath)).toBe(true);
67+
68+
const jsonContent = fs.readFileSync(traceJsonPath, 'utf8');
69+
const traceData = JSON.parse(jsonContent);
70+
71+
expect(traceData.metadata.source).toBe('DevTools');
72+
expect(traceData.metadata.dataOrigin).toBe('TraceEvents');
73+
expect(Array.isArray(traceData.traceEvents)).toBe(true);
74+
75+
// Should have preamble events + user events + complete event
76+
expect(traceData.traceEvents.length).toBeGreaterThan(testEvents.length);
77+
78+
// Check that our events are included
79+
const userEvents = traceData.traceEvents.filter((e: any) =>
80+
testEvents.some(testEvent => testEvent.name === e.name),
81+
);
82+
expect(userEvents).toHaveLength(testEvents.length);
83+
});
84+
85+
it('should recover events from JSONL file', async () => {
86+
const sink = new TraceFileSink({
87+
filename: 'test-data',
88+
directory: baseDir,
89+
});
90+
sink.open();
91+
testEvents.forEach(event => sink.write(event as any));
92+
sink.close();
93+
94+
const recovered = sink.recover();
95+
expect(recovered.records).toStrictEqual(testEvents);
96+
expect(recovered.errors).toStrictEqual([]);
97+
expect(recovered.partialTail).toBeNull();
98+
});
99+
100+
it('should handle empty trace files', async () => {
101+
const sink = new TraceFileSink({
102+
filename: 'empty-test',
103+
directory: baseDir,
104+
});
105+
sink.open();
106+
sink.finalize();
107+
108+
const emptyJsonPath = path.join(baseDir, 'empty-test.json');
109+
expect(fs.existsSync(emptyJsonPath)).toBe(true);
110+
111+
const jsonContent = fs.readFileSync(emptyJsonPath, 'utf8');
112+
const traceData = JSON.parse(jsonContent);
113+
114+
expect(traceData.metadata.source).toBe('DevTools');
115+
// Should have at least preamble and complete events
116+
expect(traceData.traceEvents.length).toBeGreaterThanOrEqual(2);
117+
});
118+
119+
it('should handle metadata in trace files', async () => {
120+
const metadata = {
121+
version: '1.0.0',
122+
environment: 'test',
123+
customData: { key: 'value' },
124+
};
125+
126+
const sink = new TraceFileSink({
127+
filename: 'metadata-test',
128+
directory: baseDir,
129+
metadata,
130+
});
131+
sink.open();
132+
sink.write({ name: 'test-event', ts: 100, ph: 'I' } as any);
133+
sink.finalize();
134+
135+
const metadataJsonPath = path.join(baseDir, 'metadata-test.json');
136+
const jsonContent = fs.readFileSync(metadataJsonPath, 'utf8');
137+
const traceData = JSON.parse(jsonContent);
138+
139+
expect(traceData.metadata.version).toBe('1.0.0');
140+
expect(traceData.metadata.environment).toBe('test');
141+
expect(traceData.metadata.customData).toStrictEqual({ key: 'value' });
142+
expect(traceData.metadata.source).toBe('DevTools');
143+
});
144+
145+
describe('edge cases', () => {
146+
it('should handle single event traces', async () => {
147+
const singleEvent: TraceEvent = {
148+
name: 'singleEvent',
149+
ts: 123,
150+
ph: 'I',
151+
cat: 'test',
152+
};
153+
154+
const sink = new TraceFileSink({
155+
filename: 'single-event-test',
156+
directory: baseDir,
157+
});
158+
sink.open();
159+
sink.write(singleEvent as any);
160+
sink.finalize();
161+
162+
const singleJsonPath = path.join(baseDir, 'single-event-test.json');
163+
const jsonContent = fs.readFileSync(singleJsonPath, 'utf8');
164+
const traceData = JSON.parse(jsonContent);
165+
166+
expect(
167+
traceData.traceEvents.some((e: any) => e.name === 'singleEvent'),
168+
).toBe(true);
169+
});
170+
171+
it('should handle events with complex args', async () => {
172+
const complexEvent: TraceEvent = {
173+
name: 'complexEvent',
174+
ts: 456,
175+
ph: 'X',
176+
cat: 'test',
177+
args: {
178+
detail: { nested: { data: [1, 2, 3] } },
179+
data: { url: 'https://example.com', size: 1024 },
180+
},
181+
};
182+
183+
const sink = new TraceFileSink({
184+
filename: 'complex-args-test',
185+
directory: baseDir,
186+
});
187+
sink.open();
188+
sink.write(complexEvent as any);
189+
sink.finalize();
190+
191+
const complexJsonPath = path.join(baseDir, 'complex-args-test.json');
192+
const jsonContent = fs.readFileSync(complexJsonPath, 'utf8');
193+
const traceData = JSON.parse(jsonContent);
194+
195+
const eventInTrace = traceData.traceEvents.find(
196+
(e: any) => e.name === 'complexEvent',
197+
);
198+
expect(eventInTrace).toBeDefined();
199+
expect(eventInTrace.args.detail).toStrictEqual(
200+
'{"nested":{"data":[1,2,3]}}',
201+
);
202+
expect(eventInTrace.args.data.url).toBe('https://example.com');
203+
});
204+
205+
it('should handle non-existent directories gracefully', async () => {
206+
const nonExistentDir = path.join(baseDir, 'non-existent');
207+
const sink = new TraceFileSink({
208+
filename: 'non-existent-dir-test',
209+
directory: nonExistentDir,
210+
});
211+
212+
sink.open();
213+
sink.write({ name: 'test', ts: 100, ph: 'I' } as any);
214+
sink.finalize();
215+
216+
const jsonPath = path.join(
217+
nonExistentDir,
218+
'non-existent-dir-test.json',
219+
);
220+
expect(fs.existsSync(jsonPath)).toBe(true);
221+
});
222+
});
223+
});
224+
});

packages/utils/src/lib/file-sink-json-trace.ts

Lines changed: 54 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import * as fs from 'node:fs';
22
import * as path from 'node:path';
33
import { performance } from 'node:perf_hooks';
4-
import { JsonlFileSink, recoverJsonlFile } from './file-sink-jsonl.js';
4+
import {
5+
JsonlFileSink,
6+
jsonlDecode,
7+
jsonlEncode,
8+
recoverJsonlFile,
9+
} from './file-sink-jsonl.js';
510
import { getCompleteEvent, getStartTracing } from './trace-file-utils.js';
611
import type {
712
InstantEvent,
@@ -11,46 +16,60 @@ import type {
1116
UserTimingDetail,
1217
} from './trace-file.type.js';
1318

14-
const tryJson = <T>(v: unknown): T | unknown => {
15-
if (typeof v !== 'string') return v;
16-
try {
17-
return JSON.parse(v) as T;
18-
} catch {
19-
return v;
19+
export function decodeDetail(target: UserTimingDetail): UserTimingDetail {
20+
if (typeof target.detail === 'string') {
21+
return { ...target, detail: jsonlDecode<UserTimingDetail>(target.detail) };
2022
}
21-
};
23+
return target;
24+
}
2225

23-
const toJson = (v: unknown): unknown => {
24-
if (v === undefined) return undefined;
25-
try {
26-
return JSON.stringify(v);
27-
} catch {
28-
return v;
26+
export function encodeDetail(target: UserTimingDetail): UserTimingDetail {
27+
if (target.detail && typeof target.detail === 'object') {
28+
return {
29+
...target,
30+
detail: jsonlEncode(target.detail as UserTimingDetail),
31+
};
2932
}
30-
};
33+
return target;
34+
}
3135

3236
export function decodeTraceEvent({ args, ...rest }: TraceEventRaw): TraceEvent {
3337
if (!args) return rest as TraceEvent;
3438

35-
const out: any = { ...args };
36-
if ('detail' in out) out.detail = tryJson<UserTimingDetail>(out.detail);
37-
if (out.data?.detail)
38-
out.data.detail = tryJson<UserTimingDetail>(out.data.detail);
39+
const out: UserTimingDetail = { ...args };
40+
const processedOut = decodeDetail(out);
3941

40-
return { ...rest, args: out } as TraceEvent;
42+
return {
43+
...rest,
44+
args:
45+
out.data && typeof out.data === 'object'
46+
? {
47+
...processedOut,
48+
data: decodeDetail(out.data as UserTimingDetail),
49+
}
50+
: processedOut,
51+
};
4152
}
4253

4354
export function encodeTraceEvent({ args, ...rest }: TraceEvent): TraceEventRaw {
4455
if (!args) return rest as TraceEventRaw;
4556

46-
const out: any = { ...args };
47-
if ('detail' in out) out.detail = toJson(out.detail);
48-
if (out.data?.detail) out.data.detail = toJson(out.data.detail);
57+
const out: UserTimingDetail = { ...args };
58+
const processedOut = encodeDetail(out);
4959

50-
return { ...rest, args: out } as TraceEventRaw;
60+
return {
61+
...rest,
62+
args:
63+
out.data && typeof out.data === 'object'
64+
? {
65+
...processedOut,
66+
data: encodeDetail(out.data as UserTimingDetail),
67+
}
68+
: processedOut,
69+
};
5170
}
5271

53-
function getTraceMetadata(
72+
export function getTraceMetadata(
5473
startDate?: Date,
5574
metadata?: Record<string, unknown>,
5675
) {
@@ -76,30 +95,30 @@ ${traceEventsContent}
7695
}`;
7796
}
7897

79-
function finalizeTraceFile(
98+
export function finalizeTraceFile(
8099
events: (SpanEvent | InstantEvent)[],
81100
outputPath: string,
82101
metadata?: Record<string, unknown>,
83102
): void {
84103
const { writeFileSync } = fs;
85104

105+
if (events.length === 0) {
106+
return;
107+
}
108+
86109
const sortedEvents = events.sort((a, b) => a.ts - b.ts);
87110
const first = sortedEvents[0];
88111
const last = sortedEvents[sortedEvents.length - 1];
89112

90-
// Use performance.now() as fallback when no events exist
91113
const fallbackTs = performance.now();
92114
const firstTs = first?.ts ?? fallbackTs;
93115
const lastTs = last?.ts ?? fallbackTs;
94116

95-
// Add margins for readability
96117
const tsMargin = 1000;
97118
const startTs = firstTs - tsMargin;
98119
const endTs = lastTs + tsMargin;
99-
const startDate = new Date().toISOString();
100120

101121
const traceEventsJson = [
102-
// Preamble
103122
encodeTraceEvent(
104123
getStartTracing({
105124
ts: startTs,
@@ -112,15 +131,16 @@ function finalizeTraceFile(
112131
dur: 20,
113132
}),
114133
),
115-
// Events
116134
...events.map(encodeTraceEvent),
117135
encodeTraceEvent(
118136
getCompleteEvent({
119137
ts: endTs,
120138
dur: 20,
121139
}),
122140
),
123-
].join(',\n');
141+
]
142+
.map(event => JSON.stringify(event))
143+
.join(',\n');
124144

125145
const jsonOutput = createTraceFileContent(
126146
traceEventsJson,
@@ -130,11 +150,11 @@ function finalizeTraceFile(
130150
writeFileSync(outputPath, jsonOutput, 'utf8');
131151
}
132152

133-
export interface TraceFileSinkOptions {
153+
export type TraceFileSinkOptions = {
134154
filename: string;
135155
directory?: string;
136156
metadata?: Record<string, unknown>;
137-
}
157+
};
138158

139159
export class TraceFileSink extends JsonlFileSink<SpanEvent | InstantEvent> {
140160
readonly #filePath: string;

0 commit comments

Comments
 (0)