Four things an author could not do, and the check that should have caught them. A Decision could not be deleted. Case, Reference and Question all could, so an author who opened a decision draft had no way to close it. The contract gained the operation and the list now offers it for every kind. Its path carries the project because a decision belongs to one; a row with no project says so rather than failing. Assets could only be managed by leaving the document. The picker now deletes one in place — the server still refuses an asset a document uses — so a mistaken upload does not cost the author their editing session. Zoom was decided for the author and could not be changed: only a DIAGRAM got it, so a screenshot uploaded as an image or attachment went in with zoom off and no way to turn it on. It now defaults on for images and the picker offers the choice. The toggle is a picker control, not a document field, and carries its own class — wearing the field class put it in the editor's field list. `scripts/smoke/production-sweep.ts` walks every public and Studio screen and the document flow, reporting console errors, failed API calls and error text. It exists because verifying only the screen I had just changed is what let broken screens reach production repeatedly; this runs before a deploy, not after a report.
108 lines
5.0 KiB
TypeScript
108 lines
5.0 KiB
TypeScript
/**
|
|
* 배포본 전수 확인.
|
|
*
|
|
* 이 파일은 절차 실패에서 나왔다 — 고친 화면만 확인하고 배포해서, 나머지가 깨진 것은 매번
|
|
* 사용자가 먼저 발견했다. 운영 환경이므로 배포 전에 모든 화면을 한 번씩 열어 보는 것이 맞다.
|
|
*
|
|
* 각 화면에서 보는 것: main 이 그려졌는지, 콘솔 오류, 4xx/5xx API 응답, 그리고 화면에 뜬
|
|
* 오류 문구. 하나라도 있으면 그 화면을 실패로 적고 끝까지 진행한다.
|
|
*
|
|
* SPW=<비밀번호> node scripts/smoke/production-sweep.mjs [origin]
|
|
*/
|
|
import process from "node:process";
|
|
|
|
import { chromium, type Page } from "@playwright/test";
|
|
|
|
const ORIGIN = process.argv[2] ?? "https://hyeonworks.com";
|
|
const PW = process.env.SPW;
|
|
const ERROR_TEXT =
|
|
/요청을 처리하지 못했습니다|지원 정보 확인|표시할 수 없습니다|불러오지 못했습니다|화면을 찾을 수 없습니다|Not Found/;
|
|
|
|
const results: { label: string; path: string; problems: string[] }[] = [];
|
|
|
|
async function visit(
|
|
page: Page,
|
|
label: string,
|
|
path: string,
|
|
{ expectMain = true }: { expectMain?: boolean } = {},
|
|
) {
|
|
const problems: string[] = [];
|
|
const onConsole = (m: { type(): string; text(): string }) => { if (m.type() === "error" && !/401/.test(m.text())) problems.push(`console: ${m.text().slice(0, 120)}`); };
|
|
const onResponse = (r: {
|
|
url(): string;
|
|
status(): number;
|
|
request(): { method(): string };
|
|
}) => {
|
|
const u = new URL(r.url()).pathname;
|
|
if (r.status() >= 400 && u.startsWith("/api") && !u.includes("/studio/session")) {
|
|
problems.push(`${r.status()} ${r.request().method()} ${u}`);
|
|
}
|
|
};
|
|
page.on("console", onConsole);
|
|
page.on("response", onResponse);
|
|
try {
|
|
await page.goto(ORIGIN + path, { waitUntil: "domcontentloaded", timeout: 45000 });
|
|
await page.waitForTimeout(3000);
|
|
const main = await page.locator("main").count();
|
|
const body = (await page.locator("body").innerText().catch(() => "")).replace(/\s+/g, " ");
|
|
if (expectMain && main === 0) problems.push("main 없음");
|
|
const shown = body.match(ERROR_TEXT);
|
|
if (shown) problems.push(`화면 문구: ${shown[0]}`);
|
|
} catch (error) {
|
|
problems.push(`이동 실패: ${String(error).slice(0, 90)}`);
|
|
} finally {
|
|
page.off("console", onConsole);
|
|
page.off("response", onResponse);
|
|
}
|
|
results.push({ label, path, problems });
|
|
console.log(`${problems.length ? "✗" : "✓"} ${label.padEnd(22)} ${path}`);
|
|
for (const p of problems) console.log(` ${p}`);
|
|
}
|
|
|
|
const browser = await chromium.launch();
|
|
const page = await (await browser.newContext()).newPage();
|
|
|
|
console.log("=== 공개 ===");
|
|
for (const [label, path] of [
|
|
["홈", "/"], ["탐색", "/explore"], ["Case 목록", "/explore/cases"],
|
|
["프로젝트", "/projects"], ["변경 기록", "/releases"], ["릴리즈 상세", "/releases/0.1.0"],
|
|
["검색", "/search"], ["프로필", "/profile"],
|
|
]) await visit(page, label, path);
|
|
|
|
if (!PW) { console.log("\n(SPW 없음 — Studio 생략)"); await browser.close(); process.exit(0); }
|
|
|
|
console.log("\n=== 로그인 ===");
|
|
await page.goto(ORIGIN + "/studio", { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
await page.waitForTimeout(2500);
|
|
const start = page.getByRole("button", { name: /로그인 시작/ }).or(page.getByRole("link", { name: /로그인 시작/ }));
|
|
if (await start.count()) { await start.first().click(); await page.waitForTimeout(5000); }
|
|
await page.fill("#username", "hyeonworks");
|
|
await page.fill("#password", PW);
|
|
await page.click("#kc-login, input[type=submit], button[type=submit]");
|
|
await page.waitForTimeout(6000);
|
|
console.log(" 로그인 후:", page.url().replace(ORIGIN, "") || "/");
|
|
|
|
console.log("\n=== Studio ===");
|
|
for (const [label, path] of [
|
|
["대시보드", "/studio"], ["작업본", "/studio/documents"], ["새 문서", "/studio/documents/new"],
|
|
["게시 기록", "/studio/publications"], ["Asset", "/studio/assets"],
|
|
["주제·프로젝트", "/studio/taxonomy"], ["릴리즈", "/studio/releases"],
|
|
]) await visit(page, label, path);
|
|
|
|
// 작업본 하나를 골라 편집·검증·미리보기까지 연다
|
|
await page.goto(ORIGIN + "/studio/documents", { waitUntil: "domcontentloaded" });
|
|
await page.waitForTimeout(3000);
|
|
const href = await page.locator("a[href*='/studio/documents/'][href$='/edit']").first().getAttribute("href").catch(() => null);
|
|
if (href) {
|
|
const id = href.split("/")[3];
|
|
console.log("\n=== 문서 흐름 ===", id);
|
|
for (const [label, suffix] of [["편집", "/edit"], ["검증", "/validation"], ["미리보기", "/preview"], ["게시", "/publish"]])
|
|
await visit(page, label, `/studio/documents/${id}${suffix}`);
|
|
} else console.log("\n(편집 링크를 찾지 못해 문서 흐름 생략)");
|
|
|
|
await browser.close();
|
|
const failed = results.filter((r) => r.problems.length);
|
|
console.log(`\n=== 결과 === ${results.length - failed.length}/${results.length} 통과`);
|
|
for (const r of failed) console.log(` ✗ ${r.label} (${r.path}): ${r.problems.join(" | ").slice(0, 160)}`);
|
|
process.exit(failed.length ? 1 : 0);
|