diff --git a/.github/screenshots/package.json b/.github/screenshots/package.json new file mode 100644 index 0000000..1847e29 --- /dev/null +++ b/.github/screenshots/package.json @@ -0,0 +1,8 @@ +{ + "name": "labflow-readme-screenshots", + "private": true, + "type": "module", + "dependencies": { + "playwright": "1.56.0" + } +} diff --git a/.github/screenshots/shots.mjs b/.github/screenshots/shots.mjs new file mode 100644 index 0000000..ca7c2d6 --- /dev/null +++ b/.github/screenshots/shots.mjs @@ -0,0 +1,28 @@ +// What the README screenshots show and how to tell each page has finished loading. +// frontend/tests/readme-screenshots.test.mjs checks every selector here still exists in +// the app, so a UI change that would break the screenshot run fails the normal test suite. + +export const LOGIN = { + ready: "[data-demo]", + username: 'input[autocomplete="username"]', + password: 'input[autocomplete="current-password"]', + submit: ".login-submit", + signedIn: "[data-nav]", +}; + +/** Demo accounts seeded by DemoUserInitializer, one browser session each. */ +export const SESSIONS = [ + { user: null, shots: [{ file: "01-login.png", ready: "[data-demo]", fullPage: false }] }, + { + user: "admin", password: "admin123", + shots: [ + { file: "02-dashboard-admin.png", ready: ".row-item" }, + { file: "03-equipment.png", nav: "equipment", ready: ".equipment-table tbody tr" }, + { file: "05-workorders.png", nav: "workorders", ready: "[data-workorder-id]" }, + { file: "06-audit.png", nav: "audit", ready: "[data-audit-id]" }, + ], + }, + { user: "teacher", password: "teacher123", shots: [{ file: "04-reservations.png", nav: "reservations", ready: ".week-block" }] }, + { user: "student", password: "student123", shots: [{ file: "07-dashboard-student.png", ready: ".row-item" }] }, + { user: "technician", password: "tech123", shots: [{ file: "08-workorders-technician.png", nav: "workorders", ready: "[data-workorder-id]" }] }, +]; diff --git a/.github/screenshots/take.mjs b/.github/screenshots/take.mjs new file mode 100644 index 0000000..5103c82 --- /dev/null +++ b/.github/screenshots/take.mjs @@ -0,0 +1,43 @@ +// Logs in as each demo role and captures the README screenshots: 1440 wide, 2x, Asia/Shanghai. +// Usage: node take.mjs ; playwright is installed by the workflow. +import { chromium } from "playwright"; +import { LOGIN, SESSIONS } from "./shots.mjs"; + +const [out, base = "http://127.0.0.1:13000/"] = process.argv.slice(2); +const browser = await chromium.launch(); +let failed = 0; + +for (const session of SESSIONS) { + const context = await browser.newContext({ + viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2, locale: "zh-CN", timezoneId: "Asia/Shanghai", + }); + const page = await context.newPage(); + page.on("pageerror", (error) => console.error(`[${session.user ?? "login"}] page error:`, String(error))); + try { + await page.goto(base, { waitUntil: "networkidle" }); + await page.waitForSelector(LOGIN.ready, { timeout: 60000 }); + if (session.user) { + await page.fill(LOGIN.username, session.user); + await page.fill(LOGIN.password, session.password); + await page.click(LOGIN.submit); + await page.waitForSelector(LOGIN.signedIn, { timeout: 60000 }); + } + for (const shot of session.shots) { + if (shot.nav) await page.click(`[data-nav="${shot.nav}"]`); + await page.waitForSelector(shot.ready, { timeout: 60000 }); + await page.waitForLoadState("networkidle"); + await page.waitForTimeout(1200); + // Park the pointer in a corner so no hover state ends up in the picture. + await page.mouse.move(2, 898); + await page.screenshot({ path: `${out}/${shot.file}`, fullPage: shot.fullPage ?? true }); + console.log("saved", shot.file); + } + } catch (error) { + failed += 1; + console.error(`error in ${session.user ?? "login"}:`, String(error).split("\n")[0]); + } + await context.close(); +} + +await browser.close(); +process.exit(failed ? 1 : 0); diff --git a/.github/workflows/readme-screenshots.yml b/.github/workflows/readme-screenshots.yml new file mode 100644 index 0000000..4eb4015 --- /dev/null +++ b/.github/workflows/readme-screenshots.yml @@ -0,0 +1,95 @@ +# Retakes the README screenshots from a real running stack (default H2 profile + demo seed, +# same as .\start-fullstack.ps1) and commits them back to the branch it ran on. +# +# How to use: push your branch, then run it by hand on that branch (Actions → README 截图 → +# Run workflow, or `gh workflow run readme-screenshots.yml --ref `), then open a PR. +# Manual only: dates on the pages change every day, so every run produces new pixels and a new +# commit; running on each push would add a commit after every push. The commit it pushes uses +# the workflow token, so it does not trigger CI — push any later change yourself to re-run CI. +name: README 截图 + +on: + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: readme-screenshots-${{ github.ref }} + cancel-in-progress: true + +jobs: + shots: + name: 起栈并截图 + runs-on: ubuntu-latest + env: + TZ: Asia/Shanghai + steps: + - name: 不在 main 上直接提交 + if: github.ref == 'refs/heads/main' + run: | + echo "::error::请在分支上运行(例如 chore/readme-screenshots-xxx),截图会提交回该分支,再走 PR 合入。" + exit 1 + + - uses: actions/checkout@v4 + + - name: 准备 Java 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + cache: maven + + - name: 准备 Node 22 + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: 中文字体 + run: sudo apt-get update -qq && sudo apt-get install -y -qq fonts-noto-cjk + + - name: 打包后端 + run: ./mvnw -B --no-transfer-progress -DskipTests package + + - name: 构建前端 + working-directory: frontend + run: | + npm ci --no-audit --no-fund + npx vinext build + + - name: 安装截图工具 + working-directory: .github/screenshots + run: | + npm install --no-audit --no-fund + npx playwright install --with-deps chromium + + # One step: background processes started in an earlier step are not guaranteed to survive. + - name: 起栈并截图 + env: + BACKEND_BASE_URL: http://127.0.0.1:18080 + run: | + set +e + setsid nohup java -jar target/*.jar --server.port=18080 > backend.log 2>&1 < /dev/null & + for i in $(seq 1 90); do curl -fs -o /dev/null http://127.0.0.1:18080/actuator/health && break; sleep 2; done + (cd frontend && setsid nohup npx vinext start --port 13000 > ../frontend.log 2>&1 < /dev/null &) + for i in $(seq 1 60); do curl -fs -o /dev/null http://127.0.0.1:13000/ && break; sleep 2; done + node .github/screenshots/take.mjs "$GITHUB_WORKSPACE/docs/screenshots" http://127.0.0.1:13000/ 2>&1 | tee shots.log + code=${PIPESTATUS[0]} + # Job logs are not always readable from where this is reviewed, so surface the result as annotations. + grep -E "error|saved" shots.log | sed 's/^/::notice::/' + exit $code + + - name: 失败时打印日志 + if: failure() + run: | + tail -n 15 backend.log | sed 's/^/::warning::backend /' || true + tail -n 15 frontend.log | sed 's/^/::warning::frontend /' || true + + - name: 提交截图 + run: | + git config user.name "Emrys" + git config user.email "219520811+Emryswu@users.noreply.github.com" + git add docs/screenshots + git diff --cached --quiet && { echo "::notice::截图没有变化"; exit 0; } + git commit -m "docs(readme): 重拍界面截图(Actions 实拍)" + git push diff --git a/AGENTS.md b/AGENTS.md index 4b77b69..1e29949 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,7 @@ | `benchmark/` | 并发压测脚本(Python)与结果,`RESULTS.md` 是对外引用的数据来源 | | `scripts/`、根目录 `*.ps1` | Windows 本地起栈、生产 profile 起停与验证脚本 | | `docs/` | `concurrency-notes.md` 并发设计说明,`screenshots/` 是 README 用图 | +| `.github/screenshots/` | README 截图的页面清单(`shots.mjs`)与截图脚本,由 `readme-screenshots.yml` 运行 | ## 命令 @@ -35,6 +36,11 @@ CI(`ci.yml`)对每个指向 main 的 PR 跑以下全部检查。 - 测试:`npm test`,先 `vinext build` 生产构建,再跑 `tests/**/*.test.mjs`。CI 跑在 UTC,断言渲染出的日期或时分时,测试数据用 `new Date(年, 月, 日, 时, 分)` 按本地时间构造,不要写死带时区的字符串;本地可用 `TZ=UTC npm test` 复查。 - 本地开发:`npm run dev`,后端地址见 `frontend/.env.example`(默认 `http://127.0.0.1:18080`)。 +**README 截图** + +- 截图只来自真实运行的服务,不用假后端或手工拼图。改了界面后先推送自己的分支,再对该分支手动运行「README 截图」workflow(`gh workflow run readme-screenshots.yml --ref <分支>`),它会起栈、按角色截图并提交回该分支,不能在 main 上运行。截图提交由 workflow 令牌推送,不会触发 CI,之后再推一次自己的提交即可让 CI 覆盖最新状态。 +- 要拍的页面和等待的选择器写在 `.github/screenshots/shots.mjs`;`frontend/tests/readme-screenshots.test.mjs` 检查类名和 `data-*` 属性仍存在、清单与 README 引用的图一致;`rendered-html.test.mjs` 用完整登录选择器查询真实渲染的表单,覆盖 `autocomplete` 的属性值。改界面时如果它们失败,同步改 `shots.mjs`。 + **提交信息检查**(在仓库根目录下) - 测试:`node --test .github/scripts/check-commit-messages.test.mjs` diff --git a/README.md b/README.md index a69a581..70801c6 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ > 以下截图全部来自真实运行的服务:由 GitHub Actions 打包后端、以默认 H2 profile 启动(与 `.\start-fullstack.ps1` 相同配置,零外部中间件), > 再起前端、按角色登录截取。数据是启动时写入的演示种子:8 台设备、4 条预约、3 条工单与对应审计记录。 +> 截图由「README 截图」workflow(`.github/workflows/readme-screenshots.yml`)生成,界面改动后在 Actions 里对自己的分支手动运行即可重拍。 **运营总览(管理员)**:四张数字卡(设备在册、待审批、开放工单、待处理差账);有流量的指标带近 7 天柱状图和较前 7 天的环比,数据来自 `/api/dashboard/trends`,按服务器本地零点分桶。下面是设备状态环形图、可以直接通过 / 拒绝的待审批预约和开放工单。 @@ -21,7 +22,7 @@ ![预约审批](docs/screenshots/04-reservations.png) -**操作审计**:谁、在什么角色下、对哪个对象做了什么,全部留痕,仅管理员可见。 +**操作审计**:谁、在什么角色下、对哪个对象做了什么,全部留痕,仅管理员可见;可按角色(含定时任务、支付回调等「系统」操作)和操作类型筛选。 ![操作审计](docs/screenshots/06-audit.png) diff --git a/docs/screenshots/01-login.png b/docs/screenshots/01-login.png index 9d84f02..9fc49b1 100644 Binary files a/docs/screenshots/01-login.png and b/docs/screenshots/01-login.png differ diff --git a/docs/screenshots/02-dashboard-admin.png b/docs/screenshots/02-dashboard-admin.png index 122ef76..4913bce 100644 Binary files a/docs/screenshots/02-dashboard-admin.png and b/docs/screenshots/02-dashboard-admin.png differ diff --git a/docs/screenshots/03-equipment.png b/docs/screenshots/03-equipment.png index cea1713..d0a995e 100644 Binary files a/docs/screenshots/03-equipment.png and b/docs/screenshots/03-equipment.png differ diff --git a/docs/screenshots/04-reservations.png b/docs/screenshots/04-reservations.png index 63235d2..fade2b8 100644 Binary files a/docs/screenshots/04-reservations.png and b/docs/screenshots/04-reservations.png differ diff --git a/docs/screenshots/05-workorders.png b/docs/screenshots/05-workorders.png index 965442e..92408a3 100644 Binary files a/docs/screenshots/05-workorders.png and b/docs/screenshots/05-workorders.png differ diff --git a/docs/screenshots/06-audit.png b/docs/screenshots/06-audit.png index 7f3a58d..b0d4c02 100644 Binary files a/docs/screenshots/06-audit.png and b/docs/screenshots/06-audit.png differ diff --git a/docs/screenshots/07-dashboard-student.png b/docs/screenshots/07-dashboard-student.png index eacfcf9..80756b2 100644 Binary files a/docs/screenshots/07-dashboard-student.png and b/docs/screenshots/07-dashboard-student.png differ diff --git a/docs/screenshots/08-workorders-technician.png b/docs/screenshots/08-workorders-technician.png index 74da016..eb6f67e 100644 Binary files a/docs/screenshots/08-workorders-technician.png and b/docs/screenshots/08-workorders-technician.png differ diff --git a/frontend/tests/readme-screenshots.test.mjs b/frontend/tests/readme-screenshots.test.mjs new file mode 100644 index 0000000..e4020d6 --- /dev/null +++ b/frontend/tests/readme-screenshots.test.mjs @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +// The README screenshot workflow (.github/workflows/readme-screenshots.yml) waits on these +// selectors. It only runs on demand, so without this test a UI change would leave it broken +// until the next retake — which is how the previous one went stale. +const { LOGIN, SESSIONS } = await import(new URL("../../.github/screenshots/shots.mjs", import.meta.url)); + +const appDirectory = fileURLToPath(new URL("../app/", import.meta.url)); +const readmePath = fileURLToPath(new URL("../../README.md", import.meta.url)); +const screenshotDirectory = fileURLToPath(new URL("../../docs/screenshots/", import.meta.url)); + +function sources(directory) { + return readdirSync(directory).flatMap((name) => { + const path = join(directory, name); + if (statSync(path).isDirectory()) return sources(path); + return name.endsWith(".tsx") ? [readFileSync(path, "utf8")] : []; + }); +} + +/** The class names and data attributes a selector depends on. */ +function hooks(selector) { + return [ + ...[...selector.matchAll(/\.([\w-]+)/g)].map((match) => ({ kind: "class", name: match[1] })), + ...[...selector.matchAll(/\[(data-[\w-]+)/g)].map((match) => ({ kind: "attribute", name: match[1] })), + ]; +} + +// Full login selectors (including autocomplete values) are checked against the rendered +// login form in rendered-html.test.mjs; this scan only checks class/data-attribute hooks. +test("screenshot selector class and data-attribute hooks still exist in the app", () => { + const code = sources(appDirectory).join("\n"); + const selectors = [ + ...Object.values(LOGIN), + ...SESSIONS.flatMap((session) => session.shots.flatMap((shot) => [shot.ready, shot.nav && `[data-nav="${shot.nav}"]`])), + ].filter(Boolean); + const missing = []; + for (const selector of selectors) { + for (const hook of hooks(selector)) { + const found = hook.kind === "class" + ? new RegExp(`className=[{"\`][^>]*\\b${hook.name}\\b`).test(code) + : code.includes(`${hook.name}=`); + if (!found) missing.push(`${selector} → ${hook.name}`); + } + } + assert.deepEqual(missing, [], "update .github/screenshots/shots.mjs to match the UI"); +}); + +test("the shot list covers exactly the screenshots the README shows", () => { + const readme = readFileSync(readmePath, "utf8"); + const shown = [...readme.matchAll(/docs\/screenshots\/([\w-]+\.png)/g)].map((match) => match[1]).sort(); + const taken = SESSIONS.flatMap((session) => session.shots.map((shot) => shot.file)).sort(); + assert.deepEqual(taken, shown); + const onDisk = readdirSync(screenshotDirectory).filter((name) => name.endsWith(".png")).sort(); + assert.deepEqual(onDisk, shown, "every file in docs/screenshots is used by the README"); +}); diff --git a/frontend/tests/rendered-html.test.mjs b/frontend/tests/rendered-html.test.mjs index 1186a54..f5544a1 100644 --- a/frontend/tests/rendered-html.test.mjs +++ b/frontend/tests/rendered-html.test.mjs @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; import { readFile, readdir } from "node:fs/promises"; import test from "node:test"; +import { JSDOM } from "jsdom"; +import { LOGIN } from "../../.github/screenshots/shots.mjs"; async function render() { const workerUrl = new URL("../dist/server/index.js", import.meta.url); @@ -53,6 +55,24 @@ test("server-renders LabFlow login shell", async () => { assert.doesNotMatch(html, /Your site is taking shape|react-loading-skeleton|Codex is building/i); }); +test("README screenshot login selectors match the rendered login form", async (t) => { + const response = await render(); + assert.equal(response.status, 200); + const dom = new JSDOM(await response.text()); + t.after(() => dom.window.close()); + const document = dom.window.document; + + assert.ok(document.querySelector(LOGIN.ready), `login readiness selector: ${LOGIN.ready}`); + const form = document.querySelector(LOGIN.submit)?.closest("form"); + assert.ok(form, `login submit selector must belong to a form: ${LOGIN.submit}`); + for (const key of ["username", "password", "submit"]) { + assert.equal(form.querySelectorAll(LOGIN[key]).length, 1, `${key} selector must match exactly one login control: ${LOGIN[key]}`); + } + assert.equal(form.querySelector(LOGIN.username).tagName, "INPUT"); + assert.equal(form.querySelector(LOGIN.password).type, "password"); + assert.equal(form.querySelector(LOGIN.submit).type, "submit"); +}); + test("keeps LabFlow app source structure without starter skeleton", async () => { const [page, layout, packageJson, appEntries] = await Promise.all([ readFile(new URL("../app/page.tsx", import.meta.url), "utf8"),