@dhlab/e2e-autogen
Version:
Google 스프레드시트 기반 시나리오를 Playwright 테스트 스텁 코드로 자동 생성하고, 테스트 실행 결과를 다시 시트에 업데이트하는 CLI 도구
311 lines (264 loc) • 8.09 kB
text/typescript
import type {
FullConfig,
TestResult as PlaywrightTestResult,
Reporter,
Suite,
TestCase,
TestStep,
} from "@playwright/test/reporter";
import fs from "fs";
import path from "path";
class E2EAutogenPlaywrightReporter implements Reporter {
private outputFile: string;
private suites: TSuiteGroup[] = [];
constructor(options?: { outputFile?: string }) {
this.outputFile = options?.outputFile
? path.resolve(process.cwd(), options.outputFile)
: path.resolve(process.cwd(), "e2e-autogen-report.json");
}
onBegin(config: FullConfig, suite: Suite) {
this.suites = [];
}
async onTestEnd(test: TestCase, result: PlaywrightTestResult) {
const file = test.location.file;
const suiteTitle = test.parent?.title ?? "";
const scenarioId = extractScenarioId(test.title);
const specTitle =
test.titlePath().find((t) => t.includes("TC-")) || test.title;
const description = removeTestIdFromTitle(specTitle);
const manualSteps = extractManualSteps(result).filter((s) =>
s.testId.startsWith(scenarioId)
);
// Step 분석
const flakySteps = getFlakyStepTitles(result);
const steps = (result.steps ?? [])
.map((step) => {
const testId = extractTestId(step.title);
const duration = step.duration;
const error = step.error?.message;
if (!testId) {
return null;
}
const manualMatch = manualSteps.find((m) => m.testId === testId);
if (manualMatch) {
return {
title: step.title,
testId,
duration,
...(error ? { error } : {}),
status: "manual_only",
} as TStepResult;
}
if (flakySteps.includes(step.title)) {
return {
title: step.title,
testId,
duration,
...(error ? { error } : {}),
status: "flaky",
} as TStepResult;
}
if (duration === 0) {
return {
title: step.title,
testId,
duration,
...(error ? { error } : {}),
status: "not_executed",
} as TStepResult;
}
return {
title: step.title,
testId,
duration,
...(error ? { error } : {}),
status: error ? "fail" : "pass",
} as TStepResult;
})
.filter((s): s is TStepResult => s !== null);
const entry: TTestEntry = {
title: test.title,
status: result.status,
duration: result.duration,
error: result.error?.message,
steps,
};
let suiteGroup = this.suites.find((s) => s.file === file);
if (!suiteGroup) {
suiteGroup = { title: suiteTitle, file, specs: [] };
this.suites.push(suiteGroup);
}
let spec = suiteGroup.specs.find((s) => s.scenarioId === scenarioId);
if (!spec) {
spec = {
title: specTitle,
scenarioId,
description,
file,
tests: [],
pass: [],
fail: [],
flaky: [],
manual_only: [],
not_executed: [],
};
suiteGroup.specs.push(spec);
}
spec.tests.push(entry);
// ----- manual_only 기록 -----
for (const step of steps) {
if (step.status !== "manual_only" || !step.testId) continue;
const reason =
manualSteps.find((m) => m.testId === step.testId)?.description ?? "";
const already = spec.manual_only.find((m) => m[0] === step.testId);
if (!already) spec.manual_only.push([step.testId, reason]);
}
}
async onEnd() {
// 최종 분류 계산
for (const suite of this.suites) {
for (const spec of suite.specs) {
updateSpecClassification(spec);
}
}
fs.writeFileSync(
this.outputFile,
JSON.stringify({ suites: this.suites }, null, 2)
);
}
printsToStdio() {
return true;
}
}
export default E2EAutogenPlaywrightReporter;
type TStepStatus = "pass" | "fail" | "flaky" | "manual_only" | "not_executed";
type TStepResult = {
title: string;
testId: string;
status: TStepStatus;
duration: number;
error?: string;
};
type TTestEntry = {
title: string;
status: string;
duration: number;
error?: string;
steps: TStepResult[];
};
type TSpecGroup = {
title: string;
scenarioId: string;
description: string;
file: string;
tests: TTestEntry[];
pass: string[];
fail: string[];
flaky: string[];
manual_only: [string, string][];
not_executed: string[];
};
type TSuiteGroup = {
title: string;
file: string;
specs: TSpecGroup[];
};
// ───── 유틸 함수들 ─────
function extractTestId(title: string): string {
const match = title.match(/\[(TC-[\d.]+)]/);
return match ? match[1] : "";
}
function extractScenarioId(title: string): string {
const match = title.match(/\[(TC-\d+\.\d+)/);
return match ? match[1] : "";
}
function removeTestIdFromTitle(title: string): string {
return title.replace(/\[TC-[\d.]+]\s*/, "").trim();
}
function getFlakyStepTitles(result: PlaywrightTestResult): string[] {
const map: Record<string, string[]> = {};
const retryDetails =
(
result as unknown as {
retryDetails?: { steps: TestStep[] }[];
}
).retryDetails ?? [];
for (const attempt of retryDetails) {
for (const step of attempt.steps) {
if (!map[step.title]) map[step.title] = [];
map[step.title].push(step.error ? "fail" : "pass");
}
}
for (const step of result.steps ?? []) {
if (!map[step.title]) map[step.title] = [];
map[step.title].push(step.error ? "fail" : "pass");
}
return Object.entries(map)
.filter(
([, statuses]) => statuses.includes("fail") && statuses.includes("pass")
)
.map(([title]) => title);
}
type TManualStep = {
testId: string;
description: string;
};
/**
* result.annotations 에 기록된 manual_only 정보를 추출한다.
* description 형식: `${testId}|${reason}`
*/
function extractManualSteps(result: PlaywrightTestResult): TManualStep[] {
const annotations = (result as any).annotations ?? [];
return annotations
.filter((a: any) => a.type === "manual_only")
.map((a: any) => {
const [testId, ...descParts] = (a.description ?? "").split("|");
return { testId, description: descParts.join("|") } as TManualStep;
})
.filter((m: TManualStep) => m.testId);
}
function updateSpecClassification(spec: TSpecGroup) {
// 초기화
spec.pass = [];
spec.fail = [];
spec.flaky = [];
spec.not_executed = [];
const stepStatusMap: Record<string, TStepStatus[]> = {};
// 시도별 Step 상태 수집
for (const testEntry of spec.tests) {
for (const step of testEntry.steps) {
if (!step.testId) continue;
if (!stepStatusMap[step.testId]) stepStatusMap[step.testId] = [];
stepStatusMap[step.testId].push(step.status);
}
}
const addUnique = (arr: string[], id: string) => {
if (!arr.includes(id)) arr.push(id);
};
for (const [testId, statuses] of Object.entries(stepStatusMap)) {
const uniqStatuses = Array.from(new Set(statuses));
// 1) flaky 위치가 최우선 (시도 내 pass+fail 혼재 또는 step 자체가 flaky)
if (uniqStatuses.includes("flaky")) {
addUnique(spec.flaky, testId);
continue;
}
const hasPass = uniqStatuses.includes("pass");
const hasFail = uniqStatuses.includes("fail");
const hasNotExecuted = uniqStatuses.includes("not_executed");
if (hasPass && hasFail) {
// 재시도 혼합 결과
addUnique(spec.flaky, testId);
} else if (hasFail) {
addUnique(spec.fail, testId);
} else if (hasPass) {
addUnique(spec.pass, testId);
} else if (hasNotExecuted) {
addUnique(spec.not_executed, testId);
}
}
// 중복 방지 보증 (예외 케이스 대비)
spec.pass = Array.from(new Set(spec.pass));
spec.fail = Array.from(new Set(spec.fail));
spec.flaky = Array.from(new Set(spec.flaky));
spec.not_executed = Array.from(new Set(spec.not_executed));
}