@pghoya2956/livemap 1.1.1 → 1.3.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.
@@ -0,0 +1,33 @@
1
+ // Node 사용자 리포터(패키지 공개 경로): node --test의 test:pass·test:fail 이벤트를 파일별로 세어 결과 JSON 한 실행으로 낸다.
2
+ // node --test --test-reporter=@pghoya2956/livemap/src/reporters/node-results.mjs --test-reporter-destination=map/.out/node-results.json
3
+ // Node v22.0.0 문서에 있는 이벤트 필드(file·details.type·skip·todo)만 쓴다. test:summary는 v22.0.0 문서에 없어 쓰지 않는다.
4
+ // describe(details.type === 'suite')는 빼고, skip이 있으면 skipped, todo가 있으면 pending이다. 불러오기에 실패한 파일은
5
+ // 파일 이름으로 test:fail 하나가 와서 실패 1이 된다. 루트는 러너의 작업 폴더이고, 루트 밖 파일은 빼고 그 수를 outsideRoot에 남긴다.
6
+ // 러너 종료 코드는 리포터가 알 수 없어 exit는 null이다(livemap test-report가 채운다).
7
+ import { emptyFile, gitStamp, RESULTS_SCHEMA, sortFiles, toRootPath } from '../results.mjs';
8
+
9
+ const flag = (v) => v !== undefined && v !== null && v !== false;
10
+
11
+ export default async function* nodeResults(source) {
12
+ const root = process.cwd();
13
+ const at = new Date().toISOString();
14
+ const { sha, dirtyPaths } = gitStamp(root);
15
+ const files = new Map();
16
+ const outside = new Set();
17
+ for await (const event of source) {
18
+ if (event.type !== 'test:pass' && event.type !== 'test:fail') continue;
19
+ const d = event.data || {};
20
+ if (d.details?.type === 'suite' || !d.file) continue;
21
+ const filePath = toRootPath(root, d.file);
22
+ if (!filePath) { outside.add(d.file); continue; }
23
+ if (!files.has(filePath)) files.set(filePath, emptyFile(filePath));
24
+ const f = files.get(filePath);
25
+ f.tests += 1;
26
+ if (flag(d.todo)) f.pending += 1;
27
+ else if (flag(d.skip)) f.skipped += 1;
28
+ else if (event.type === 'test:fail') f.failed += 1;
29
+ else f.passed += 1;
30
+ }
31
+ const run = { runner: 'node', source: null, sha, dirtyPaths, at, exit: null, outsideRoot: outside.size, files: sortFiles([...files.values()]) };
32
+ yield JSON.stringify({ schema: RESULTS_SCHEMA, runs: [run] }, null, 2) + '\n';
33
+ }
@@ -0,0 +1,187 @@
1
+ // 결과 JSON: 러너가 낸 검사 결과를 파일별로 모은 livemap 소유 형식(필드 이름은 CTRF를 따른다).
2
+ // { schema: 1, runs: [{ runner, source, sha, dirtyPaths, at, exit, outsideRoot?, stamped?, files: [{ filePath, tests, passed, failed, skipped, pending, tags }] }] }
3
+ // 경로는 tests.report와 같은 폴더의 test-results.json. 같은 러너·같은 출처의 실행은 바꾸고 나머지는 남긴다.
4
+ // 쓰기는 임시 파일에 쓴 뒤 이름을 바꿔, 두 실행이 동시에 써도 파일이 깨지지 않고 나중 실행이 남는다.
5
+ import { execFileSync } from 'node:child_process';
6
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
7
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
8
+
9
+ export const RESULTS_SCHEMA = 1;
10
+ export const RESULTS_NAME = 'test-results.json';
11
+ export const DEFAULT_REPORT = 'map/.out/junit.xml';
12
+
13
+ // 프로젝트 루트 기준 결과 JSON 경로
14
+ export const resultsPath = (cfg) => join(dirname(cfg?.tests?.report || DEFAULT_REPORT), RESULTS_NAME);
15
+
16
+ export const emptyResults = () => ({ schema: RESULTS_SCHEMA, runs: [] });
17
+
18
+ export function readResults(file) {
19
+ if (!existsSync(file)) return emptyResults();
20
+ let doc;
21
+ try { doc = JSON.parse(readFileSync(file, 'utf8')); } catch (e) { throw new Error(`결과 JSON을 읽지 못함(${file}): ${e.message}`); }
22
+ if (!doc || doc.schema !== RESULTS_SCHEMA || !Array.isArray(doc.runs)) throw new Error(`결과 JSON 형식이 아님(${file}): schema ${RESULTS_SCHEMA}·runs 배열 필요`);
23
+ return doc;
24
+ }
25
+
26
+ const sameRun = (a, b) => a.runner === b.runner && a.source === b.source;
27
+
28
+ // 같은 러너·출처의 실행은 그 자리에서 바꾸고, 없으면 뒤에 붙인다
29
+ export function mergeRun(doc, run) {
30
+ const runs = doc.runs.slice();
31
+ const i = runs.findIndex((r) => sameRun(r, run));
32
+ if (i >= 0) runs[i] = run; else runs.push(run);
33
+ return { ...doc, schema: RESULTS_SCHEMA, runs };
34
+ }
35
+
36
+ export function writeResults(file, ...runs) {
37
+ let doc = readResults(file);
38
+ for (const run of runs) doc = mergeRun(doc, run);
39
+ mkdirSync(dirname(file), { recursive: true });
40
+ const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
41
+ writeFileSync(tmp, JSON.stringify(doc, null, 2) + '\n');
42
+ renameSync(tmp, file);
43
+ return doc;
44
+ }
45
+
46
+ // 실행 시점 기준 커밋과 작업트리 변경 경로(루트 기준 상대). git이 없으면 sha null·빈 목록
47
+ export function gitStamp(root) {
48
+ const run = (...a) => { try { return execFileSync('git', ['-C', root, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); } catch { return null; } };
49
+ const sha = run('rev-parse', 'HEAD')?.trim() || null;
50
+ if (!sha) return { sha: null, dirtyPaths: [] };
51
+ const prefix = run('rev-parse', '--show-prefix')?.trim() || '';
52
+ // -z: 경로 인용 없음. 이름 바꾸기(R)·복사(C)는 새 경로 뒤에 원래 경로가 한 칸 더 온다
53
+ const parts = (run('status', '--porcelain=v1', '-z', '--', '.') || '').split('\0');
54
+ const paths = new Set();
55
+ for (let i = 0; i < parts.length; i += 1) {
56
+ const e = parts[i];
57
+ if (e.length < 4) continue;
58
+ paths.add(e.slice(3));
59
+ if (e[0] === 'R' || e[0] === 'C') { i += 1; if (parts[i]) paths.add(parts[i]); }
60
+ }
61
+ const dirtyPaths = [...paths].map((p) => (prefix && p.startsWith(prefix) ? p.slice(prefix.length) : p)).sort();
62
+ return { sha, dirtyPaths };
63
+ }
64
+
65
+ // 절대 경로(또는 file URL)를 루트 기준 상대 경로로. 루트 밖이면 null.
66
+ // 러너는 심볼릭 링크를 푼 실제 경로를 적기도 하므로(macOS /var → /private/var) 밖으로 보이면 양쪽 실제 경로로 한 번 더 본다
67
+ const real = (p) => { try { return realpathSync(p); } catch { return p; } };
68
+ export function toRootPath(root, p) {
69
+ let file = String(p);
70
+ if (file.startsWith('file://')) file = new URL(file).pathname;
71
+ const abs = isAbsolute(file) ? file : resolve(root, file);
72
+ const inside = (r) => (r && !r.startsWith('..') && !isAbsolute(r) ? r.split('\\').join('/') : null);
73
+ return inside(relative(root, abs)) ?? inside(relative(real(root), real(abs)));
74
+ }
75
+
76
+ export const emptyFile = (filePath) => ({ filePath, tests: 0, passed: 0, failed: 0, skipped: 0, pending: 0, tags: [] });
77
+ export const sortFiles = (files) => files.slice().sort((a, b) => (a.filePath < b.filePath ? -1 : a.filePath > b.filePath ? 1 : 0));
78
+
79
+ // ---- import: livemap 리포터 출력·Playwright JSON 리포터 출력·JUnit XML 판별과 해석 ----
80
+
81
+ // 형식 이름(livemap·playwright·junit) 또는 null
82
+ export function detectFormat(text) {
83
+ let json;
84
+ try { json = JSON.parse(text); } catch { json = undefined; }
85
+ if (json !== undefined) {
86
+ if (json && json.schema === RESULTS_SCHEMA && Array.isArray(json.runs)) return 'livemap';
87
+ if (json && json.config && typeof json.config === 'object' && Array.isArray(json.suites)) return 'playwright';
88
+ return null;
89
+ }
90
+ return /<(testsuites|testsuite|testcase)\b/.test(text) ? 'junit' : null;
91
+ }
92
+
93
+ const add = (a, b) => { for (const k of ['tests', 'passed', 'failed', 'skipped', 'pending']) a[k] += b[k] || 0; return a; };
94
+ export const totalsOf = (files) => files.reduce((t, f) => add(t, f), { tests: 0, passed: 0, failed: 0, skipped: 0, pending: 0 });
95
+
96
+ // Playwright 1.63.0 JSON: 파일은 config.rootDir + suites[].file(중첩 suite 포함), 결과는 tests[].status, 태그는 specs[].tags
97
+ export function parsePlaywright(report, root) {
98
+ const rootDir = typeof report.config?.rootDir === 'string' ? report.config.rootDir : root;
99
+ const files = new Map();
100
+ const outside = new Set();
101
+ const visit = (suite, parentFile) => {
102
+ const suiteFile = suite.file || parentFile;
103
+ for (const spec of suite.specs || []) {
104
+ const name = spec.file || suiteFile;
105
+ if (!name) continue;
106
+ const filePath = toRootPath(root, resolve(rootDir, name));
107
+ if (!filePath) { outside.add(resolve(rootDir, name)); continue; }
108
+ if (!files.has(filePath)) files.set(filePath, { ...emptyFile(filePath), flaky: 0 });
109
+ const f = files.get(filePath);
110
+ for (const tag of spec.tags || []) if (!f.tags.includes(tag)) f.tags.push(tag);
111
+ for (const t of spec.tests || []) {
112
+ f.tests += 1;
113
+ if (t.status === 'expected') f.passed += 1;
114
+ else if (t.status === 'flaky') { f.passed += 1; f.flaky += 1; } else if (t.status === 'skipped') f.skipped += 1;
115
+ else f.failed += 1; // unexpected
116
+ }
117
+ }
118
+ for (const child of suite.suites || []) visit(child, suiteFile);
119
+ };
120
+ for (const s of report.suites) visit(s, null);
121
+ const list = sortFiles([...files.values()]).map((f) => ({ ...f, tags: f.tags.slice().sort() }));
122
+ // 필드 순서를 리포터 출력과 맞춘다(flaky는 pending 뒤)
123
+ const ordered = list.map(({ filePath, tests, passed, failed, skipped, pending, flaky, tags }) => ({ filePath, tests, passed, failed, skipped, pending, flaky, tags }));
124
+ return { runner: 'playwright', at: report.stats?.startTime || null, outsideRoot: outside.size, files: ordered };
125
+ }
126
+
127
+ const XML_ENTITIES = { lt: '<', gt: '>', amp: '&', quot: '"', apos: "'" };
128
+ const unescapeXml = (s) => s.replace(/&(lt|gt|amp|quot|apos|#\d+|#x[0-9a-f]+);/gi, (m, e) => (e[0] === '#' ? String.fromCodePoint(e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : Number(e.slice(1))) : XML_ENTITIES[e.toLowerCase()]));
129
+ const attrsOf = (s) => Object.fromEntries([...s.matchAll(/([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)].map((m) => [m[1], unescapeXml(m[2] ?? m[3])]));
130
+
131
+ // JUnit XML: 최상위·중첩 <testcase>를 모두 센다. file 속성(testcase 또는 감싼 testsuite)이 있으면 파일별로 나누고,
132
+ // 없으면 실행 전체(totals)에만 더한다. todo로 건너뛴 검사는 pending, failure·error는 failed, skipped는 skipped다.
133
+ // 어댑터의 JUnit 호환 읽기와 import가 이 해석기 하나를 쓴다.
134
+ export function parseJunit(xml, root) {
135
+ const tag = /<(\/?)(testsuite|testcase|skipped|failure|error)\b((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/g;
136
+ const body = xml.replace(/<!--[\s\S]*?-->/g, '').replace(/<!\[CDATA\[[\s\S]*?\]\]>/g, '');
137
+ const suites = [];
138
+ const files = new Map();
139
+ const totals = { tests: 0, passed: 0, failed: 0, skipped: 0, pending: 0 };
140
+ const outside = new Set();
141
+ let current = null;
142
+ let at = null;
143
+ const close = (c) => {
144
+ const one = { tests: 1, passed: 0, failed: 0, skipped: 0, pending: 0 };
145
+ if (c.todo) one.pending = 1; else if (c.failed) one.failed = 1; else if (c.skipped) one.skipped = 1; else one.passed = 1;
146
+ add(totals, one);
147
+ const raw = c.file || [...suites].reverse().find((s) => s.file)?.file;
148
+ if (!raw) return;
149
+ const filePath = toRootPath(root, raw);
150
+ if (!filePath) { outside.add(raw); return; }
151
+ if (!files.has(filePath)) files.set(filePath, emptyFile(filePath));
152
+ add(files.get(filePath), one);
153
+ };
154
+ for (const m of body.matchAll(tag)) {
155
+ const [, end, name, rawAttrs, selfClose] = m;
156
+ const a = attrsOf(rawAttrs);
157
+ if (name === 'testsuite') {
158
+ if (end) suites.pop();
159
+ else { at ||= a.timestamp || null; if (!selfClose) suites.push(a); }
160
+ } else if (name === 'testcase') {
161
+ if (end) { if (current) close(current); current = null; }
162
+ else {
163
+ const c = { file: a.file || null, failed: false, skipped: false, todo: false };
164
+ if (selfClose) close(c); else current = c;
165
+ }
166
+ } else if (current && !end) {
167
+ if (name === 'skipped') { if (a.type === 'todo') current.todo = true; else current.skipped = true; }
168
+ else current.failed = true;
169
+ }
170
+ }
171
+ return { runner: 'junit', at, outsideRoot: outside.size, files: sortFiles([...files.values()]), totals };
172
+ }
173
+
174
+ // 가져올 파일 하나를 결과 JSON 실행 목록으로 바꾼다. 판별할 수 없으면 null.
175
+ // livemap 리포터 출력은 그 sha·dirtyPaths·at를 지키고 출처가 비었으면 가져온 파일 경로를 적는다.
176
+ // Playwright·JUnit은 sha가 없으므로 sha(--sha, 없으면 현재 HEAD)와 현재 dirtyPaths를 적고 stamped "import"를 남긴다.
177
+ export function importRuns({ root, text, source, sha }) {
178
+ const format = detectFormat(text);
179
+ if (!format) return null;
180
+ if (format === 'livemap') return JSON.parse(text).runs.map((r) => ({ ...r, source: r.source ?? source }));
181
+ const stamp = gitStamp(root);
182
+ const parsed = format === 'playwright' ? parsePlaywright(JSON.parse(text), root) : parseJunit(text, root);
183
+ const { runner, at, outsideRoot, files, totals } = parsed;
184
+ const run = { runner, source, stamped: 'import', sha: sha || stamp.sha, dirtyPaths: stamp.dirtyPaths, at: at || new Date().toISOString(), exit: null, outsideRoot, files };
185
+ if (totals) run.totals = totals;
186
+ return [run];
187
+ }
@@ -1,22 +1,58 @@
1
- // 단위 검사를 JUnit 리포트로 남기고 기준 커밋·시각을 옆에 적는다. 상황판이 둘을 읽어 "최신 커밋에서 통과"(등급 A)판정한다.
2
- // 검사 폴더는 config.tests.dir, 리포트는 config.tests.report(메타는 같은 이름의 .json). 로컬 스택이 필요한 검사면 개발 머신에서 돌린다.
3
- import { spawnSync, execFileSync } from 'node:child_process';
4
- import { mkdirSync, writeFileSync, readdirSync, existsSync } from 'node:fs';
1
+ // 단위 검사를 JUnit 리포트와 결과 JSON으로 남긴다. 상황판이 결과 JSON(없으면 JUnit)을 읽어 검사 신호와 등급 A를 정한다.
2
+ // 검사 폴더는 config.tests.dir, 리포트는 config.tests.report(메타는 같은 이름의 .json), 결과 JSON은 같은 폴더의 test-results.json.
3
+ // JUnit 리포터(1.x 호환)와 livemap 리포터를 한 번에 붙여 돌리고, 결과 JSON의 이 실행(러너 node·출처 livemap test-report)을 바꾼다.
4
+ // 종료 코드는 러너 종료 코드다. 로컬 스택이 필요한 검사면 개발 머신에서 돌린다.
5
+ import { spawnSync } from 'node:child_process';
6
+ import { mkdirSync, writeFileSync, readdirSync, existsSync, readFileSync, rmSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
5
8
  import { dirname, join, resolve } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { gitStamp, importRuns, resultsPath, toRootPath, writeResults, DEFAULT_REPORT } from './results.mjs';
11
+
12
+ const here = dirname(fileURLToPath(import.meta.url));
13
+ export const NODE_REPORTER = join(here, 'reporters', 'node-results.mjs');
14
+ export const TEST_REPORT_SOURCE = 'livemap test-report';
6
15
 
7
16
  export function testReport({ root, cfg }) {
8
17
  const dir = cfg?.tests?.dir || 'tests';
9
- const report = cfg?.tests?.report || 'map/.out/junit.xml';
18
+ const report = cfg?.tests?.report || DEFAULT_REPORT;
10
19
  const abs = (p) => resolve(root, p);
11
20
  if (!existsSync(abs(dir))) { console.error(`검사 폴더 없음: ${dir} (config.tests.dir)`); return 2; }
12
21
  mkdirSync(dirname(abs(report)), { recursive: true });
13
22
  // Node 22.22는 디렉토리 인자를 검사 하나의 실패로 취급하므로 파일 목록을 넘긴다.
14
23
  const files = readdirSync(abs(dir)).filter((f) => f.endsWith('.test.mjs')).map((f) => join(dir, f));
15
- const r = spawnSync('node', ['--test', '--test-concurrency=1', '--test-reporter=junit', `--test-reporter-destination=${report}`, ...files], { stdio: 'inherit', cwd: root });
16
- let sha = null;
17
- try { sha = execFileSync('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); } catch { /* git 저장소가 아니면 기준 커밋 없음 */ }
24
+ const stamp = gitStamp(root);
25
+ const at = new Date().toISOString();
26
+ const tmp = join(tmpdir(), `livemap-node-results-${process.pid}-${Date.now()}.json`);
27
+ // node --test 안에서 불렸을 때(검사·CI 래퍼) 자식 러너가 재귀 실행으로 파일을 건너뛰지 않게 문맥 변수를 뺀다
28
+ const env = { ...process.env };
29
+ delete env.NODE_TEST_CONTEXT;
30
+ const r = spawnSync(process.execPath, ['--test', '--test-concurrency=1',
31
+ '--test-reporter=junit', `--test-reporter-destination=${report}`,
32
+ `--test-reporter=${NODE_REPORTER}`, `--test-reporter-destination=${tmp}`, ...files], { stdio: 'inherit', cwd: root, env });
33
+ const exit = r.status ?? 1;
18
34
  const meta = report.replace(/\.xml$/, '.json');
19
- writeFileSync(abs(meta), JSON.stringify({ sha, at: new Date().toISOString(), exit: r.status }, null, 2));
20
- console.log(`test report ${report} (sha ${sha ? sha.slice(0, 7) : '-'}, exit ${r.status})`);
21
- return r.status ?? 1;
35
+ writeFileSync(abs(meta), JSON.stringify({ sha: stamp.sha, at, exit: r.status }, null, 2));
36
+ // 리포터 출력이 없으면(러너가 뜨지 못함) 파일 없이 종료 코드만 남긴다
37
+ let run = { runner: 'node', sha: stamp.sha, dirtyPaths: stamp.dirtyPaths, at, outsideRoot: 0, files: [] };
38
+ try { run = JSON.parse(readFileSync(tmp, 'utf8')).runs[0]; } catch { /* 위 기본 실행을 쓴다 */ } finally { rmSync(tmp, { force: true }); }
39
+ const results = resultsPath(cfg);
40
+ writeResults(abs(results), { ...run, source: TEST_REPORT_SOURCE, exit });
41
+ console.log(`test report → ${report}, ${results} (sha ${stamp.sha ? stamp.sha.slice(0, 7) : '-'}, exit ${r.status})`);
42
+ return exit;
43
+ }
44
+
45
+ // livemap test-report --import <파일> [--sha <커밋>]: 러너를 돌리지 않고 다른 러너의 출력을 결과 JSON에 넣는다.
46
+ // 판별할 수 없거나 파일이 없으면 exit 2이고 결과 JSON을 건드리지 않는다.
47
+ export function importReport({ root, cfg, file, sha, cwd = process.cwd() }) {
48
+ if (!file || file.startsWith('--')) { console.error('usage: livemap test-report --import <파일> [--sha <커밋>]'); return 2; }
49
+ const abs = resolve(cwd, file);
50
+ if (!existsSync(abs)) { console.error(`가져올 파일 없음: ${file}`); return 2; }
51
+ const source = toRootPath(root, abs) ?? abs;
52
+ const runs = importRuns({ root, text: readFileSync(abs, 'utf8'), source, sha });
53
+ if (!runs) { console.error(`형식을 판별할 수 없음: ${file} (livemap 리포터 출력·Playwright JSON·JUnit XML)`); return 2; }
54
+ const results = resultsPath(cfg);
55
+ writeResults(resolve(root, results), ...runs);
56
+ for (const r of runs) console.log(`test report import → ${results}: ${r.runner} ${r.source} (파일 ${r.files.length}, sha ${r.sha ? r.sha.slice(0, 7) : '-'})`);
57
+ return 0;
22
58
  }