paseo-req-kit 1.12.1 → 1.13.0-beta.2
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.
- package/CHANGELOG.md +33 -0
- package/README.md +8 -3
- package/bin/bootstrap.d.mts +13 -0
- package/bin/bootstrap.mjs +444 -0
- package/bin/req-docs.d.mts +5 -0
- package/bin/req-docs.mjs +169 -0
- package/bin/req.mjs +21 -1
- package/contracts/cli-json.md +2 -0
- package/docs/adr/0032-npm-only-install.md +27 -0
- package/docs/adr/0033-prototype-review-and-ui-check.md +28 -0
- package/docs/adr/README.md +2 -0
- package/docs/guide//344/275/277/347/224/250/350/200/205/346/214/207/345/215/227.md +8 -0
- package/docs/guide//345/256/211/350/243/205/344/270/216/345/215/207/347/272/247.md +22 -2
- package/docs/guide//347/273/264/346/212/244/350/200/205/346/214/207/345/215/227.md +1 -1
- package/docs/guide//351/241/271/347/233/256/345/256/232/345/210/266/344/270/216/350/277/220/350/241/214/350/247/204/345/210/231.md +7 -1
- package/engine/prompts/03-design.md +5 -1
- package/engine/prompts/04b-design-revise.md +1 -1
- package/engine/prompts/browser-verify.md +2 -2
- package/engine/prompts/ui-check.md +15 -0
- package/engine/src/api.ts +17 -2
- package/engine/src/config-view.ts +1 -1
- package/engine/src/pipeline.ts +15 -7
- package/engine/src/review-assets/app.css +420 -0
- package/engine/src/review-assets/app.html +95 -0
- package/engine/src/review-assets/app.js +954 -0
- package/engine/src/review.ts +570 -0
- package/engine/src/ui-check-page.js +208 -0
- package/engine/src/ui-check.ts +366 -0
- package/package.json +3 -1
- package/paseo-plugin/client/data.ts +12 -1
- package/paseo-plugin/client/detail.tsx +3 -0
- package/paseo-plugin/client/inbox.tsx +2 -0
- package/paseo-plugin/client/review-link.tsx +29 -0
- package/paseo-plugin/index.server.ts +2 -1
- package/paseo-plugin/package.json +1 -1
- package/paseo-plugin/server/handlers.ts +7 -1
- package/paseo-plugin/shared/contracts.ts +9 -0
- package/site-dist/404.html +27 -0
- package/site-dist/assets/app.DWPPPhFm.js +1 -0
- package/site-dist/assets/changelog.md.CoelFFCL.js +1 -0
- package/site-dist/assets/changelog.md.CoelFFCL.lean.js +1 -0
- package/site-dist/assets/chunks/@localSearchIndexroot.Hdq0-4L3.js +1 -0
- package/site-dist/assets/chunks/VPLocalSearchBox.D3_9dRGY.js +9 -0
- package/site-dist/assets/chunks/framework.B9qR_F1m.js +20 -0
- package/site-dist/assets/chunks/theme.Bm8aLiLN.js +2 -0
- package/site-dist/assets/guide_getting-started.md.B4GJeoPz.js +6 -0
- package/site-dist/assets/guide_getting-started.md.B4GJeoPz.lean.js +1 -0
- package/site-dist/assets/guide_how-it-works.md.D-kQsF1t.js +25 -0
- package/site-dist/assets/guide_how-it-works.md.D-kQsF1t.lean.js +1 -0
- package/site-dist/assets/guide_index.md.CLnGCmGW.js +1 -0
- package/site-dist/assets/guide_index.md.CLnGCmGW.lean.js +1 -0
- package/site-dist/assets/guide_install.md.DkCyZsOU.js +42 -0
- package/site-dist/assets/guide_install.md.DkCyZsOU.lean.js +42 -0
- package/site-dist/assets/guide_maintainer.md.RZIzyCTV.js +1 -0
- package/site-dist/assets/guide_maintainer.md.RZIzyCTV.lean.js +1 -0
- package/site-dist/assets/guide_phases.md.BpGAb1re.js +1 -0
- package/site-dist/assets/guide_phases.md.BpGAb1re.lean.js +1 -0
- package/site-dist/assets/guide_project.md.nGlTCo4D.js +77 -0
- package/site-dist/assets/guide_project.md.nGlTCo4D.lean.js +77 -0
- package/site-dist/assets/guide_user.md.tiHFxIsQ.js +1 -0
- package/site-dist/assets/guide_user.md.tiHFxIsQ.lean.js +1 -0
- package/site-dist/assets/index.md.Cf_sSf_f.js +1 -0
- package/site-dist/assets/index.md.Cf_sSf_f.lean.js +1 -0
- package/site-dist/assets/inter-italic-cyrillic-ext.r48I6akx.woff2 +0 -0
- package/site-dist/assets/inter-italic-cyrillic.By2_1cv3.woff2 +0 -0
- package/site-dist/assets/inter-italic-greek-ext.1u6EdAuj.woff2 +0 -0
- package/site-dist/assets/inter-italic-greek.DJ8dCoTZ.woff2 +0 -0
- package/site-dist/assets/inter-italic-latin-ext.CN1xVJS-.woff2 +0 -0
- package/site-dist/assets/inter-italic-latin.C2AdPX0b.woff2 +0 -0
- package/site-dist/assets/inter-italic-vietnamese.BSbpV94h.woff2 +0 -0
- package/site-dist/assets/inter-roman-cyrillic-ext.BBPuwvHQ.woff2 +0 -0
- package/site-dist/assets/inter-roman-cyrillic.C5lxZ8CY.woff2 +0 -0
- package/site-dist/assets/inter-roman-greek-ext.CqjqNYQ-.woff2 +0 -0
- package/site-dist/assets/inter-roman-greek.BBVDIX6e.woff2 +0 -0
- package/site-dist/assets/inter-roman-latin-ext.4ZJIpNVo.woff2 +0 -0
- package/site-dist/assets/inter-roman-latin.Di8DUHzh.woff2 +0 -0
- package/site-dist/assets/inter-roman-vietnamese.BjW4sHH5.woff2 +0 -0
- package/site-dist/assets/reference_adr_0001-step-agents-under-origin-chat.md.RtRTq7oi.js +1 -0
- package/site-dist/assets/reference_adr_0001-step-agents-under-origin-chat.md.RtRTq7oi.lean.js +1 -0
- package/site-dist/assets/reference_adr_0002-no-directory-switch-in-chat.md.BxtN-Aa2.js +1 -0
- package/site-dist/assets/reference_adr_0002-no-directory-switch-in-chat.md.BxtN-Aa2.lean.js +1 -0
- package/site-dist/assets/reference_adr_0003-script-engine-not-hub.md.66EgZtqx.js +1 -0
- package/site-dist/assets/reference_adr_0003-script-engine-not-hub.md.66EgZtqx.lean.js +1 -0
- package/site-dist/assets/reference_adr_0004-result-files.md.DbqcCozI.js +1 -0
- package/site-dist/assets/reference_adr_0004-result-files.md.DbqcCozI.lean.js +1 -0
- package/site-dist/assets/reference_adr_0005-no-default-branch-local-commits-only.md.CkImA4s6.js +1 -0
- package/site-dist/assets/reference_adr_0005-no-default-branch-local-commits-only.md.CkImA4s6.lean.js +1 -0
- package/site-dist/assets/reference_adr_0006-skill-install-targets.md.CwY8YepV.js +1 -0
- package/site-dist/assets/reference_adr_0006-skill-install-targets.md.CwY8YepV.lean.js +1 -0
- package/site-dist/assets/reference_adr_0007-own-interview-skill.md.CryhxRyG.js +1 -0
- package/site-dist/assets/reference_adr_0007-own-interview-skill.md.CryhxRyG.lean.js +1 -0
- package/site-dist/assets/reference_adr_0008-entries-do-not-call-entries.md.DsRWAgeA.js +1 -0
- package/site-dist/assets/reference_adr_0008-entries-do-not-call-entries.md.DsRWAgeA.lean.js +1 -0
- package/site-dist/assets/reference_adr_0009-versioned-install-layout.md.CSg1VqlW.js +1 -0
- package/site-dist/assets/reference_adr_0009-versioned-install-layout.md.CSg1VqlW.lean.js +1 -0
- package/site-dist/assets/reference_adr_0010-generated-feature-overview.md.D8F3MK-h.js +1 -0
- package/site-dist/assets/reference_adr_0010-generated-feature-overview.md.D8F3MK-h.lean.js +1 -0
- package/site-dist/assets/reference_adr_0011-resume-after-completion.md.BCrZrKjf.js +1 -0
- package/site-dist/assets/reference_adr_0011-resume-after-completion.md.BCrZrKjf.lean.js +1 -0
- package/site-dist/assets/reference_adr_0012-review-panel.md.k9rrHFFc.js +1 -0
- package/site-dist/assets/reference_adr_0012-review-panel.md.k9rrHFFc.lean.js +1 -0
- package/site-dist/assets/reference_adr_0013-no-release-tooling.md.BQeJ8dEI.js +1 -0
- package/site-dist/assets/reference_adr_0013-no-release-tooling.md.BQeJ8dEI.lean.js +1 -0
- package/site-dist/assets/reference_adr_0014-notifier-slot.md.C23QR1xO.js +1 -0
- package/site-dist/assets/reference_adr_0014-notifier-slot.md.C23QR1xO.lean.js +1 -0
- package/site-dist/assets/reference_adr_0015-incremental-continue.md.Buy7wwO8.js +1 -0
- package/site-dist/assets/reference_adr_0015-incremental-continue.md.Buy7wwO8.lean.js +1 -0
- package/site-dist/assets/reference_adr_0016-upgrade-from-tags.md.qfIW-tqJ.js +1 -0
- package/site-dist/assets/reference_adr_0016-upgrade-from-tags.md.qfIW-tqJ.lean.js +1 -0
- package/site-dist/assets/reference_adr_0017-use-paseo-capabilities.md.CjcGli87.js +1 -0
- package/site-dist/assets/reference_adr_0017-use-paseo-capabilities.md.CjcGli87.lean.js +1 -0
- package/site-dist/assets/reference_adr_0018-own-worktree-option.md.fVDpgGSE.js +1 -0
- package/site-dist/assets/reference_adr_0018-own-worktree-option.md.fVDpgGSE.lean.js +1 -0
- package/site-dist/assets/reference_adr_0019-parallel-orchestration.md.DrfrV0To.js +1 -0
- package/site-dist/assets/reference_adr_0019-parallel-orchestration.md.DrfrV0To.lean.js +1 -0
- package/site-dist/assets/reference_adr_0020-scheduling-through-the-chat.md.BqSemGFG.js +1 -0
- package/site-dist/assets/reference_adr_0020-scheduling-through-the-chat.md.BqSemGFG.lean.js +1 -0
- package/site-dist/assets/reference_adr_0021-npm-version-manager.md.CU3PG6y5.js +1 -0
- package/site-dist/assets/reference_adr_0021-npm-version-manager.md.CU3PG6y5.lean.js +1 -0
- package/site-dist/assets/reference_adr_0022-develop-and-verify-phases.md.C6fQDLq4.js +1 -0
- package/site-dist/assets/reference_adr_0022-develop-and-verify-phases.md.C6fQDLq4.lean.js +1 -0
- package/site-dist/assets/reference_adr_0023-types-gate-slices.md.D2E6yyYQ.js +1 -0
- package/site-dist/assets/reference_adr_0023-types-gate-slices.md.D2E6yyYQ.lean.js +1 -0
- package/site-dist/assets/reference_adr_0024-confirm-early-review-axes.md.FmK_ke0x.js +1 -0
- package/site-dist/assets/reference_adr_0024-confirm-early-review-axes.md.FmK_ke0x.lean.js +1 -0
- package/site-dist/assets/reference_adr_0025-joint-requirements.md.ChoKvMUx.js +1 -0
- package/site-dist/assets/reference_adr_0025-joint-requirements.md.ChoKvMUx.lean.js +1 -0
- package/site-dist/assets/reference_adr_0026-machine-records-and-api.md.veybjBPq.js +1 -0
- package/site-dist/assets/reference_adr_0026-machine-records-and-api.md.veybjBPq.lean.js +1 -0
- package/site-dist/assets/reference_adr_0027-paseo-plugin.md.BMX33ZNU.js +1 -0
- package/site-dist/assets/reference_adr_0027-paseo-plugin.md.BMX33ZNU.lean.js +1 -0
- package/site-dist/assets/reference_adr_0028-external-integrations.md.DtdMe8RK.js +1 -0
- package/site-dist/assets/reference_adr_0028-external-integrations.md.DtdMe8RK.lean.js +1 -0
- package/site-dist/assets/reference_adr_0029-requirements-from-cards.md.BHd1SQPe.js +1 -0
- package/site-dist/assets/reference_adr_0029-requirements-from-cards.md.BHd1SQPe.lean.js +1 -0
- package/site-dist/assets/reference_adr_0030-card-intake-process.md.kLc-DG9_.js +1 -0
- package/site-dist/assets/reference_adr_0030-card-intake-process.md.kLc-DG9_.lean.js +1 -0
- package/site-dist/assets/reference_adr_0031-fewer-interventions.md.BDnQcz8g.js +1 -0
- package/site-dist/assets/reference_adr_0031-fewer-interventions.md.BDnQcz8g.lean.js +1 -0
- package/site-dist/assets/reference_adr_0032-npm-only-install.md.C-3L1FkX.js +1 -0
- package/site-dist/assets/reference_adr_0032-npm-only-install.md.C-3L1FkX.lean.js +1 -0
- package/site-dist/assets/reference_adr_0033-prototype-review-and-ui-check.md.hFcQzmaa.js +1 -0
- package/site-dist/assets/reference_adr_0033-prototype-review-and-ui-check.md.hFcQzmaa.lean.js +1 -0
- package/site-dist/assets/reference_adr_index.md.BYF-Ofor.js +1 -0
- package/site-dist/assets/reference_adr_index.md.BYF-Ofor.lean.js +1 -0
- package/site-dist/assets/reference_commands.md.ClKbS-t8.js +1 -0
- package/site-dist/assets/reference_commands.md.ClKbS-t8.lean.js +1 -0
- package/site-dist/assets/reference_config.md.CRe27LaA.js +6 -0
- package/site-dist/assets/reference_config.md.CRe27LaA.lean.js +1 -0
- package/site-dist/assets/reference_contracts_cli-json.md.N-v2HNUd.js +1 -0
- package/site-dist/assets/reference_contracts_cli-json.md.N-v2HNUd.lean.js +1 -0
- package/site-dist/assets/reference_contracts_feature-files.md.DSiA8OEH.js +26 -0
- package/site-dist/assets/reference_contracts_feature-files.md.DSiA8OEH.lean.js +26 -0
- package/site-dist/assets/reference_contracts_index.md.Bkznz4px.js +1 -0
- package/site-dist/assets/reference_contracts_index.md.Bkznz4px.lean.js +1 -0
- package/site-dist/assets/reference_contracts_notifier-event.md.CCfrkm_A.js +24 -0
- package/site-dist/assets/reference_contracts_notifier-event.md.CCfrkm_A.lean.js +24 -0
- package/site-dist/assets/reference_contracts_result-file.md.DmR4fjB-.js +1 -0
- package/site-dist/assets/reference_contracts_result-file.md.DmR4fjB-.lean.js +1 -0
- package/site-dist/assets/reference_contracts_spec.md.BwwS0L_j.js +8 -0
- package/site-dist/assets/reference_contracts_spec.md.BwwS0L_j.lean.js +8 -0
- package/site-dist/assets/reference_contracts_state.md.5bNTC3ow.js +1 -0
- package/site-dist/assets/reference_contracts_state.md.5bNTC3ow.lean.js +1 -0
- package/site-dist/assets/reference_contracts_usage-record.md._C0OXWeq.js +10 -0
- package/site-dist/assets/reference_contracts_usage-record.md._C0OXWeq.lean.js +10 -0
- package/site-dist/assets/reference_contracts_verify.md.CbA88u6S.js +29 -0
- package/site-dist/assets/reference_contracts_verify.md.CbA88u6S.lean.js +29 -0
- package/site-dist/assets/reference_scope.md.Kzqw8U_f.js +1 -0
- package/site-dist/assets/reference_scope.md.Kzqw8U_f.lean.js +1 -0
- package/site-dist/assets/style.Dwsa4SMe.css +1 -0
- package/site-dist/changelog.html +30 -0
- package/site-dist/guide/getting-started.html +35 -0
- package/site-dist/guide/how-it-works.html +54 -0
- package/site-dist/guide/index.html +30 -0
- package/site-dist/guide/install.html +71 -0
- package/site-dist/guide/maintainer.html +30 -0
- package/site-dist/guide/phases.html +30 -0
- package/site-dist/guide/project.html +106 -0
- package/site-dist/guide/user.html +30 -0
- package/site-dist/hashmap.json +1 -0
- package/site-dist/icons/agents.svg +1 -0
- package/site-dist/icons/chat.svg +1 -0
- package/site-dist/icons/paseo.svg +1 -0
- package/site-dist/icons/phases.svg +1 -0
- package/site-dist/icons/review.svg +1 -0
- package/site-dist/icons/safe.svg +1 -0
- package/site-dist/index.html +30 -0
- package/site-dist/install.ps1 +118 -0
- package/site-dist/install.sh +198 -0
- package/site-dist/logo.svg +11 -0
- package/site-dist/reference/adr/0001-step-agents-under-origin-chat.html +30 -0
- package/site-dist/reference/adr/0002-no-directory-switch-in-chat.html +30 -0
- package/site-dist/reference/adr/0003-script-engine-not-hub.html +30 -0
- package/site-dist/reference/adr/0004-result-files.html +30 -0
- package/site-dist/reference/adr/0005-no-default-branch-local-commits-only.html +30 -0
- package/site-dist/reference/adr/0006-skill-install-targets.html +30 -0
- package/site-dist/reference/adr/0007-own-interview-skill.html +30 -0
- package/site-dist/reference/adr/0008-entries-do-not-call-entries.html +30 -0
- package/site-dist/reference/adr/0009-versioned-install-layout.html +30 -0
- package/site-dist/reference/adr/0010-generated-feature-overview.html +30 -0
- package/site-dist/reference/adr/0011-resume-after-completion.html +30 -0
- package/site-dist/reference/adr/0012-review-panel.html +30 -0
- package/site-dist/reference/adr/0013-no-release-tooling.html +30 -0
- package/site-dist/reference/adr/0014-notifier-slot.html +30 -0
- package/site-dist/reference/adr/0015-incremental-continue.html +30 -0
- package/site-dist/reference/adr/0016-upgrade-from-tags.html +30 -0
- package/site-dist/reference/adr/0017-use-paseo-capabilities.html +30 -0
- package/site-dist/reference/adr/0018-own-worktree-option.html +30 -0
- package/site-dist/reference/adr/0019-parallel-orchestration.html +30 -0
- package/site-dist/reference/adr/0020-scheduling-through-the-chat.html +30 -0
- package/site-dist/reference/adr/0021-npm-version-manager.html +30 -0
- package/site-dist/reference/adr/0022-develop-and-verify-phases.html +30 -0
- package/site-dist/reference/adr/0023-types-gate-slices.html +30 -0
- package/site-dist/reference/adr/0024-confirm-early-review-axes.html +30 -0
- package/site-dist/reference/adr/0025-joint-requirements.html +30 -0
- package/site-dist/reference/adr/0026-machine-records-and-api.html +30 -0
- package/site-dist/reference/adr/0027-paseo-plugin.html +30 -0
- package/site-dist/reference/adr/0028-external-integrations.html +30 -0
- package/site-dist/reference/adr/0029-requirements-from-cards.html +30 -0
- package/site-dist/reference/adr/0030-card-intake-process.html +30 -0
- package/site-dist/reference/adr/0031-fewer-interventions.html +30 -0
- package/site-dist/reference/adr/0032-npm-only-install.html +30 -0
- package/site-dist/reference/adr/0033-prototype-review-and-ui-check.html +30 -0
- package/site-dist/reference/adr/index.html +30 -0
- package/site-dist/reference/commands.html +30 -0
- package/site-dist/reference/config.html +35 -0
- package/site-dist/reference/contracts/cli-json.html +30 -0
- package/site-dist/reference/contracts/feature-files.html +55 -0
- package/site-dist/reference/contracts/index.html +30 -0
- package/site-dist/reference/contracts/notifier-event.html +53 -0
- package/site-dist/reference/contracts/result-file.html +30 -0
- package/site-dist/reference/contracts/spec.html +37 -0
- package/site-dist/reference/contracts/state.html +30 -0
- package/site-dist/reference/contracts/usage-record.html +39 -0
- package/site-dist/reference/contracts/verify.html +58 -0
- package/site-dist/reference/scope.html +30 -0
- package/site-dist/vp-icons.css +1 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Runs inside the page being checked (req ui-check, or an agent's browser_evaluate): finds layout
|
|
2
|
+
// problems a person would see, and lists the page's visible labels, as plain data. It must stay
|
|
3
|
+
// one self-contained function: it is sent to the browser as text.
|
|
4
|
+
// Input: { width, narrow, state } — state: show only [data-state="<state>"] (prototypes).
|
|
5
|
+
(function reqKitUiCheck(input) {
|
|
6
|
+
const opts = input || {};
|
|
7
|
+
const doc = document;
|
|
8
|
+
const win = window;
|
|
9
|
+
if (opts.state) {
|
|
10
|
+
for (const el of doc.querySelectorAll("[data-state]")) {
|
|
11
|
+
if (el.getAttribute("data-state") !== opts.state) el.style.setProperty("display", "none", "important");
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const W = win.innerWidth;
|
|
15
|
+
const H_DOC = Math.max(doc.documentElement.scrollHeight, doc.body ? doc.body.scrollHeight : 0);
|
|
16
|
+
const issues = [];
|
|
17
|
+
const add = (kind, el, detail) => issues.push({ kind, target: el ? describe(el) : null, detail });
|
|
18
|
+
|
|
19
|
+
function visible(el) {
|
|
20
|
+
if (!el.isConnected) return false;
|
|
21
|
+
const cs = win.getComputedStyle(el);
|
|
22
|
+
if (cs.display === "none" || cs.visibility === "hidden" || Number(cs.opacity) === 0) return false;
|
|
23
|
+
const r = el.getBoundingClientRect();
|
|
24
|
+
return r.width > 0 && r.height > 0;
|
|
25
|
+
}
|
|
26
|
+
function ownText(el) {
|
|
27
|
+
let t = "";
|
|
28
|
+
for (const n of el.childNodes) if (n.nodeType === 3) t += n.textContent;
|
|
29
|
+
return t.replace(/\s+/g, " ").trim();
|
|
30
|
+
}
|
|
31
|
+
function text(el) {
|
|
32
|
+
if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") return el.value || el.getAttribute("placeholder") || "";
|
|
33
|
+
if (el.tagName === "IMG") return el.getAttribute("alt") || "";
|
|
34
|
+
return (el.innerText || el.textContent || "").replace(/\s+/g, " ").trim();
|
|
35
|
+
}
|
|
36
|
+
const KIND = { BUTTON: "按钮", A: "链接", INPUT: "输入框", SELECT: "下拉框", TEXTAREA: "多行输入框", IMG: "图片", TABLE: "表格", TH: "表头", TD: "单元格", LABEL: "标签", LI: "列表项", H1: "标题", H2: "标题", H3: "标题", H4: "小标题", H5: "小标题", H6: "小标题", P: "文字", SPAN: "文字" };
|
|
37
|
+
function describe(el) {
|
|
38
|
+
const role = el.getAttribute("role");
|
|
39
|
+
let kind = KIND[el.tagName] || (role === "button" ? "按钮" : "区域");
|
|
40
|
+
if (el.tagName === "INPUT" && /submit|button/.test(el.type)) kind = "按钮";
|
|
41
|
+
const r = el.getBoundingClientRect();
|
|
42
|
+
return {
|
|
43
|
+
kind,
|
|
44
|
+
text: text(el).slice(0, 60),
|
|
45
|
+
selector: cssPath(el),
|
|
46
|
+
box: { x: Math.round(r.left + win.scrollX), y: Math.round(r.top + win.scrollY), w: Math.round(r.width), h: Math.round(r.height) },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function cssPath(el) {
|
|
50
|
+
const parts = [];
|
|
51
|
+
for (let n = el; n && n.nodeType === 1 && n.tagName !== "HTML"; n = n.parentElement) {
|
|
52
|
+
if (n.id) {
|
|
53
|
+
parts.unshift("#" + CSS.escape(n.id));
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
let p = n.tagName.toLowerCase();
|
|
57
|
+
const parent = n.parentElement;
|
|
58
|
+
if (parent) {
|
|
59
|
+
const same = Array.from(parent.children).filter((c) => c.tagName === n.tagName);
|
|
60
|
+
if (same.length > 1) p += ":nth-of-type(" + (same.indexOf(n) + 1) + ")";
|
|
61
|
+
}
|
|
62
|
+
parts.unshift(p);
|
|
63
|
+
if (n.tagName === "BODY") break;
|
|
64
|
+
}
|
|
65
|
+
return parts.join(" > ");
|
|
66
|
+
}
|
|
67
|
+
function rgb(c) {
|
|
68
|
+
const m = /rgba?\(([^)]+)\)/.exec(c || "");
|
|
69
|
+
if (!m) return null;
|
|
70
|
+
const v = m[1].split(/[\s,/]+/).filter(Boolean).map(Number);
|
|
71
|
+
return { r: v[0], g: v[1], b: v[2], a: v.length > 3 ? v[3] : 1 };
|
|
72
|
+
}
|
|
73
|
+
function hex(c) {
|
|
74
|
+
return c ? "#" + [c.r, c.g, c.b].map((x) => Math.round(x).toString(16).padStart(2, "0")).join("") : "";
|
|
75
|
+
}
|
|
76
|
+
function lum(c) {
|
|
77
|
+
const f = (x) => {
|
|
78
|
+
x /= 255;
|
|
79
|
+
return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
|
|
80
|
+
};
|
|
81
|
+
return 0.2126 * f(c.r) + 0.7152 * f(c.g) + 0.0722 * f(c.b);
|
|
82
|
+
}
|
|
83
|
+
/** The color behind an element, or null when an image or gradient is behind it. */
|
|
84
|
+
function backdrop(el) {
|
|
85
|
+
for (let n = el; n && n.nodeType === 1; n = n.parentElement) {
|
|
86
|
+
const cs = win.getComputedStyle(n);
|
|
87
|
+
if (cs.backgroundImage && cs.backgroundImage !== "none") return null;
|
|
88
|
+
const c = rgb(cs.backgroundColor);
|
|
89
|
+
if (c && c.a > 0.95) return c;
|
|
90
|
+
if (c && c.a > 0.05) return null;
|
|
91
|
+
}
|
|
92
|
+
return { r: 255, g: 255, b: 255, a: 1 };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const all = Array.from(doc.body ? doc.body.querySelectorAll("*") : []).filter((el) => !/^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE|svg|path|BR)$/.test(el.tagName) && visible(el));
|
|
96
|
+
const interactive = all.filter((el) => /^(BUTTON|A|INPUT|SELECT|TEXTAREA|SUMMARY)$/.test(el.tagName) || ["button", "link", "tab", "checkbox", "switch", "menuitem"].includes(el.getAttribute("role")));
|
|
97
|
+
const leaves = all.filter((el) => ownText(el) || /^(IMG|INPUT|SELECT|TEXTAREA|BUTTON)$/.test(el.tagName));
|
|
98
|
+
|
|
99
|
+
// 1. Wider than the window: what sticks out on the right.
|
|
100
|
+
const docWidth = doc.documentElement.scrollWidth;
|
|
101
|
+
if (docWidth > W + 1) {
|
|
102
|
+
const out = all.filter((el) => el.getBoundingClientRect().right > W + 1 && !(el.parentElement && el.parentElement.getBoundingClientRect().right > W + 1 && el.parentElement !== doc.body));
|
|
103
|
+
out.slice(0, 5).forEach((el) => add("overflow", el, { overBy: Math.round(el.getBoundingClientRect().right - W) }));
|
|
104
|
+
if (out.length === 0) add("overflow", null, { overBy: docWidth - W });
|
|
105
|
+
}
|
|
106
|
+
// 2. Cut-off text (hidden overflow, ellipsis).
|
|
107
|
+
for (const el of leaves) {
|
|
108
|
+
if (!ownText(el)) continue;
|
|
109
|
+
const cs = win.getComputedStyle(el);
|
|
110
|
+
const clipsX = cs.overflowX !== "visible" || cs.textOverflow === "ellipsis";
|
|
111
|
+
const clipsY = cs.overflowY !== "visible";
|
|
112
|
+
if ((clipsX && el.scrollWidth > el.clientWidth + 1) || (clipsY && el.scrollHeight > el.clientHeight + 2 && cs.overflowY !== "auto" && cs.overflowY !== "scroll")) {
|
|
113
|
+
add("clipped", el, { shown: el.clientWidth, needed: el.scrollWidth });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// 3. Things on top of each other (neither inside the other).
|
|
117
|
+
const boxes = leaves.slice(0, 500).map((el) => ({ el, r: el.getBoundingClientRect() }));
|
|
118
|
+
let overlaps = 0;
|
|
119
|
+
for (let i = 0; i < boxes.length && overlaps < 10; i++) {
|
|
120
|
+
for (let j = i + 1; j < boxes.length && overlaps < 10; j++) {
|
|
121
|
+
const a = boxes[i];
|
|
122
|
+
const b = boxes[j];
|
|
123
|
+
if (a.el.contains(b.el) || b.el.contains(a.el)) continue;
|
|
124
|
+
const w = Math.min(a.r.right, b.r.right) - Math.max(a.r.left, b.r.left);
|
|
125
|
+
const h = Math.min(a.r.bottom, b.r.bottom) - Math.max(a.r.top, b.r.top);
|
|
126
|
+
if (w <= 2 || h <= 2) continue;
|
|
127
|
+
const smaller = Math.min(a.r.width * a.r.height, b.r.width * b.r.height);
|
|
128
|
+
if (w * h >= 0.3 * smaller) {
|
|
129
|
+
overlaps++;
|
|
130
|
+
add("overlap", a.el, { other: describe(b.el), area: { w: Math.round(w), h: Math.round(h) } });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// 4. Off the page on the left or top.
|
|
135
|
+
for (const el of leaves) {
|
|
136
|
+
const r = el.getBoundingClientRect();
|
|
137
|
+
if (r.right + win.scrollX < 2 || r.bottom + win.scrollY < 2) add("offscreen", el, {});
|
|
138
|
+
}
|
|
139
|
+
// 5. Images that did not load.
|
|
140
|
+
for (const img of doc.querySelectorAll("img")) {
|
|
141
|
+
if (img.complete && img.naturalWidth === 0 && visible(img)) add("broken_image", img, { src: (img.getAttribute("src") || "").slice(0, 120) });
|
|
142
|
+
}
|
|
143
|
+
// 6. Too small to tap (narrow screens).
|
|
144
|
+
if (opts.narrow) {
|
|
145
|
+
for (const el of interactive) {
|
|
146
|
+
const r = el.getBoundingClientRect();
|
|
147
|
+
if (r.width < 24 || r.height < 24) add("small_target", el, { w: Math.round(r.width), h: Math.round(r.height) });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// 7. Text that is hard to read on its background.
|
|
151
|
+
let lowContrast = 0;
|
|
152
|
+
for (const el of leaves) {
|
|
153
|
+
if (lowContrast >= 10 || !ownText(el)) continue;
|
|
154
|
+
const cs = win.getComputedStyle(el);
|
|
155
|
+
const fg = rgb(cs.color);
|
|
156
|
+
const bg = backdrop(el);
|
|
157
|
+
if (!fg || !bg || fg.a < 0.5) continue;
|
|
158
|
+
const l1 = lum(fg);
|
|
159
|
+
const l2 = lum(bg);
|
|
160
|
+
const ratio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
|
161
|
+
const size = parseFloat(cs.fontSize);
|
|
162
|
+
// Only text that is clearly hard to read (WCAG asks 4.5:1; many component libraries' own
|
|
163
|
+
// buttons sit just under it, and reporting those would bury the real problems).
|
|
164
|
+
const large = size >= 24 || (size >= 18.6 && Number(cs.fontWeight) >= 700);
|
|
165
|
+
const need = large ? 2.5 : 3;
|
|
166
|
+
if (ratio < need && !el.closest("[disabled],[aria-disabled=true]")) {
|
|
167
|
+
lowContrast++;
|
|
168
|
+
add("low_contrast", el, { ratio: Math.round(ratio * 10) / 10, need, color: hex(fg), background: hex(bg) });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// 8. Controls nobody can name: inputs without a label, buttons without text.
|
|
172
|
+
for (const el of interactive) {
|
|
173
|
+
const named = el.getAttribute("aria-label") || el.getAttribute("aria-labelledby") || el.getAttribute("title");
|
|
174
|
+
if (/^(INPUT|SELECT|TEXTAREA)$/.test(el.tagName) && !/hidden|submit|button|image|reset/.test(el.type || "")) {
|
|
175
|
+
const before = el.previousElementSibling;
|
|
176
|
+
const labelled =
|
|
177
|
+
named ||
|
|
178
|
+
el.getAttribute("placeholder") ||
|
|
179
|
+
el.closest("label") ||
|
|
180
|
+
(el.id && doc.querySelector('label[for="' + CSS.escape(el.id) + '"]')) ||
|
|
181
|
+
(before && /^(LABEL|SPAN)$/.test(before.tagName) && text(before));
|
|
182
|
+
if (!labelled) add("no_label", el, {});
|
|
183
|
+
} else if (!named && !text(el) && !el.querySelector("img[alt]:not([alt=''])")) {
|
|
184
|
+
add("no_name", el, {});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// The labels people read: titles, buttons, links, field names, table headers, hints in the
|
|
189
|
+
// marked regions. Sample data in table cells is left out (it differs from the real page).
|
|
190
|
+
const labels = [];
|
|
191
|
+
const seen = new Set();
|
|
192
|
+
const push = (t) => {
|
|
193
|
+
const v = (t || "").replace(/\s+/g, " ").trim();
|
|
194
|
+
if (v && v.length <= 80 && !seen.has(v) && !/^[\d\s.,:\-/%¥$]+$/.test(v)) {
|
|
195
|
+
seen.add(v);
|
|
196
|
+
labels.push(v);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
for (const el of all) {
|
|
200
|
+
if (/^(H1|H2|H3|H4|H5|H6|BUTTON|A|LABEL|TH|LEGEND|SUMMARY|CAPTION|OPTION)$/.test(el.tagName) || ["button", "tab", "link", "menuitem", "heading"].includes(el.getAttribute("role"))) push(text(el));
|
|
201
|
+
if (el.getAttribute("placeholder")) push(el.getAttribute("placeholder"));
|
|
202
|
+
if (el.tagName === "INPUT" && /submit|button/.test(el.type)) push(el.value);
|
|
203
|
+
if (el.closest("[data-ac]") && !el.closest("td") && ownText(el)) push(ownText(el));
|
|
204
|
+
}
|
|
205
|
+
if (doc.title) push(doc.title);
|
|
206
|
+
|
|
207
|
+
return { width: W, height: H_DOC, title: doc.title, issues, labels };
|
|
208
|
+
});
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
// `req ui-check` (1.13): checks a web page without looking at it. Opens the page in this machine's
|
|
2
|
+
// Edge or Chrome (headless, through playwright-core; no browser download), at each width, and
|
|
3
|
+
// reports in words what a person would notice: things cut off, on top of each other, sticking
|
|
4
|
+
// out of the screen, images not loading, text too pale to read, controls without a name,
|
|
5
|
+
// console errors and failed requests. With --prototype it also compares the page's labels
|
|
6
|
+
// (titles, buttons, field names, table headers) with the confirmed prototype. Screenshots go to
|
|
7
|
+
// files for people, not to the model: models with weak vision gain little from them and pay
|
|
8
|
+
// for every image in tokens.
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import { createRequire } from "node:module";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { pathToFileURL } from "node:url";
|
|
14
|
+
import { ENGINE_DIR, KIT_ROOT, kitHome } from "./locations.js";
|
|
15
|
+
|
|
16
|
+
const PLAYWRIGHT_VERSION = "1.56.1";
|
|
17
|
+
const PAGE_SCRIPT = path.join(ENGINE_DIR, "src", "ui-check-page.js");
|
|
18
|
+
|
|
19
|
+
export interface Target {
|
|
20
|
+
kind: string;
|
|
21
|
+
text: string;
|
|
22
|
+
selector: string;
|
|
23
|
+
box: { x: number; y: number; w: number; h: number };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Issue {
|
|
27
|
+
kind: "overflow" | "clipped" | "overlap" | "offscreen" | "broken_image" | "small_target" | "low_contrast" | "no_label" | "no_name";
|
|
28
|
+
target: Target | null;
|
|
29
|
+
detail: Record<string, unknown> & { other?: Target };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PageFindings {
|
|
33
|
+
width: number;
|
|
34
|
+
height: number;
|
|
35
|
+
title: string;
|
|
36
|
+
issues: Issue[];
|
|
37
|
+
labels: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The in-page check as a function expression (also what an agent passes to browser_evaluate). */
|
|
41
|
+
export function pageScript(): string {
|
|
42
|
+
const src = fs.readFileSync(PAGE_SCRIPT, "utf8");
|
|
43
|
+
const start = src.indexOf("(function");
|
|
44
|
+
return src.slice(start).trim().replace(/;\s*$/, "");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Words
|
|
49
|
+
|
|
50
|
+
const who = (t: Target | null | undefined) => (t ? `${t.kind}${t.text ? `"${t.text.slice(0, 30)}${t.text.length > 30 ? "…" : ""}"` : ""}` : "页面");
|
|
51
|
+
|
|
52
|
+
export function issueText(i: Issue): string {
|
|
53
|
+
const d = i.detail;
|
|
54
|
+
switch (i.kind) {
|
|
55
|
+
case "overflow":
|
|
56
|
+
return `超出屏幕:${who(i.target)} 右边超出 ${d.overBy}px,会出现横向滚动条`;
|
|
57
|
+
case "clipped":
|
|
58
|
+
return `文字显示不全:${who(i.target)}(可见宽 ${d.shown}px,需要 ${d.needed}px)`;
|
|
59
|
+
case "overlap":
|
|
60
|
+
return `互相遮挡:${who(i.target)} 和 ${who(d.other)} 重叠了 ${(d.area as { w: number }).w}×${(d.area as { h: number }).h}px`;
|
|
61
|
+
case "offscreen":
|
|
62
|
+
return `跑到页面外:${who(i.target)} 在左边或上边看不到的地方`;
|
|
63
|
+
case "broken_image":
|
|
64
|
+
return `图片没有加载出来:${d.src || who(i.target)}`;
|
|
65
|
+
case "small_target":
|
|
66
|
+
return `太小不好点:${who(i.target)} 只有 ${d.w}×${d.h}px(手机上建议至少 24×24)`;
|
|
67
|
+
case "low_contrast":
|
|
68
|
+
return `文字太淡看不清:${who(i.target)}(${d.color} 在 ${d.background} 上,对比度 ${d.ratio}:1,至少要 ${d.need}:1)`;
|
|
69
|
+
case "no_label":
|
|
70
|
+
return `输入框没有名字:${who(i.target)} 旁边没有标签或提示文字`;
|
|
71
|
+
case "no_name":
|
|
72
|
+
return `按钮或链接没有文字:${who(i.target)}(只有图标时要有提示文字)`;
|
|
73
|
+
default:
|
|
74
|
+
return `${i.kind}:${who(i.target)}`;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Labels in the prototype and not on the page (and the other way round). */
|
|
79
|
+
export function compareLabels(prototype: string[], page: string[]): { missing: string[]; extra: string[] } {
|
|
80
|
+
const norm = (s: string) => s.replace(/\s+/g, "").replace(/[::]$/, "").toLowerCase();
|
|
81
|
+
const pageSet = new Set(page.map(norm));
|
|
82
|
+
const protoSet = new Set(prototype.map(norm));
|
|
83
|
+
return {
|
|
84
|
+
missing: prototype.filter((t) => !pageSet.has(norm(t))),
|
|
85
|
+
extra: page.filter((t) => !protoSet.has(norm(t))),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// The browser
|
|
91
|
+
|
|
92
|
+
interface Pw {
|
|
93
|
+
chromium: {
|
|
94
|
+
launch(o: Record<string, unknown>): Promise<{
|
|
95
|
+
newPage(o: Record<string, unknown>): Promise<PwPage>;
|
|
96
|
+
close(): Promise<void>;
|
|
97
|
+
}>;
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
interface PwPage {
|
|
101
|
+
goto(url: string, o?: Record<string, unknown>): Promise<unknown>;
|
|
102
|
+
evaluate<T>(src: string): Promise<T>;
|
|
103
|
+
screenshot(o: Record<string, unknown>): Promise<unknown>;
|
|
104
|
+
on(event: string, fn: (x: never) => void): void;
|
|
105
|
+
setViewportSize(s: { width: number; height: number }): Promise<void>;
|
|
106
|
+
waitForTimeout(ms: number): Promise<void>;
|
|
107
|
+
getByText(t: string, o?: Record<string, unknown>): { first(): { waitFor(o?: Record<string, unknown>): Promise<void> } };
|
|
108
|
+
locator(s: string): { first(): { click(): Promise<void>; fill(v: string): Promise<void>; selectOption(v: string): Promise<unknown>; press(k: string): Promise<void>; waitFor(o?: Record<string, unknown>): Promise<void> } };
|
|
109
|
+
close(): Promise<void>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const toolsDir = (env: NodeJS.ProcessEnv) => path.join(kitHome(env), "tools");
|
|
113
|
+
|
|
114
|
+
function tryRequire(from: string): Pw | null {
|
|
115
|
+
try {
|
|
116
|
+
return createRequire(path.join(from, "noop.js"))("playwright-core") as Pw;
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** playwright-core: next to req-kit, in ~/.req-kit/tools, or installed there now (from the npm registry or mirror). */
|
|
123
|
+
export function loadPlaywright(env: NodeJS.ProcessEnv = process.env, log: (s: string) => void = (s) => console.error(s)): Pw {
|
|
124
|
+
const found = tryRequire(KIT_ROOT) ?? tryRequire(toolsDir(env));
|
|
125
|
+
if (found) return found;
|
|
126
|
+
log(`第一次使用界面检查:安装 playwright-core@${PLAYWRIGHT_VERSION} 到 ${toolsDir(env)}(只装驱动,用本机的 Edge 或 Chrome,不下载浏览器)……`);
|
|
127
|
+
fs.mkdirSync(toolsDir(env), { recursive: true });
|
|
128
|
+
const windows = process.platform === "win32";
|
|
129
|
+
const r = spawnSync(windows ? "npm.cmd" : "npm", ["install", "--prefix", toolsDir(env), "--no-audit", "--no-fund", "--no-save", `playwright-core@${PLAYWRIGHT_VERSION}`], {
|
|
130
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
131
|
+
shell: windows,
|
|
132
|
+
env: { ...env, PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1" },
|
|
133
|
+
timeout: 600_000,
|
|
134
|
+
});
|
|
135
|
+
const loaded = r.status === 0 ? tryRequire(toolsDir(env)) : null;
|
|
136
|
+
if (!loaded) throw new Error(`没能安装 playwright-core(npm 退出码 ${r.status})。请检查 npm 源(npm config get registry),或手动运行:npm install --prefix "${toolsDir(env)}" playwright-core@${PLAYWRIGHT_VERSION}`);
|
|
137
|
+
return loaded;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** This machine's Edge or Chrome; REQ_KIT_BROWSER / --browser names a browser program directly. */
|
|
141
|
+
export async function launchBrowser(pw: Pw, executable: string | null) {
|
|
142
|
+
const tries: Array<Record<string, unknown>> = executable ? [{ executablePath: executable }] : [{ channel: "msedge" }, { channel: "chrome" }, {}];
|
|
143
|
+
const errors: string[] = [];
|
|
144
|
+
for (const t of tries) {
|
|
145
|
+
try {
|
|
146
|
+
return await pw.chromium.launch({ headless: true, ...t });
|
|
147
|
+
} catch (error) {
|
|
148
|
+
errors.push(`${JSON.stringify(t)}:${(error as Error).message.split("\n")[0]}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
throw new Error(`没有找到能用的浏览器(Edge 或 Chrome)。装好其中一个,或用 --browser <浏览器程序路径> 指定。\n${errors.join("\n")}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface Step {
|
|
155
|
+
goto?: string;
|
|
156
|
+
click?: string;
|
|
157
|
+
fill?: [string, string];
|
|
158
|
+
select?: [string, string];
|
|
159
|
+
press?: [string, string];
|
|
160
|
+
wait?: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function runSteps(page: PwPage, steps: Step[]) {
|
|
164
|
+
for (const s of steps) {
|
|
165
|
+
if (s.goto) await page.goto(s.goto, { waitUntil: "load" });
|
|
166
|
+
else if (s.click) await page.locator(s.click).first().click();
|
|
167
|
+
else if (s.fill) await page.locator(s.fill[0]).first().fill(s.fill[1]);
|
|
168
|
+
else if (s.select) await page.locator(s.select[0]).first().selectOption(s.select[1]);
|
|
169
|
+
else if (s.press) await page.locator(s.press[0]).first().press(s.press[1]);
|
|
170
|
+
else if (s.wait) await page.locator(s.wait).first().waitFor({ timeout: 15_000 });
|
|
171
|
+
await page.waitForTimeout(150);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface CheckOptions {
|
|
176
|
+
url: string;
|
|
177
|
+
widths: number[];
|
|
178
|
+
prototype: string | null;
|
|
179
|
+
state: string | null;
|
|
180
|
+
steps: Step[];
|
|
181
|
+
shots: string | null;
|
|
182
|
+
browser: string | null;
|
|
183
|
+
env?: NodeJS.ProcessEnv;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface WidthReport {
|
|
187
|
+
width: number;
|
|
188
|
+
findings: PageFindings;
|
|
189
|
+
console: string[];
|
|
190
|
+
failedRequests: string[];
|
|
191
|
+
screenshot: string | null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface CheckReport {
|
|
195
|
+
url: string;
|
|
196
|
+
widths: WidthReport[];
|
|
197
|
+
prototype: { file: string; state: string | null; missing: string[]; extra: string[] } | null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function check(opts: CheckOptions): Promise<CheckReport> {
|
|
201
|
+
const env = opts.env ?? process.env;
|
|
202
|
+
const pw = loadPlaywright(env);
|
|
203
|
+
const browser = await launchBrowser(pw, opts.browser ?? env.REQ_KIT_BROWSER?.trim() ?? null);
|
|
204
|
+
const script = pageScript();
|
|
205
|
+
try {
|
|
206
|
+
const widths: WidthReport[] = [];
|
|
207
|
+
for (const width of opts.widths) {
|
|
208
|
+
const page = await browser.newPage({ viewport: { width, height: 900 } });
|
|
209
|
+
const consoleErrors: string[] = [];
|
|
210
|
+
const failed: string[] = [];
|
|
211
|
+
page.on("console", (m: { type(): string; text(): string }) => {
|
|
212
|
+
if (m.type() === "error") consoleErrors.push(m.text().slice(0, 300));
|
|
213
|
+
});
|
|
214
|
+
page.on("pageerror", (e: Error) => consoleErrors.push(e.message.slice(0, 300)));
|
|
215
|
+
page.on("requestfailed", (r: { method(): string; url(): string; failure(): { errorText: string } | null }) => failed.push(`${r.method()} ${r.url().slice(0, 160)}(${r.failure()?.errorText ?? "失败"})`));
|
|
216
|
+
page.on("response", (r: { status(): number; request(): { method(): string }; url(): string }) => {
|
|
217
|
+
if (r.status() >= 400) failed.push(`${r.request().method()} ${r.url().slice(0, 160)} → ${r.status()}`);
|
|
218
|
+
});
|
|
219
|
+
await page.goto(opts.url, { waitUntil: "networkidle", timeout: 45_000 }).catch(async (e: Error) => {
|
|
220
|
+
if (!/Timeout/.test(e.message)) throw e;
|
|
221
|
+
});
|
|
222
|
+
await runSteps(page, opts.steps);
|
|
223
|
+
await page.waitForTimeout(300);
|
|
224
|
+
const findings = await page.evaluate<PageFindings>(`(${script})(${JSON.stringify({ width, narrow: width <= 480, state: null })})`);
|
|
225
|
+
let shot: string | null = null;
|
|
226
|
+
if (opts.shots) {
|
|
227
|
+
fs.mkdirSync(opts.shots, { recursive: true });
|
|
228
|
+
const u = new URL(opts.url);
|
|
229
|
+
const last = (u.protocol === "file:" ? path.basename(u.pathname).replace(/\.html?$/i, "") : u.pathname.split("/").filter(Boolean).slice(-2).join("-")) || "page";
|
|
230
|
+
const name = `${last.replace(/[^a-zA-Z0-9\u4e00-\u9fa5_-]+/g, "-").slice(0, 60)}-${width}.png`;
|
|
231
|
+
shot = path.join(opts.shots, name);
|
|
232
|
+
await page.screenshot({ path: shot, fullPage: true });
|
|
233
|
+
}
|
|
234
|
+
widths.push({ width, findings, console: [...new Set(consoleErrors)], failedRequests: [...new Set(failed)], screenshot: shot });
|
|
235
|
+
await page.close();
|
|
236
|
+
}
|
|
237
|
+
let prototype: CheckReport["prototype"] = null;
|
|
238
|
+
if (opts.prototype) {
|
|
239
|
+
// The prototype's own scripts (there should be none) are dropped; the check needs JavaScript.
|
|
240
|
+
const p2 = await browser.newPage({ viewport: { width: opts.widths[0] ?? 1280, height: 900 } });
|
|
241
|
+
const html = fs.readFileSync(opts.prototype, "utf8").replace(/<script\b[\s\S]*?<\/script>/gi, "");
|
|
242
|
+
await p2.goto("about:blank");
|
|
243
|
+
await p2.evaluate(`document.open(); document.write(${JSON.stringify(html)}); document.close();`);
|
|
244
|
+
const protoFindings = await p2.evaluate<PageFindings>(`(${script})(${JSON.stringify({ width: opts.widths[0] ?? 1280, narrow: false, state: opts.state })})`);
|
|
245
|
+
await p2.close();
|
|
246
|
+
const pageLabels = [...new Set(widths.flatMap((w) => w.findings.labels))];
|
|
247
|
+
const { missing, extra } = compareLabels(protoFindings.labels, pageLabels);
|
|
248
|
+
prototype = { file: opts.prototype, state: opts.state, missing, extra };
|
|
249
|
+
}
|
|
250
|
+
return { url: opts.url, widths, prototype };
|
|
251
|
+
} finally {
|
|
252
|
+
await browser.close();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const WIDTH_NAME: Record<number, string> = { 1280: "电脑", 1440: "电脑", 1920: "大屏", 768: "平板", 390: "手机", 375: "手机", 414: "手机" };
|
|
257
|
+
|
|
258
|
+
export function reportText(r: CheckReport): { text: string; problems: number } {
|
|
259
|
+
const out: string[] = [];
|
|
260
|
+
let problems = 0;
|
|
261
|
+
for (const w of r.widths) {
|
|
262
|
+
out.push(`## ${WIDTH_NAME[w.width] ?? ""}宽度 ${w.width}px:${r.url}`);
|
|
263
|
+
out.push(`页面标题:${w.findings.title || "(无)"};页面高度 ${w.findings.height}px`);
|
|
264
|
+
if (w.console.length) {
|
|
265
|
+
problems += w.console.length;
|
|
266
|
+
out.push(`控制台报错(${w.console.length}):`, ...w.console.slice(0, 10).map((c) => `- ${c}`));
|
|
267
|
+
}
|
|
268
|
+
if (w.failedRequests.length) {
|
|
269
|
+
problems += w.failedRequests.length;
|
|
270
|
+
out.push(`请求失败(${w.failedRequests.length}):`, ...w.failedRequests.slice(0, 10).map((c) => `- ${c}`));
|
|
271
|
+
}
|
|
272
|
+
if (w.findings.issues.length) {
|
|
273
|
+
problems += w.findings.issues.length;
|
|
274
|
+
out.push(`版面问题(${w.findings.issues.length}):`, ...w.findings.issues.slice(0, 30).map((i) => `- ${issueText(i)}${i.target ? ` [${i.target.selector}]` : ""}`));
|
|
275
|
+
} else out.push("版面问题:没有发现");
|
|
276
|
+
if (w.screenshot) out.push(`截图(给人看):${w.screenshot}`);
|
|
277
|
+
out.push("");
|
|
278
|
+
}
|
|
279
|
+
if (r.prototype) {
|
|
280
|
+
const p = r.prototype;
|
|
281
|
+
out.push(`## 和原型对文字:${p.file}${p.state ? `,状态"${p.state}"` : ""}`);
|
|
282
|
+
if (p.missing.length) {
|
|
283
|
+
problems += p.missing.length;
|
|
284
|
+
out.push(`原型里有、页面上没有(${p.missing.length}):${p.missing.slice(0, 30).map((t) => `"${t}"`).join("、")}`);
|
|
285
|
+
} else out.push("原型里的标题、按钮、字段名、表头在页面上都找到了。");
|
|
286
|
+
if (p.extra.length) out.push(`页面上有、原型里没有(多数是原型没画的部分,核对一下是否应该有):${p.extra.slice(0, 20).map((t) => `"${t}"`).join("、")}`);
|
|
287
|
+
out.push("");
|
|
288
|
+
}
|
|
289
|
+
out.push(problems === 0 ? "结论:没有发现问题。" : `结论:发现 ${problems} 处需要核对的地方。逐条判断是不是真的问题(有的是故意的设计),是的按缺陷处理。`);
|
|
290
|
+
return { text: out.join("\n"), problems };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
// req ui-check <网址> [选项]
|
|
295
|
+
|
|
296
|
+
const HELP = `用法:req ui-check <网址> [选项]
|
|
297
|
+
|
|
298
|
+
不靠看图检查网页:用本机的 Edge 或 Chrome 打开页面,按每个宽度用文字报告看得出来的问题
|
|
299
|
+
(文字显示不全、互相遮挡、超出屏幕、图片没加载、文字太淡、控件没有名字、控制台报错、请求失败)。
|
|
300
|
+
|
|
301
|
+
--width 1280,390 检查的宽度(默认电脑 1280 和手机 390)
|
|
302
|
+
--prototype <原型文件> 和确认过的原型对文字:标题、按钮、字段名、表头、提示语
|
|
303
|
+
--state <状态名> 原型中 data-state 的状态(如"空数据"),对文字时只看这个状态
|
|
304
|
+
--steps <文件.json> 打开后先做的操作,例如 [{"fill":["#keyword","手机"]},{"click":"text=查询"},{"wait":"text=共 3 条"}]
|
|
305
|
+
--shots <目录> 把整页截图存到这个目录(给人看;不要再把截图读给模型)
|
|
306
|
+
--browser <程序路径> 指定浏览器(默认找本机的 Edge,再找 Chrome;也可以设 REQ_KIT_BROWSER)
|
|
307
|
+
--json 输出 JSON
|
|
308
|
+
--script 只输出页面检查脚本(在 Paseo 浏览器的 browser_evaluate 中用)
|
|
309
|
+
|
|
310
|
+
退出码:0 没有发现问题,1 发现需要核对的地方,2 没能检查(打不开页面、没有浏览器等)。`;
|
|
311
|
+
|
|
312
|
+
function option(argv: string[], name: string): string | null {
|
|
313
|
+
const i = argv.findIndex((a) => a === name || a.startsWith(`${name}=`));
|
|
314
|
+
if (i < 0) return null;
|
|
315
|
+
const a = argv[i];
|
|
316
|
+
return a.includes("=") ? a.slice(name.length + 1) : (argv[i + 1] ?? null);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function main(argv: string[]): Promise<number> {
|
|
320
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
321
|
+
console.log(HELP);
|
|
322
|
+
return 0;
|
|
323
|
+
}
|
|
324
|
+
if (argv.includes("--script")) {
|
|
325
|
+
console.log(pageScript());
|
|
326
|
+
return 0;
|
|
327
|
+
}
|
|
328
|
+
const valued = new Set(["--width", "--prototype", "--state", "--steps", "--shots", "--browser"]);
|
|
329
|
+
const url = argv.find((a, i) => !a.startsWith("--") && !valued.has(argv[i - 1] ?? ""));
|
|
330
|
+
if (!url) {
|
|
331
|
+
console.error(HELP);
|
|
332
|
+
return 2;
|
|
333
|
+
}
|
|
334
|
+
const widths = (option(argv, "--width") ?? "1280,390")
|
|
335
|
+
.split(/[,,\s]+/)
|
|
336
|
+
.map(Number)
|
|
337
|
+
.filter((n) => Number.isInteger(n) && n >= 200 && n <= 4000);
|
|
338
|
+
const stepsFile = option(argv, "--steps");
|
|
339
|
+
const prototype = option(argv, "--prototype");
|
|
340
|
+
if (prototype && !fs.existsSync(prototype)) {
|
|
341
|
+
console.error(`没有这个原型文件:${prototype}`);
|
|
342
|
+
return 2;
|
|
343
|
+
}
|
|
344
|
+
const report = await check({
|
|
345
|
+
url: /^[a-z]+:\/\//i.test(url) ? url : fs.existsSync(url) ? pathToFileURL(path.resolve(url)).href : `http://${url}`,
|
|
346
|
+
widths: widths.length ? widths : [1280, 390],
|
|
347
|
+
prototype,
|
|
348
|
+
state: option(argv, "--state"),
|
|
349
|
+
steps: stepsFile ? (JSON.parse(fs.readFileSync(stepsFile, "utf8")) as Step[]) : [],
|
|
350
|
+
shots: option(argv, "--shots"),
|
|
351
|
+
browser: option(argv, "--browser"),
|
|
352
|
+
});
|
|
353
|
+
const { text, problems } = reportText(report);
|
|
354
|
+
console.log(argv.includes("--json") ? JSON.stringify(report, null, 2) : text);
|
|
355
|
+
return problems === 0 ? 0 : 1;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
|
|
359
|
+
main(process.argv.slice(2)).then(
|
|
360
|
+
(code) => process.exit(code),
|
|
361
|
+
(error: Error) => {
|
|
362
|
+
console.error(`没能检查:${error.message}`);
|
|
363
|
+
process.exit(2);
|
|
364
|
+
},
|
|
365
|
+
);
|
|
366
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "paseo-req-kit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.13.0-beta.2",
|
|
4
4
|
"description": "req-kit: a requirement-development workflow for Paseo. /req aligns a requirement in chat; the engine runs design, implementation, review and verification as Paseo agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"bin": {
|
|
28
28
|
"req": "bin/req.mjs",
|
|
29
29
|
"req-kit": "bin/req.mjs",
|
|
30
|
+
"paseo-req-kit": "bin/req.mjs",
|
|
30
31
|
"start-pipeline": "bin/start-pipeline.mjs"
|
|
31
32
|
},
|
|
32
33
|
"scripts": {
|
|
@@ -57,6 +58,7 @@
|
|
|
57
58
|
"docs/guide/",
|
|
58
59
|
"docs/adr/",
|
|
59
60
|
"install.mjs",
|
|
61
|
+
"site-dist/",
|
|
60
62
|
"config.example.json",
|
|
61
63
|
"CHANGELOG.md",
|
|
62
64
|
"SCOPE.md",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Data hooks: every read is an RPC to the daemon side (req api), cached by React Query.
|
|
2
2
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
3
|
import { useRpc, useSettings } from "@getpaseo/plugin/client";
|
|
4
|
-
import { configRpc, fileRpc, listRpc, modelsRpc, preferences, runRpc, setAgentsRpc, statsRpc, statusRpc, versionRpc, type Action, type AgentProfileData } from "../shared/contracts";
|
|
4
|
+
import { configRpc, fileRpc, listRpc, modelsRpc, preferences, reviewUrlRpc, runRpc, setAgentsRpc, statsRpc, statusRpc, versionRpc, type Action, type AgentProfileData } from "../shared/contracts";
|
|
5
5
|
|
|
6
6
|
export function usePreferences(): { readOnly: boolean; refreshMs: number } {
|
|
7
7
|
const settings = useSettings(preferences);
|
|
@@ -72,3 +72,14 @@ export function useConfigView(repo: string | null) {
|
|
|
72
72
|
const config = useRpc(configRpc);
|
|
73
73
|
return useQuery({ queryKey: ["req", "config", repo ?? ""], queryFn: () => config(repo ? { repo } : {}), retry: false });
|
|
74
74
|
}
|
|
75
|
+
|
|
76
|
+
/** The prototype review page's address (1.13); it stays valid while its server runs. */
|
|
77
|
+
export function useReviewUrl(repo: string, featureDir: string, enabled: boolean) {
|
|
78
|
+
const reviewUrl = useRpc(reviewUrlRpc);
|
|
79
|
+
return useQuery({
|
|
80
|
+
queryKey: ["req", "review-url", repo, featureDir],
|
|
81
|
+
queryFn: () => reviewUrl({ repo, featureDir }),
|
|
82
|
+
enabled: enabled && Boolean(repo && featureDir),
|
|
83
|
+
retry: false,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
@@ -9,6 +9,7 @@ import type { Action, RequirementDetail, Step } from "../shared/contracts";
|
|
|
9
9
|
import { useAction, useDocument, usePreferences, useRequirement, useRequirements } from "./data";
|
|
10
10
|
import { ACTION_TEXT, actionsFor, ago, ATTENTION_LABELS, facts, FILE_LABELS, minutes, tokens } from "./format";
|
|
11
11
|
import { Markdown, tsvToMarkdown } from "./markdown";
|
|
12
|
+
import { ReviewLink } from "./review-link";
|
|
12
13
|
import { Badge, Button, Chip, Status, useStyles } from "./ui";
|
|
13
14
|
|
|
14
15
|
type Theme = PluginHostProps["theme"];
|
|
@@ -192,6 +193,8 @@ export function RequirementView({
|
|
|
192
193
|
</View>
|
|
193
194
|
) : null}
|
|
194
195
|
|
|
196
|
+
{d.hasPrototype ? <ReviewLink theme={theme} compact={layout.compact} repo={d.sourceRepo ?? d.repo} featureDir={d.featureDir} waiting={d.attention === "approval"} /> : null}
|
|
197
|
+
|
|
195
198
|
{d.files.length > 0 ? (
|
|
196
199
|
<View style={{ gap: 6 }}>
|
|
197
200
|
<Text style={s.heading}>文档</Text>
|
|
@@ -8,6 +8,7 @@ import { Text, View } from "react-native";
|
|
|
8
8
|
import type { Attention, Requirement } from "../shared/contracts";
|
|
9
9
|
import { useAction, usePreferences } from "./data";
|
|
10
10
|
import { facts } from "./format";
|
|
11
|
+
import { ReviewLink } from "./review-link";
|
|
11
12
|
import { Badge, Button, useStyles } from "./ui";
|
|
12
13
|
|
|
13
14
|
type Theme = PluginHostProps["theme"];
|
|
@@ -98,6 +99,7 @@ function InboxCard({ theme, layout, item, open, readOnly }: { theme: Theme; layo
|
|
|
98
99
|
</View>
|
|
99
100
|
) : (
|
|
100
101
|
<>
|
|
102
|
+
{item.attention === "approval" && item.hasPrototype ? <ReviewLink theme={theme} compact={layout.compact} repo={repo} featureDir={item.featureDir} waiting /> : null}
|
|
101
103
|
{writing && w?.canReply ? (
|
|
102
104
|
<View style={{ gap: 8 }}>
|
|
103
105
|
<TextInput
|