Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/screenshots/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "labflow-readme-screenshots",
"private": true,
"type": "module",
"dependencies": {
"playwright": "1.56.0"
}
}
28 changes: 28 additions & 0 deletions .github/screenshots/shots.mjs
Original file line number Diff line number Diff line change
@@ -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]" }] },
];
43 changes: 43 additions & 0 deletions .github/screenshots/take.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Logs in as each demo role and captures the README screenshots: 1440 wide, 2x, Asia/Shanghai.
// Usage: node take.mjs <output dir> <frontend url>; 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);
95 changes: 95 additions & 0 deletions .github/workflows/readme-screenshots.yml
Original file line number Diff line number Diff line change
@@ -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 <branch>`), 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
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 运行 |

## 命令

Expand All @@ -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`
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,按服务器本地零点分桶。下面是设备状态环形图、可以直接通过 / 拒绝的待审批预约和开放工单。

Expand All @@ -21,7 +22,7 @@

![预约审批](docs/screenshots/04-reservations.png)

**操作审计**:谁、在什么角色下、对哪个对象做了什么,全部留痕,仅管理员可见。
**操作审计**:谁、在什么角色下、对哪个对象做了什么,全部留痕,仅管理员可见;可按角色(含定时任务、支付回调等「系统」操作)和操作类型筛选。

![操作审计](docs/screenshots/06-audit.png)

Expand Down
Binary file modified docs/screenshots/01-login.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/02-dashboard-admin.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/03-equipment.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/04-reservations.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/05-workorders.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/06-audit.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/07-dashboard-student.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/08-workorders-technician.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
59 changes: 59 additions & 0 deletions frontend/tests/readme-screenshots.test.mjs
Original file line number Diff line number Diff line change
@@ -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");
});
20 changes: 20 additions & 0 deletions frontend/tests/rendered-html.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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"),
Expand Down
Loading