@nick848/fet 1.1.10 → 1.1.11
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/README.md +5 -1
- package/README_en.md +4 -1
- package/dist/cli/index.js +910 -58
- package/dist/cli/index.js.map +1 -1
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -2090,6 +2090,12 @@ function renderFetConfig(scan, language = "zh-CN") {
|
|
|
2090
2090
|
mode: "require_before_apply",
|
|
2091
2091
|
whenNoTestScript: "block"
|
|
2092
2092
|
},
|
|
2093
|
+
visual: {
|
|
2094
|
+
enabled: true,
|
|
2095
|
+
compareMode: "layout-only",
|
|
2096
|
+
requireBeforeVerify: "when_figma",
|
|
2097
|
+
whenNoCapture: "warn"
|
|
2098
|
+
},
|
|
2093
2099
|
specLanguage: {
|
|
2094
2100
|
style: "layered_bilingual",
|
|
2095
2101
|
canonical: "en",
|
|
@@ -2274,7 +2280,7 @@ function tddResultsRelativePath(changeId) {
|
|
|
2274
2280
|
|
|
2275
2281
|
// src/templates/tdd.ts
|
|
2276
2282
|
function renderTddInstructions(changeId, manifest, language) {
|
|
2277
|
-
const
|
|
2283
|
+
const manifestPath3 = tddManifestRelativePath(changeId);
|
|
2278
2284
|
const specPath = tddSpecRelativePath(changeId);
|
|
2279
2285
|
const caseList = manifest.cases.map((item) => `- \`${item.id}\`: ${item.title} \u2192 \`${item.testFile}\` (${item.testIds.join(", ")})`).join("\n");
|
|
2280
2286
|
if (language === "en") {
|
|
@@ -2293,13 +2299,13 @@ Create or update unit tests **before** marking implementation tasks done in \`ta
|
|
|
2293
2299
|
## Sources
|
|
2294
2300
|
${manifest.sources.map((s) => `- ${s}`).join("\n")}
|
|
2295
2301
|
|
|
2296
|
-
## Cases (from ${
|
|
2302
|
+
## Cases (from ${manifestPath3})
|
|
2297
2303
|
${caseList}
|
|
2298
2304
|
|
|
2299
2305
|
## Rules
|
|
2300
2306
|
1. Each case must map to a real test file under the repo test tree.
|
|
2301
2307
|
2. Tests should fail until implementation lands (red \u2192 green).
|
|
2302
|
-
3. Do not edit \`${
|
|
2308
|
+
3. Do not edit \`${manifestPath3}\` by hand unless fixing IDs; re-run \`fet tdd --change ${changeId}\` after planning changes.
|
|
2303
2309
|
4. When tests exist, run \`fet test --change ${changeId}\` before \`fet verify\`.
|
|
2304
2310
|
|
|
2305
2311
|
Human-readable matrix: \`${specPath}\`
|
|
@@ -2320,13 +2326,13 @@ generatedAt: ${manifest.generatedAt}
|
|
|
2320
2326
|
## \u6765\u6E90
|
|
2321
2327
|
${manifest.sources.map((s) => `- ${s}`).join("\n")}
|
|
2322
2328
|
|
|
2323
|
-
## \u7528\u4F8B\uFF08\u89C1 ${
|
|
2329
|
+
## \u7528\u4F8B\uFF08\u89C1 ${manifestPath3}\uFF09
|
|
2324
2330
|
${caseList}
|
|
2325
2331
|
|
|
2326
2332
|
## \u89C4\u5219
|
|
2327
2333
|
1. \u6BCF\u4E2A\u7528\u4F8B\u5FC5\u987B\u5BF9\u5E94\u4ED3\u5E93\u5185\u771F\u5B9E\u6D4B\u8BD5\u6587\u4EF6\u3002
|
|
2328
2334
|
2. \u5B9E\u73B0\u843D\u5730\u524D\u6D4B\u8BD5\u5E94\u5904\u4E8E\u5931\u8D25\uFF08\u7EA2\uFF09\u72B6\u6001\uFF0C\u843D\u5730\u540E\u5E94\u53D8\u7EFF\u3002
|
|
2329
|
-
3. \u9664\u975E\u4FEE\u6B63 ID\uFF0C\u4E0D\u8981\u624B\u6539 \`${
|
|
2335
|
+
3. \u9664\u975E\u4FEE\u6B63 ID\uFF0C\u4E0D\u8981\u624B\u6539 \`${manifestPath3}\`\uFF1B\u89C4\u5212\u53D8\u66F4\u540E\u8BF7\u91CD\u65B0\u6267\u884C \`fet tdd --change ${changeId}\`\u3002
|
|
2330
2336
|
4. \u6D4B\u8BD5\u5C31\u7EEA\u540E\u5148 \`fet test --change ${changeId}\`\uFF0C\u518D \`fet verify\`\u3002
|
|
2331
2337
|
|
|
2332
2338
|
\u53EF\u8BFB\u77E9\u9635\u89C1 \`${specPath}\`
|
|
@@ -2371,19 +2377,151 @@ function escapeTable(value) {
|
|
|
2371
2377
|
return value.replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
2372
2378
|
}
|
|
2373
2379
|
function renderTddApplyNextSteps(changeId, language) {
|
|
2374
|
-
const
|
|
2380
|
+
const manifestPath3 = tddManifestRelativePath(changeId);
|
|
2375
2381
|
if (language === "en") {
|
|
2376
2382
|
return [
|
|
2377
|
-
`Read ${
|
|
2383
|
+
`Read ${manifestPath3} and tdd-instructions.md; implement code until fet test passes for this change.`,
|
|
2378
2384
|
`Run fet test --change ${changeId} before fet verify.`
|
|
2379
2385
|
];
|
|
2380
2386
|
}
|
|
2381
2387
|
return [
|
|
2382
|
-
`\u9605\u8BFB ${
|
|
2388
|
+
`\u9605\u8BFB ${manifestPath3} \u4E0E tdd-instructions.md\uFF1B\u5B9E\u73B0\u4EE3\u7801\u76F4\u81F3\u672C change \u7684 fet test \u901A\u8FC7\u3002`,
|
|
2383
2389
|
`\u5728 fet verify \u4E4B\u524D\u5148\u6267\u884C fet test --change ${changeId}\u3002`
|
|
2384
2390
|
];
|
|
2385
2391
|
}
|
|
2386
2392
|
|
|
2393
|
+
// src/visual/paths.ts
|
|
2394
|
+
function visualFetDirRelative(changeId) {
|
|
2395
|
+
return `openspec/changes/${changeId}/.fet`;
|
|
2396
|
+
}
|
|
2397
|
+
function visualManifestRelativePath(changeId) {
|
|
2398
|
+
return `${visualFetDirRelative(changeId)}/visual-manifest.yaml`;
|
|
2399
|
+
}
|
|
2400
|
+
function visualSpecRelativePath(changeId) {
|
|
2401
|
+
return `${visualFetDirRelative(changeId)}/visual-spec.md`;
|
|
2402
|
+
}
|
|
2403
|
+
function visualInstructionsRelativePath(changeId) {
|
|
2404
|
+
return `${visualFetDirRelative(changeId)}/visual-instructions.md`;
|
|
2405
|
+
}
|
|
2406
|
+
function visualCaptureRelativePath(changeId) {
|
|
2407
|
+
return `${visualFetDirRelative(changeId)}/visual-capture.json`;
|
|
2408
|
+
}
|
|
2409
|
+
function visualResultsRelativePath(changeId) {
|
|
2410
|
+
return `${visualFetDirRelative(changeId)}/visual-results.json`;
|
|
2411
|
+
}
|
|
2412
|
+
function visualBaselinesDirRelative(changeId) {
|
|
2413
|
+
return `${visualFetDirRelative(changeId)}/visual-baselines`;
|
|
2414
|
+
}
|
|
2415
|
+
function visualPageScreenshotRelative(changeId, pageId) {
|
|
2416
|
+
return `${visualBaselinesDirRelative(changeId)}/${pageId}/implementation.png`;
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
// src/templates/visual.ts
|
|
2420
|
+
function renderVisualInstructions(changeId, manifest, language) {
|
|
2421
|
+
const pages = manifest.pages.map(
|
|
2422
|
+
(page) => `- \`${page.id}\`: ${page.title} \u2192 \`${page.route}\` (ignore: ${page.ignoreSelectors.join(", ") || "none"})`
|
|
2423
|
+
).join("\n");
|
|
2424
|
+
if (language === "en") {
|
|
2425
|
+
return `---
|
|
2426
|
+
schemaVersion: 1
|
|
2427
|
+
fetVersion: ${FET_VERSION}
|
|
2428
|
+
changeId: ${changeId}
|
|
2429
|
+
purpose: visual-instructions
|
|
2430
|
+
generatedAt: ${manifest.generatedAt}
|
|
2431
|
+
compareMode: layout-only
|
|
2432
|
+
---
|
|
2433
|
+
|
|
2434
|
+
# Visual verification (layout-only)
|
|
2435
|
+
|
|
2436
|
+
Compare **layout / spacing / shell regions** only. Do **not** pixel-match dynamic API text, list rows, or images.
|
|
2437
|
+
|
|
2438
|
+
## Figma references
|
|
2439
|
+
${manifest.figmaUrls.map((url) => `- ${url}`).join("\n") || "- (none detected)"}
|
|
2440
|
+
|
|
2441
|
+
## Pages
|
|
2442
|
+
${pages}
|
|
2443
|
+
|
|
2444
|
+
## Default command
|
|
2445
|
+
|
|
2446
|
+
\`\`\`sh
|
|
2447
|
+
fet visual --change ${changeId}
|
|
2448
|
+
\`\`\`
|
|
2449
|
+
|
|
2450
|
+
Runs manifest refresh, capture (Playwright + \`--base-url\`), and layout checks in one step.
|
|
2451
|
+
|
|
2452
|
+
See \`${visualSpecRelativePath(changeId)}\` for the human-readable matrix.
|
|
2453
|
+
`;
|
|
2454
|
+
}
|
|
2455
|
+
return `---
|
|
2456
|
+
schemaVersion: 1
|
|
2457
|
+
fetVersion: ${FET_VERSION}
|
|
2458
|
+
changeId: ${changeId}
|
|
2459
|
+
purpose: visual-instructions
|
|
2460
|
+
generatedAt: ${manifest.generatedAt}
|
|
2461
|
+
compareMode: layout-only
|
|
2462
|
+
---
|
|
2463
|
+
|
|
2464
|
+
# \u89C6\u89C9\u9A8C\u6536\uFF08\u4EC5 layout-only\uFF09
|
|
2465
|
+
|
|
2466
|
+
\u53EA\u9A8C\u6536 **\u6392\u7248 / \u95F4\u8DDD / \u58F3\u5C42\u533A\u57DF**\uFF0C**\u4E0D\u8981**\u5BF9\u52A8\u6001\u63A5\u53E3\u6587\u6848\u3001\u5217\u8868\u884C\u3001\u56FE\u7247\u505A\u50CF\u7D20\u7EA7\u5BF9\u6BD4\u3002
|
|
2467
|
+
|
|
2468
|
+
## Figma \u5F15\u7528
|
|
2469
|
+
${manifest.figmaUrls.map((url) => `- ${url}`).join("\n") || "- \uFF08\u672A\u68C0\u6D4B\u5230\uFF09"}
|
|
2470
|
+
|
|
2471
|
+
## \u9875\u9762
|
|
2472
|
+
${pages}
|
|
2473
|
+
|
|
2474
|
+
## \u9ED8\u8BA4\u547D\u4EE4
|
|
2475
|
+
|
|
2476
|
+
\`\`\`sh
|
|
2477
|
+
fet visual --change ${changeId}
|
|
2478
|
+
\`\`\`
|
|
2479
|
+
|
|
2480
|
+
\u4E00\u6761\u547D\u4EE4\u5B8C\u6210\u6E05\u5355\u66F4\u65B0\u3001\u622A\u56FE\uFF08Playwright + \`--base-url\`\uFF09\u4E0E\u5E03\u5C40\u68C0\u67E5\u3002
|
|
2481
|
+
|
|
2482
|
+
\u8BE6\u89C1 \`${visualSpecRelativePath(changeId)}\`\u3002
|
|
2483
|
+
`;
|
|
2484
|
+
}
|
|
2485
|
+
function renderVisualSpec(changeId, manifest, language) {
|
|
2486
|
+
const rows = manifest.pages.flatMap(
|
|
2487
|
+
(page) => page.checkRegions.map(
|
|
2488
|
+
(region) => `| ${page.id} | ${page.route} | \`${region.selector}\` | ${region.checks.join(", ")} | ${page.ignoreSelectors.join(", ") || "-"} |`
|
|
2489
|
+
)
|
|
2490
|
+
).join("\n");
|
|
2491
|
+
if (language === "en") {
|
|
2492
|
+
return `---
|
|
2493
|
+
changeId: ${changeId}
|
|
2494
|
+
compareMode: layout-only
|
|
2495
|
+
planningFingerprint: ${manifest.planningFingerprint}
|
|
2496
|
+
---
|
|
2497
|
+
|
|
2498
|
+
# Visual case matrix (layout-only)
|
|
2499
|
+
|
|
2500
|
+
| Page | Route | Region | Checks | Ignored dynamic |
|
|
2501
|
+
|------|-------|--------|--------|-----------------|
|
|
2502
|
+
${rows}
|
|
2503
|
+
`;
|
|
2504
|
+
}
|
|
2505
|
+
return `---
|
|
2506
|
+
changeId: ${changeId}
|
|
2507
|
+
compareMode: layout-only
|
|
2508
|
+
planningFingerprint: ${manifest.planningFingerprint}
|
|
2509
|
+
---
|
|
2510
|
+
|
|
2511
|
+
# \u89C6\u89C9\u7528\u4F8B\u77E9\u9635\uFF08\u4EC5 layout-only\uFF09
|
|
2512
|
+
|
|
2513
|
+
| \u9875\u9762 | \u8DEF\u7531 | \u533A\u57DF | \u68C0\u67E5\u9879 | \u5FFD\u7565\u7684\u52A8\u6001\u533A |
|
|
2514
|
+
|------|------|------|--------|--------------|
|
|
2515
|
+
${rows}
|
|
2516
|
+
`;
|
|
2517
|
+
}
|
|
2518
|
+
function renderVisualVerifyNextSteps(changeId, language) {
|
|
2519
|
+
if (language === "en") {
|
|
2520
|
+
return [`Run fet visual --change ${changeId} before fet verify when this change references Figma.`];
|
|
2521
|
+
}
|
|
2522
|
+
return [`\u82E5\u672C change \u5F15\u7528 Figma\uFF0C\u8BF7\u5728 fet verify \u4E4B\u524D\u6267\u884C fet visual --change ${changeId}\u3002`];
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2387
2525
|
// src/templates/figma-guard.ts
|
|
2388
2526
|
var FIGMA_URL_PATTERN = /https?:\/\/(?:www\.)?figma\.com\/(?:file|design|proto)\/[^\s)\]"'<>]+/gi;
|
|
2389
2527
|
function figmaStopHandoffRelativePath(changeId) {
|
|
@@ -3818,6 +3956,8 @@ function createChangeState(fetVersion, changeId, phase) {
|
|
|
3818
3956
|
manualVerify: null,
|
|
3819
3957
|
tdd: null,
|
|
3820
3958
|
testRun: null,
|
|
3959
|
+
visual: null,
|
|
3960
|
+
visualRun: null,
|
|
3821
3961
|
lastOpenSpecCommand: null,
|
|
3822
3962
|
warnings: []
|
|
3823
3963
|
};
|
|
@@ -4104,6 +4244,7 @@ async function applyWorkflowCommand(ctx, args) {
|
|
|
4104
4244
|
}
|
|
4105
4245
|
if (figmaGuard) {
|
|
4106
4246
|
applyNextSteps.unshift(...renderFigmaApplyNextSteps(changeId, ctx.language, figmaGuard.mode));
|
|
4247
|
+
applyNextSteps.splice(applyNextSteps.length - 1, 0, ...renderVisualVerifyNextSteps(changeId, ctx.language));
|
|
4107
4248
|
}
|
|
4108
4249
|
ctx.output.result({
|
|
4109
4250
|
ok: true,
|
|
@@ -5043,7 +5184,7 @@ async function tddCommand(ctx) {
|
|
|
5043
5184
|
cases,
|
|
5044
5185
|
testCommand: testCommand2
|
|
5045
5186
|
});
|
|
5046
|
-
const
|
|
5187
|
+
const manifestPath3 = await writeTddManifest(ctx.projectRoot, manifest);
|
|
5047
5188
|
const fetDir = join24(ctx.projectRoot, "openspec", "changes", changeId, ".fet");
|
|
5048
5189
|
await mkdir8(fetDir, { recursive: true });
|
|
5049
5190
|
const instructionsPath = tddInstructionsRelativePath(changeId);
|
|
@@ -5055,7 +5196,7 @@ async function tddCommand(ctx) {
|
|
|
5055
5196
|
status: "ready",
|
|
5056
5197
|
generatedAt: manifest.generatedAt,
|
|
5057
5198
|
planningFingerprint,
|
|
5058
|
-
manifestPath:
|
|
5199
|
+
manifestPath: manifestPath3
|
|
5059
5200
|
};
|
|
5060
5201
|
invalidateTestRun(changeState);
|
|
5061
5202
|
changeState.currentPhase = "implement";
|
|
@@ -5086,7 +5227,7 @@ async function tddCommand(ctx) {
|
|
|
5086
5227
|
],
|
|
5087
5228
|
data: {
|
|
5088
5229
|
changeId,
|
|
5089
|
-
manifestPath:
|
|
5230
|
+
manifestPath: manifestPath3,
|
|
5090
5231
|
specPath,
|
|
5091
5232
|
instructionsPath,
|
|
5092
5233
|
caseCount: cases.length,
|
|
@@ -5291,10 +5432,674 @@ function truncate(value, max = 2e3) {
|
|
|
5291
5432
|
return value.length > max ? `${value.slice(0, max)}\u2026` : value;
|
|
5292
5433
|
}
|
|
5293
5434
|
|
|
5435
|
+
// src/commands/visual.ts
|
|
5436
|
+
import { mkdir as mkdir10 } from "fs/promises";
|
|
5437
|
+
import { join as join28 } from "path";
|
|
5438
|
+
|
|
5439
|
+
// src/visual/config.ts
|
|
5440
|
+
import { readFile as readFile19 } from "fs/promises";
|
|
5441
|
+
import { join as join25 } from "path";
|
|
5442
|
+
import { parseDocument as parseDocument5 } from "yaml";
|
|
5443
|
+
var DEFAULT_CONFIG4 = {
|
|
5444
|
+
enabled: true,
|
|
5445
|
+
compareMode: "layout-only",
|
|
5446
|
+
requireBeforeVerify: "when_figma",
|
|
5447
|
+
whenNoCapture: "warn"
|
|
5448
|
+
};
|
|
5449
|
+
async function loadVisualConfig(projectRoot) {
|
|
5450
|
+
try {
|
|
5451
|
+
const raw = await readFile19(join25(projectRoot, "openspec", "config.yaml"), "utf8");
|
|
5452
|
+
const doc = parseDocument5(raw);
|
|
5453
|
+
const fetNode = doc.get("fet", true);
|
|
5454
|
+
const node = fetNode?.get?.("visual");
|
|
5455
|
+
if (!node || typeof node.get !== "function") {
|
|
5456
|
+
return DEFAULT_CONFIG4;
|
|
5457
|
+
}
|
|
5458
|
+
const enabled = node.get("enabled");
|
|
5459
|
+
const requireBeforeVerify = node.get("requireBeforeVerify");
|
|
5460
|
+
const whenNoCapture = node.get("whenNoCapture");
|
|
5461
|
+
return {
|
|
5462
|
+
enabled: enabled === void 0 ? true : Boolean(enabled),
|
|
5463
|
+
compareMode: "layout-only",
|
|
5464
|
+
requireBeforeVerify: parseRequireBeforeVerify(requireBeforeVerify),
|
|
5465
|
+
whenNoCapture: parseWhenNoCapture(whenNoCapture)
|
|
5466
|
+
};
|
|
5467
|
+
} catch {
|
|
5468
|
+
return DEFAULT_CONFIG4;
|
|
5469
|
+
}
|
|
5470
|
+
}
|
|
5471
|
+
function parseRequireBeforeVerify(value) {
|
|
5472
|
+
if (value === "off" || value === "always" || value === "when_figma") {
|
|
5473
|
+
return value;
|
|
5474
|
+
}
|
|
5475
|
+
return DEFAULT_CONFIG4.requireBeforeVerify;
|
|
5476
|
+
}
|
|
5477
|
+
function parseWhenNoCapture(value) {
|
|
5478
|
+
if (value === "block" || value === "warn" || value === "skip") {
|
|
5479
|
+
return value;
|
|
5480
|
+
}
|
|
5481
|
+
return DEFAULT_CONFIG4.whenNoCapture;
|
|
5482
|
+
}
|
|
5483
|
+
function isVisualRequiredForVerify(config, hasFigma) {
|
|
5484
|
+
if (!config.enabled) {
|
|
5485
|
+
return false;
|
|
5486
|
+
}
|
|
5487
|
+
if (config.requireBeforeVerify === "off") {
|
|
5488
|
+
return false;
|
|
5489
|
+
}
|
|
5490
|
+
if (config.requireBeforeVerify === "always") {
|
|
5491
|
+
return true;
|
|
5492
|
+
}
|
|
5493
|
+
return hasFigma;
|
|
5494
|
+
}
|
|
5495
|
+
|
|
5496
|
+
// src/visual/playwright.ts
|
|
5497
|
+
import { createRequire } from "module";
|
|
5498
|
+
import { dirname as dirname9, join as join26 } from "path";
|
|
5499
|
+
import { pathToFileURL } from "url";
|
|
5500
|
+
async function resolvePlaywright(projectRoot) {
|
|
5501
|
+
const require2 = createRequire(join26(projectRoot, "package.json"));
|
|
5502
|
+
const candidates = ["playwright", "@playwright/test"];
|
|
5503
|
+
for (const name of candidates) {
|
|
5504
|
+
try {
|
|
5505
|
+
const resolved = require2.resolve(name);
|
|
5506
|
+
const mod = await import(pathToFileURL(resolved).href);
|
|
5507
|
+
if (mod?.chromium) {
|
|
5508
|
+
return mod;
|
|
5509
|
+
}
|
|
5510
|
+
} catch {
|
|
5511
|
+
continue;
|
|
5512
|
+
}
|
|
5513
|
+
}
|
|
5514
|
+
return null;
|
|
5515
|
+
}
|
|
5516
|
+
async function capturePagesWithPlaywright(projectRoot, manifest, baseUrl) {
|
|
5517
|
+
const playwright = await resolvePlaywright(projectRoot);
|
|
5518
|
+
if (!playwright) {
|
|
5519
|
+
return [];
|
|
5520
|
+
}
|
|
5521
|
+
const browser = await playwright.chromium.launch({ headless: true });
|
|
5522
|
+
const results = [];
|
|
5523
|
+
try {
|
|
5524
|
+
for (const page of manifest.pages) {
|
|
5525
|
+
results.push(await captureSinglePage(projectRoot, browser, manifest.changeId, page, baseUrl));
|
|
5526
|
+
}
|
|
5527
|
+
} finally {
|
|
5528
|
+
await browser.close();
|
|
5529
|
+
}
|
|
5530
|
+
return results;
|
|
5531
|
+
}
|
|
5532
|
+
async function captureSinglePage(projectRoot, browser, changeId, pageDef, baseUrl) {
|
|
5533
|
+
const { mkdir: mkdir15 } = await import("fs/promises");
|
|
5534
|
+
const { join: joinPath } = await import("path");
|
|
5535
|
+
const screenshotRelative = visualPageScreenshotRelative(changeId, pageDef.id);
|
|
5536
|
+
const screenshotAbsolute = joinPath(projectRoot, screenshotRelative);
|
|
5537
|
+
await mkdir15(dirname9(screenshotAbsolute), { recursive: true });
|
|
5538
|
+
const url = new URL(pageDef.route, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).toString();
|
|
5539
|
+
const pwPage = await browser.newPage();
|
|
5540
|
+
try {
|
|
5541
|
+
await pwPage.setViewportSize(pageDef.viewport);
|
|
5542
|
+
await pwPage.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 });
|
|
5543
|
+
await pwPage.screenshot({ path: screenshotAbsolute, fullPage: true });
|
|
5544
|
+
const regions = await extractRegions(pwPage, pageDef);
|
|
5545
|
+
return {
|
|
5546
|
+
pageId: pageDef.id,
|
|
5547
|
+
route: pageDef.route,
|
|
5548
|
+
screenshotPath: screenshotRelative,
|
|
5549
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5550
|
+
regions
|
|
5551
|
+
};
|
|
5552
|
+
} catch {
|
|
5553
|
+
return {
|
|
5554
|
+
pageId: pageDef.id,
|
|
5555
|
+
route: pageDef.route,
|
|
5556
|
+
screenshotPath: null,
|
|
5557
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5558
|
+
regions: []
|
|
5559
|
+
};
|
|
5560
|
+
} finally {
|
|
5561
|
+
await pwPage.close();
|
|
5562
|
+
}
|
|
5563
|
+
}
|
|
5564
|
+
var EXTRACT_REGIONS_SCRIPT = `
|
|
5565
|
+
(inputSelectors) => {
|
|
5566
|
+
const layouts = [];
|
|
5567
|
+
for (const selector of inputSelectors) {
|
|
5568
|
+
const element = document.querySelector(selector);
|
|
5569
|
+
if (!element) {
|
|
5570
|
+
layouts.push({
|
|
5571
|
+
selector,
|
|
5572
|
+
exists: false,
|
|
5573
|
+
visible: false,
|
|
5574
|
+
width: 0,
|
|
5575
|
+
height: 0,
|
|
5576
|
+
display: "none",
|
|
5577
|
+
position: "static"
|
|
5578
|
+
});
|
|
5579
|
+
continue;
|
|
5580
|
+
}
|
|
5581
|
+
const style = window.getComputedStyle(element);
|
|
5582
|
+
const rect = element.getBoundingClientRect();
|
|
5583
|
+
layouts.push({
|
|
5584
|
+
selector,
|
|
5585
|
+
exists: true,
|
|
5586
|
+
visible: style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0,
|
|
5587
|
+
width: rect.width,
|
|
5588
|
+
height: rect.height,
|
|
5589
|
+
display: style.display,
|
|
5590
|
+
position: style.position
|
|
5591
|
+
});
|
|
5592
|
+
}
|
|
5593
|
+
return layouts;
|
|
5594
|
+
}
|
|
5595
|
+
`;
|
|
5596
|
+
async function extractRegions(page, pageDef) {
|
|
5597
|
+
const selectors = pageDef.checkRegions.map((region) => region.selector);
|
|
5598
|
+
return page.evaluate(EXTRACT_REGIONS_SCRIPT, selectors);
|
|
5599
|
+
}
|
|
5600
|
+
|
|
5601
|
+
// src/visual/manifest.ts
|
|
5602
|
+
import { mkdir as mkdir9, readFile as readFile20, stat as stat11 } from "fs/promises";
|
|
5603
|
+
import { dirname as dirname10, join as join27 } from "path";
|
|
5604
|
+
import { parse as parse5, stringify as stringify4 } from "yaml";
|
|
5605
|
+
function visualManifestPath(projectRoot, changeId) {
|
|
5606
|
+
return join27(projectRoot, visualManifestRelativePath(changeId));
|
|
5607
|
+
}
|
|
5608
|
+
async function readVisualManifest(projectRoot, changeId) {
|
|
5609
|
+
const path = visualManifestPath(projectRoot, changeId);
|
|
5610
|
+
try {
|
|
5611
|
+
await stat11(path);
|
|
5612
|
+
} catch {
|
|
5613
|
+
return null;
|
|
5614
|
+
}
|
|
5615
|
+
const doc = parse5(await readFile20(path, "utf8"));
|
|
5616
|
+
if (!doc || doc.schemaVersion !== 1 || doc.changeId !== changeId) {
|
|
5617
|
+
return null;
|
|
5618
|
+
}
|
|
5619
|
+
return doc;
|
|
5620
|
+
}
|
|
5621
|
+
async function writeVisualManifest(projectRoot, manifest) {
|
|
5622
|
+
const relative6 = visualManifestRelativePath(manifest.changeId);
|
|
5623
|
+
const path = join27(projectRoot, relative6);
|
|
5624
|
+
await mkdir9(dirname10(path), { recursive: true });
|
|
5625
|
+
await atomicWrite(path, stringify4(manifest));
|
|
5626
|
+
return relative6;
|
|
5627
|
+
}
|
|
5628
|
+
async function readVisualCapture(projectRoot, changeId) {
|
|
5629
|
+
const path = join27(projectRoot, visualCaptureRelativePath(changeId));
|
|
5630
|
+
try {
|
|
5631
|
+
const doc = JSON.parse(await readFile20(path, "utf8"));
|
|
5632
|
+
if (doc?.schemaVersion === 1 && doc.changeId === changeId) {
|
|
5633
|
+
return doc;
|
|
5634
|
+
}
|
|
5635
|
+
} catch {
|
|
5636
|
+
return null;
|
|
5637
|
+
}
|
|
5638
|
+
return null;
|
|
5639
|
+
}
|
|
5640
|
+
async function writeVisualCapture(projectRoot, capture) {
|
|
5641
|
+
const relative6 = visualCaptureRelativePath(capture.changeId);
|
|
5642
|
+
const path = join27(projectRoot, relative6);
|
|
5643
|
+
await mkdir9(dirname10(path), { recursive: true });
|
|
5644
|
+
await atomicWrite(path, `${JSON.stringify(capture, null, 2)}
|
|
5645
|
+
`);
|
|
5646
|
+
return relative6;
|
|
5647
|
+
}
|
|
5648
|
+
async function writeVisualResults(projectRoot, results) {
|
|
5649
|
+
const relative6 = visualResultsRelativePath(results.changeId);
|
|
5650
|
+
const path = join27(projectRoot, relative6);
|
|
5651
|
+
await mkdir9(dirname10(path), { recursive: true });
|
|
5652
|
+
await atomicWrite(path, `${JSON.stringify(results, null, 2)}
|
|
5653
|
+
`);
|
|
5654
|
+
return relative6;
|
|
5655
|
+
}
|
|
5656
|
+
function createVisualManifest(input) {
|
|
5657
|
+
return {
|
|
5658
|
+
schemaVersion: 1,
|
|
5659
|
+
changeId: input.changeId,
|
|
5660
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5661
|
+
fetVersion: FET_VERSION,
|
|
5662
|
+
planningFingerprint: input.planningFingerprint,
|
|
5663
|
+
compareMode: "layout-only",
|
|
5664
|
+
figmaUrls: input.figmaUrls,
|
|
5665
|
+
figmaSources: input.figmaSources,
|
|
5666
|
+
run: { baseUrl: input.baseUrl },
|
|
5667
|
+
pages: input.pages
|
|
5668
|
+
};
|
|
5669
|
+
}
|
|
5670
|
+
|
|
5671
|
+
// src/visual/capture.ts
|
|
5672
|
+
async function runVisualCapture(options) {
|
|
5673
|
+
const warnings = [];
|
|
5674
|
+
const baseUrl = options.baseUrl ?? options.manifest.run.baseUrl;
|
|
5675
|
+
if (!baseUrl) {
|
|
5676
|
+
return handleMissingBaseUrl(options.config, options.language, warnings);
|
|
5677
|
+
}
|
|
5678
|
+
const playwright = await resolvePlaywright(options.projectRoot);
|
|
5679
|
+
if (!playwright) {
|
|
5680
|
+
return handleMissingPlaywright(options.config, options.language, warnings);
|
|
5681
|
+
}
|
|
5682
|
+
const pages = await capturePagesWithPlaywright(options.projectRoot, options.manifest, baseUrl);
|
|
5683
|
+
const failedPages = pages.filter((page) => !page.screenshotPath);
|
|
5684
|
+
const capture = {
|
|
5685
|
+
schemaVersion: 1,
|
|
5686
|
+
changeId: options.changeId,
|
|
5687
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5688
|
+
baseUrl,
|
|
5689
|
+
pages
|
|
5690
|
+
};
|
|
5691
|
+
await writeVisualCapture(options.projectRoot, capture);
|
|
5692
|
+
if (failedPages.length) {
|
|
5693
|
+
return {
|
|
5694
|
+
status: "failed",
|
|
5695
|
+
capture,
|
|
5696
|
+
warnings,
|
|
5697
|
+
message: options.language === "en" ? `Capture failed for ${failedPages.length} page(s). Check dev server and routes.` : `${failedPages.length} \u4E2A\u9875\u9762\u622A\u56FE\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5 dev \u670D\u52A1\u4E0E\u8DEF\u7531\u3002`
|
|
5698
|
+
};
|
|
5699
|
+
}
|
|
5700
|
+
return { status: "passed", capture, warnings };
|
|
5701
|
+
}
|
|
5702
|
+
function handleMissingBaseUrl(config, language, warnings) {
|
|
5703
|
+
const message = language === "en" ? "No base URL for capture. Pass --base-url or set manifest.run.baseUrl." : "\u672A\u914D\u7F6E base URL\uFF0C\u65E0\u6CD5\u622A\u56FE\u3002\u8BF7\u4F7F\u7528 --base-url \u6216\u5728 manifest \u4E2D\u8BBE\u7F6E run.baseUrl\u3002";
|
|
5704
|
+
warnings.push(message);
|
|
5705
|
+
if (config.whenNoCapture === "block") {
|
|
5706
|
+
return { status: "failed", capture: null, warnings, message };
|
|
5707
|
+
}
|
|
5708
|
+
return { status: "skipped", capture: null, warnings, message };
|
|
5709
|
+
}
|
|
5710
|
+
function handleMissingPlaywright(config, language, warnings) {
|
|
5711
|
+
const message = language === "en" ? "Playwright not found in project. Install playwright or use --check-layout-only after manual capture." : "\u9879\u76EE\u672A\u5B89\u88C5 Playwright\u3002\u8BF7\u5B89\u88C5 playwright\uFF0C\u6216\u5728\u624B\u52A8\u622A\u56FE\u540E\u4F7F\u7528 --check-layout-only\u3002";
|
|
5712
|
+
warnings.push(message);
|
|
5713
|
+
if (config.whenNoCapture === "block") {
|
|
5714
|
+
return { status: "failed", capture: null, warnings, message };
|
|
5715
|
+
}
|
|
5716
|
+
return { status: "skipped", capture: null, warnings, message };
|
|
5717
|
+
}
|
|
5718
|
+
|
|
5719
|
+
// src/visual/check-layout.ts
|
|
5720
|
+
function runLayoutCheck(manifest, capture, language, options) {
|
|
5721
|
+
const regions = [];
|
|
5722
|
+
if (!capture && options?.allowManifestOnly) {
|
|
5723
|
+
return runManifestOnlyValidation(manifest, language);
|
|
5724
|
+
}
|
|
5725
|
+
if (capture?.pages.length) {
|
|
5726
|
+
for (const page of manifest.pages) {
|
|
5727
|
+
const captured = capture.pages.find((item) => item.pageId === page.id);
|
|
5728
|
+
for (const region of page.checkRegions) {
|
|
5729
|
+
const layout = captured?.regions.find((item) => item.selector === region.selector);
|
|
5730
|
+
const result = evaluateRegion(page.id, region.selector, region.checks, layout, language);
|
|
5731
|
+
regions.push(result);
|
|
5732
|
+
}
|
|
5733
|
+
}
|
|
5734
|
+
} else {
|
|
5735
|
+
for (const page of manifest.pages) {
|
|
5736
|
+
if (!page.checkRegions.length) {
|
|
5737
|
+
regions.push({
|
|
5738
|
+
pageId: page.id,
|
|
5739
|
+
selector: "(page)",
|
|
5740
|
+
status: "failed",
|
|
5741
|
+
message: language === "en" ? "No checkRegions defined" : "\u672A\u5B9A\u4E49 checkRegions"
|
|
5742
|
+
});
|
|
5743
|
+
continue;
|
|
5744
|
+
}
|
|
5745
|
+
for (const region of page.checkRegions) {
|
|
5746
|
+
regions.push({
|
|
5747
|
+
pageId: page.id,
|
|
5748
|
+
selector: region.selector,
|
|
5749
|
+
status: capture ? "failed" : "skipped",
|
|
5750
|
+
message: language === "en" ? "No capture data; run capture with --base-url or install Playwright" : "\u65E0\u622A\u56FE\u6570\u636E\uFF1B\u8BF7\u5E26 --base-url \u8FD0\u884C capture \u6216\u5B89\u88C5 Playwright"
|
|
5751
|
+
});
|
|
5752
|
+
}
|
|
5753
|
+
}
|
|
5754
|
+
}
|
|
5755
|
+
const failed = regions.filter((item) => item.status === "failed");
|
|
5756
|
+
if (failed.length) {
|
|
5757
|
+
return {
|
|
5758
|
+
status: "failed",
|
|
5759
|
+
regions,
|
|
5760
|
+
message: language === "en" ? `Layout check failed for ${failed.length} region(s).` : `${failed.length} \u4E2A\u533A\u57DF\u7684\u5E03\u5C40\u68C0\u67E5\u672A\u901A\u8FC7\u3002`
|
|
5761
|
+
};
|
|
5762
|
+
}
|
|
5763
|
+
const skipped = regions.filter((item) => item.status === "skipped");
|
|
5764
|
+
if (skipped.length && !capture) {
|
|
5765
|
+
return {
|
|
5766
|
+
status: "failed",
|
|
5767
|
+
regions,
|
|
5768
|
+
message: language === "en" ? "Layout check requires capture data when regions are defined." : "\u5DF2\u5B9A\u4E49\u68C0\u67E5\u533A\u57DF\u65F6\u9700\u8981 capture \u6570\u636E\u624D\u80FD\u5B8C\u6210\u5E03\u5C40\u9A8C\u6536\u3002"
|
|
5769
|
+
};
|
|
5770
|
+
}
|
|
5771
|
+
return { status: "passed", regions };
|
|
5772
|
+
}
|
|
5773
|
+
function runManifestOnlyValidation(manifest, language) {
|
|
5774
|
+
const regions = [];
|
|
5775
|
+
if (!manifest.pages.length) {
|
|
5776
|
+
return {
|
|
5777
|
+
status: "failed",
|
|
5778
|
+
regions,
|
|
5779
|
+
message: language === "en" ? "Visual manifest has no pages." : "\u89C6\u89C9\u6E05\u5355\u4E2D\u6CA1\u6709\u4EFB\u4F55\u9875\u9762\u3002"
|
|
5780
|
+
};
|
|
5781
|
+
}
|
|
5782
|
+
for (const page of manifest.pages) {
|
|
5783
|
+
for (const region of page.checkRegions) {
|
|
5784
|
+
regions.push({
|
|
5785
|
+
pageId: page.id,
|
|
5786
|
+
selector: region.selector,
|
|
5787
|
+
status: "skipped",
|
|
5788
|
+
message: language === "en" ? "Manifest-only mode (layout-only, no pixel/content compare)" : "\u4EC5 manifest \u6821\u9A8C\uFF08layout-only\uFF0C\u4E0D\u5BF9\u6BD4\u50CF\u7D20/\u52A8\u6001\u5185\u5BB9\uFF09"
|
|
5789
|
+
});
|
|
5790
|
+
}
|
|
5791
|
+
}
|
|
5792
|
+
return {
|
|
5793
|
+
status: "passed",
|
|
5794
|
+
regions,
|
|
5795
|
+
message: language === "en" ? "Manifest validated; capture skipped \u2014 layout-only checklist recorded." : "\u6E05\u5355\u6821\u9A8C\u901A\u8FC7\uFF1B\u5DF2\u8DF3\u8FC7\u622A\u56FE \u2014 \u5DF2\u8BB0\u5F55 layout-only \u68C0\u67E5\u9879\u3002"
|
|
5796
|
+
};
|
|
5797
|
+
}
|
|
5798
|
+
function evaluateRegion(pageId, selector, checks, layout, language) {
|
|
5799
|
+
if (!layout) {
|
|
5800
|
+
return {
|
|
5801
|
+
pageId,
|
|
5802
|
+
selector,
|
|
5803
|
+
status: "failed",
|
|
5804
|
+
message: language === "en" ? "Region not found in capture" : "capture \u4E2D\u672A\u627E\u5230\u8BE5\u533A\u57DF"
|
|
5805
|
+
};
|
|
5806
|
+
}
|
|
5807
|
+
for (const check of checks) {
|
|
5808
|
+
if (check === "exists" && !layout.exists) {
|
|
5809
|
+
return { pageId, selector, status: "failed", message: language === "en" ? "Missing element" : "\u5143\u7D20\u4E0D\u5B58\u5728" };
|
|
5810
|
+
}
|
|
5811
|
+
if (check === "visible" && !layout.visible) {
|
|
5812
|
+
return { pageId, selector, status: "failed", message: language === "en" ? "Not visible" : "\u4E0D\u53EF\u89C1" };
|
|
5813
|
+
}
|
|
5814
|
+
if (check === "has-box" && (layout.width <= 0 || layout.height <= 0)) {
|
|
5815
|
+
return { pageId, selector, status: "failed", message: language === "en" ? "Zero layout box" : "\u5E03\u5C40\u5C3A\u5BF8\u4E3A 0" };
|
|
5816
|
+
}
|
|
5817
|
+
}
|
|
5818
|
+
return { pageId, selector, status: "passed" };
|
|
5819
|
+
}
|
|
5820
|
+
function buildVisualResults(input) {
|
|
5821
|
+
return {
|
|
5822
|
+
schemaVersion: 1,
|
|
5823
|
+
changeId: input.changeId,
|
|
5824
|
+
ranAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5825
|
+
compareMode: "layout-only",
|
|
5826
|
+
planningFingerprint: input.planningFingerprint,
|
|
5827
|
+
steps: input.steps,
|
|
5828
|
+
captureStatus: input.captureStatus,
|
|
5829
|
+
layoutStatus: input.layoutStatus,
|
|
5830
|
+
regions: input.regions
|
|
5831
|
+
};
|
|
5832
|
+
}
|
|
5833
|
+
|
|
5834
|
+
// src/visual/generate.ts
|
|
5835
|
+
var DEFAULT_VIEWPORT = { width: 1280, height: 720 };
|
|
5836
|
+
async function buildVisualManifestPages(projectRoot, changeId, figmaUrls) {
|
|
5837
|
+
if (figmaUrls.length) {
|
|
5838
|
+
return [
|
|
5839
|
+
{
|
|
5840
|
+
id: `${changeId}-main`,
|
|
5841
|
+
title: "Main UI screen",
|
|
5842
|
+
route: "/",
|
|
5843
|
+
viewport: DEFAULT_VIEWPORT,
|
|
5844
|
+
dataMode: "mock",
|
|
5845
|
+
mockFixture: null,
|
|
5846
|
+
ignoreSelectors: [".dynamic-list", "[data-dynamic]", "[data-testid='dynamic-content']"],
|
|
5847
|
+
checkRegions: [
|
|
5848
|
+
{ selector: "header, [role='banner'], .app-header", checks: ["exists", "visible", "has-box"] },
|
|
5849
|
+
{ selector: "main, [role='main'], .app-main", checks: ["exists", "visible", "has-box"] },
|
|
5850
|
+
{ selector: "nav, [role='navigation'], .app-nav", checks: ["exists", "has-box"] }
|
|
5851
|
+
]
|
|
5852
|
+
}
|
|
5853
|
+
];
|
|
5854
|
+
}
|
|
5855
|
+
return [
|
|
5856
|
+
{
|
|
5857
|
+
id: `${changeId}-shell`,
|
|
5858
|
+
title: "Application shell",
|
|
5859
|
+
route: "/",
|
|
5860
|
+
viewport: DEFAULT_VIEWPORT,
|
|
5861
|
+
dataMode: "mock",
|
|
5862
|
+
mockFixture: null,
|
|
5863
|
+
ignoreSelectors: [".dynamic-list", "[data-dynamic]"],
|
|
5864
|
+
checkRegions: [{ selector: "body", checks: ["exists", "visible", "has-box"] }]
|
|
5865
|
+
}
|
|
5866
|
+
];
|
|
5867
|
+
}
|
|
5868
|
+
async function generateVisualManifestInput(projectRoot, changeId, baseUrl) {
|
|
5869
|
+
const { urls, sources } = await collectFigmaUrlsFromChange(projectRoot, changeId);
|
|
5870
|
+
const planningFingerprint = await computePlanningFingerprint(projectRoot, changeId);
|
|
5871
|
+
const pages = await buildVisualManifestPages(projectRoot, changeId, urls);
|
|
5872
|
+
return {
|
|
5873
|
+
planningFingerprint,
|
|
5874
|
+
figmaUrls: urls,
|
|
5875
|
+
figmaSources: sources,
|
|
5876
|
+
pages
|
|
5877
|
+
};
|
|
5878
|
+
}
|
|
5879
|
+
function mergeBaseUrl(manifest, baseUrl) {
|
|
5880
|
+
if (!baseUrl) {
|
|
5881
|
+
return manifest;
|
|
5882
|
+
}
|
|
5883
|
+
return {
|
|
5884
|
+
...manifest,
|
|
5885
|
+
run: { baseUrl }
|
|
5886
|
+
};
|
|
5887
|
+
}
|
|
5888
|
+
|
|
5889
|
+
// src/visual/gates.ts
|
|
5890
|
+
async function assertVisualPassed(ctx, changeId) {
|
|
5891
|
+
const config = await loadVisualConfig(ctx.projectRoot);
|
|
5892
|
+
const { urls } = await collectFigmaUrlsFromChange(ctx.projectRoot, changeId);
|
|
5893
|
+
if (!isVisualRequiredForVerify(config, urls.length > 0)) {
|
|
5894
|
+
return;
|
|
5895
|
+
}
|
|
5896
|
+
const change = await ctx.stateStore.readChange(changeId);
|
|
5897
|
+
const visualRun = change?.visualRun;
|
|
5898
|
+
if (visualRun?.status === "skipped") {
|
|
5899
|
+
return;
|
|
5900
|
+
}
|
|
5901
|
+
if (visualRun?.status === "passed" && await fingerprintMatches2(ctx, changeId, visualRun)) {
|
|
5902
|
+
return;
|
|
5903
|
+
}
|
|
5904
|
+
throw new FetError({
|
|
5905
|
+
code: "STATE_CORRUPTED" /* StateCorrupted */,
|
|
5906
|
+
message: msg(ctx.language, "\u672C change \u5C1A\u672A\u901A\u8FC7 fet visual\u3002", "This change has not passed fet visual yet."),
|
|
5907
|
+
details: { changeId, visualRun: visualRun ?? null, figmaUrlCount: urls.length },
|
|
5908
|
+
suggestedCommand: `fet visual --change ${changeId}`,
|
|
5909
|
+
recoverable: true
|
|
5910
|
+
});
|
|
5911
|
+
}
|
|
5912
|
+
async function fingerprintMatches2(ctx, changeId, visualRun) {
|
|
5913
|
+
const current = await computePlanningFingerprint(ctx.projectRoot, changeId);
|
|
5914
|
+
return visualRun.planningFingerprint === current;
|
|
5915
|
+
}
|
|
5916
|
+
function invalidateVisualRun(state) {
|
|
5917
|
+
state.visualRun = null;
|
|
5918
|
+
}
|
|
5919
|
+
function manifestPath2(changeId) {
|
|
5920
|
+
return visualManifestRelativePath(changeId);
|
|
5921
|
+
}
|
|
5922
|
+
|
|
5923
|
+
// src/commands/visual.ts
|
|
5924
|
+
async function visualCommand(ctx, options) {
|
|
5925
|
+
await withProjectLock(ctx.projectRoot, { command: "visual", cwd: ctx.cwd, fetVersion: ctx.fetVersion }, async () => {
|
|
5926
|
+
const changeId = await resolveChangeId(ctx);
|
|
5927
|
+
await assertChangeExists(ctx, changeId);
|
|
5928
|
+
const config = await loadVisualConfig(ctx.projectRoot);
|
|
5929
|
+
const steps = [];
|
|
5930
|
+
const warnings = [];
|
|
5931
|
+
const manifest = await ensureManifest(ctx, changeId, options.baseUrl ?? null);
|
|
5932
|
+
steps.push("manifest");
|
|
5933
|
+
if (options.plan) {
|
|
5934
|
+
ctx.output.result({
|
|
5935
|
+
ok: true,
|
|
5936
|
+
command: "visual",
|
|
5937
|
+
summary: msg(ctx.language, "\u5DF2\u751F\u6210 fet visual \u6267\u884C\u8BA1\u5212\u3002", "Generated fet visual execution plan."),
|
|
5938
|
+
data: {
|
|
5939
|
+
changeId,
|
|
5940
|
+
compareMode: manifest.compareMode,
|
|
5941
|
+
steps: options.checkLayoutOnly ? ["manifest", "check-layout"] : options.captureOnly ? ["manifest", "capture"] : ["manifest", "capture", "check-layout"],
|
|
5942
|
+
baseUrl: options.baseUrl ?? manifest.run.baseUrl,
|
|
5943
|
+
pages: manifest.pages.map((page) => ({ id: page.id, route: page.route }))
|
|
5944
|
+
},
|
|
5945
|
+
nextSteps: [`fet visual --change ${changeId}${options.baseUrl ? ` --base-url ${options.baseUrl}` : ""}`]
|
|
5946
|
+
});
|
|
5947
|
+
return;
|
|
5948
|
+
}
|
|
5949
|
+
let captureStatus = "skipped";
|
|
5950
|
+
let captureDoc = await readVisualCapture(ctx.projectRoot, changeId);
|
|
5951
|
+
if (!options.checkLayoutOnly) {
|
|
5952
|
+
const captureResult = await runVisualCapture({
|
|
5953
|
+
projectRoot: ctx.projectRoot,
|
|
5954
|
+
changeId,
|
|
5955
|
+
manifest,
|
|
5956
|
+
baseUrl: options.baseUrl ?? null,
|
|
5957
|
+
config,
|
|
5958
|
+
language: ctx.language
|
|
5959
|
+
});
|
|
5960
|
+
warnings.push(...captureResult.warnings);
|
|
5961
|
+
captureStatus = captureResult.status;
|
|
5962
|
+
captureDoc = captureResult.capture;
|
|
5963
|
+
steps.push("capture");
|
|
5964
|
+
if (captureResult.status === "failed") {
|
|
5965
|
+
await recordVisualFailure(ctx, changeId, manifest.planningFingerprint, steps, captureStatus, "failed", [], captureResult.message);
|
|
5966
|
+
throw new FetError({
|
|
5967
|
+
code: "OPENSPEC_COMMAND_FAILED" /* OpenSpecCommandFailed */,
|
|
5968
|
+
message: captureResult.message ?? msg(ctx.language, "fet visual \u622A\u56FE\u5931\u8D25\u3002", "fet visual capture failed."),
|
|
5969
|
+
details: { changeId },
|
|
5970
|
+
suggestedCommand: `fet visual --change ${changeId} --base-url http://localhost:3000`,
|
|
5971
|
+
recoverable: true
|
|
5972
|
+
});
|
|
5973
|
+
}
|
|
5974
|
+
} else {
|
|
5975
|
+
steps.push("capture");
|
|
5976
|
+
captureStatus = captureDoc ? "passed" : "skipped";
|
|
5977
|
+
}
|
|
5978
|
+
if (!options.captureOnly) {
|
|
5979
|
+
const layout = runLayoutCheck(manifest, captureDoc, ctx.language, {
|
|
5980
|
+
allowManifestOnly: captureStatus === "skipped" && config.whenNoCapture !== "block"
|
|
5981
|
+
});
|
|
5982
|
+
steps.push("check-layout");
|
|
5983
|
+
const results = buildVisualResults({
|
|
5984
|
+
changeId,
|
|
5985
|
+
planningFingerprint: manifest.planningFingerprint,
|
|
5986
|
+
steps,
|
|
5987
|
+
captureStatus,
|
|
5988
|
+
layoutStatus: layout.status,
|
|
5989
|
+
regions: layout.regions
|
|
5990
|
+
});
|
|
5991
|
+
const resultsPath = await writeVisualResults(ctx.projectRoot, results);
|
|
5992
|
+
if (layout.status === "failed") {
|
|
5993
|
+
await recordVisualFailure(ctx, changeId, manifest.planningFingerprint, steps, captureStatus, layout.status, layout.regions, layout.message);
|
|
5994
|
+
throw new FetError({
|
|
5995
|
+
code: "OPENSPEC_COMMAND_FAILED" /* OpenSpecCommandFailed */,
|
|
5996
|
+
message: layout.message ?? msg(ctx.language, "fet visual \u5E03\u5C40\u68C0\u67E5\u672A\u901A\u8FC7\u3002", "fet visual layout check failed."),
|
|
5997
|
+
details: { changeId, resultsPath: visualResultsRelativePath(changeId) },
|
|
5998
|
+
suggestedCommand: `fet visual --change ${changeId}`,
|
|
5999
|
+
recoverable: true
|
|
6000
|
+
});
|
|
6001
|
+
}
|
|
6002
|
+
await recordVisualSuccess(ctx, changeId, manifest.planningFingerprint, resultsPath, captureStatus);
|
|
6003
|
+
ctx.output.result({
|
|
6004
|
+
ok: true,
|
|
6005
|
+
command: "visual",
|
|
6006
|
+
summary: msg(
|
|
6007
|
+
ctx.language,
|
|
6008
|
+
`change "${changeId}" \u5DF2\u901A\u8FC7 layout-only \u89C6\u89C9\u9A8C\u6536\u3002`,
|
|
6009
|
+
`Layout-only visual verification passed for change "${changeId}".`
|
|
6010
|
+
),
|
|
6011
|
+
warnings: warnings.length ? warnings : void 0,
|
|
6012
|
+
nextSteps: [`fet verify --change ${changeId}`],
|
|
6013
|
+
data: { changeId, results, capturePath: captureDoc ? visualCaptureRelativePath(changeId) : null }
|
|
6014
|
+
});
|
|
6015
|
+
return;
|
|
6016
|
+
}
|
|
6017
|
+
ctx.output.result({
|
|
6018
|
+
ok: true,
|
|
6019
|
+
command: "visual",
|
|
6020
|
+
summary: msg(ctx.language, "fet visual \u622A\u56FE\u6B65\u9AA4\u5DF2\u5B8C\u6210\u3002", "fet visual capture step completed."),
|
|
6021
|
+
warnings: warnings.length ? warnings : void 0,
|
|
6022
|
+
data: { changeId, captureStatus, capturePath: captureDoc ? visualCaptureRelativePath(changeId) : null }
|
|
6023
|
+
});
|
|
6024
|
+
});
|
|
6025
|
+
}
|
|
6026
|
+
async function ensureManifest(ctx, changeId, baseUrl) {
|
|
6027
|
+
const fingerprint2 = await computePlanningFingerprint(ctx.projectRoot, changeId);
|
|
6028
|
+
const existing = await readVisualManifest(ctx.projectRoot, changeId);
|
|
6029
|
+
if (existing && existing.planningFingerprint === fingerprint2 && (!baseUrl || existing.run.baseUrl === baseUrl)) {
|
|
6030
|
+
return existing;
|
|
6031
|
+
}
|
|
6032
|
+
const input = await generateVisualManifestInput(ctx.projectRoot, changeId, baseUrl);
|
|
6033
|
+
const manifest = mergeBaseUrl(
|
|
6034
|
+
createVisualManifest({
|
|
6035
|
+
changeId,
|
|
6036
|
+
planningFingerprint: input.planningFingerprint,
|
|
6037
|
+
figmaUrls: input.figmaUrls,
|
|
6038
|
+
figmaSources: input.figmaSources,
|
|
6039
|
+
baseUrl,
|
|
6040
|
+
pages: input.pages
|
|
6041
|
+
}),
|
|
6042
|
+
baseUrl
|
|
6043
|
+
);
|
|
6044
|
+
await writeVisualManifest(ctx.projectRoot, manifest);
|
|
6045
|
+
const fetDir = join28(ctx.projectRoot, "openspec", "changes", changeId, ".fet");
|
|
6046
|
+
await mkdir10(fetDir, { recursive: true });
|
|
6047
|
+
await atomicWrite(join28(ctx.projectRoot, visualInstructionsRelativePath(changeId)), renderVisualInstructions(changeId, manifest, ctx.language));
|
|
6048
|
+
await atomicWrite(join28(ctx.projectRoot, visualSpecRelativePath(changeId)), renderVisualSpec(changeId, manifest, ctx.language));
|
|
6049
|
+
const changeState = await ctx.stateStore.getOrCreateChange(changeId, "verify");
|
|
6050
|
+
changeState.visual = {
|
|
6051
|
+
status: "ready",
|
|
6052
|
+
generatedAt: manifest.generatedAt,
|
|
6053
|
+
planningFingerprint: fingerprint2,
|
|
6054
|
+
manifestPath: manifestPath2(changeId)
|
|
6055
|
+
};
|
|
6056
|
+
invalidateVisualRun(changeState);
|
|
6057
|
+
await ctx.stateStore.writeChange(changeState);
|
|
6058
|
+
return manifest;
|
|
6059
|
+
}
|
|
6060
|
+
async function recordVisualSuccess(ctx, changeId, planningFingerprint, resultsPath, captureStatus) {
|
|
6061
|
+
const changeState = await ctx.stateStore.getOrCreateChange(changeId, "verify");
|
|
6062
|
+
changeState.visualRun = {
|
|
6063
|
+
status: "passed",
|
|
6064
|
+
ranAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6065
|
+
compareMode: "layout-only",
|
|
6066
|
+
planningFingerprint,
|
|
6067
|
+
manifestPath: manifestPath2(changeId),
|
|
6068
|
+
resultsPath,
|
|
6069
|
+
captureStatus
|
|
6070
|
+
};
|
|
6071
|
+
await ctx.stateStore.writeChange(changeState);
|
|
6072
|
+
}
|
|
6073
|
+
async function recordVisualFailure(ctx, changeId, planningFingerprint, steps, captureStatus, layoutStatus, regions, message) {
|
|
6074
|
+
const results = buildVisualResults({
|
|
6075
|
+
changeId,
|
|
6076
|
+
planningFingerprint,
|
|
6077
|
+
steps,
|
|
6078
|
+
captureStatus,
|
|
6079
|
+
layoutStatus,
|
|
6080
|
+
regions
|
|
6081
|
+
});
|
|
6082
|
+
await writeVisualResults(ctx.projectRoot, results);
|
|
6083
|
+
const changeState = await ctx.stateStore.getOrCreateChange(changeId, "verify");
|
|
6084
|
+
changeState.visualRun = {
|
|
6085
|
+
status: "failed",
|
|
6086
|
+
ranAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6087
|
+
compareMode: "layout-only",
|
|
6088
|
+
planningFingerprint,
|
|
6089
|
+
manifestPath: manifestPath2(changeId),
|
|
6090
|
+
resultsPath: visualResultsRelativePath(changeId),
|
|
6091
|
+
captureStatus
|
|
6092
|
+
};
|
|
6093
|
+
await ctx.stateStore.writeChange(changeState);
|
|
6094
|
+
if (message) {
|
|
6095
|
+
void message;
|
|
6096
|
+
}
|
|
6097
|
+
}
|
|
6098
|
+
|
|
5294
6099
|
// src/commands/verify.ts
|
|
5295
6100
|
import { createHash as createHash2 } from "crypto";
|
|
5296
|
-
import { mkdir as
|
|
5297
|
-
import { join as
|
|
6101
|
+
import { mkdir as mkdir11, readFile as readFile21, stat as stat12 } from "fs/promises";
|
|
6102
|
+
import { join as join29 } from "path";
|
|
5298
6103
|
async function verifyCommand(ctx, options) {
|
|
5299
6104
|
if (options.auto) {
|
|
5300
6105
|
const scan = await ctx.scanner.scan(ctx.projectRoot, {});
|
|
@@ -5361,10 +6166,11 @@ async function verifyCommand(ctx, options) {
|
|
|
5361
6166
|
async function writeInstructions(ctx, changeId) {
|
|
5362
6167
|
await assertChangeExists(ctx, changeId);
|
|
5363
6168
|
await assertTestPassed(ctx, changeId);
|
|
6169
|
+
await assertVisualPassed(ctx, changeId);
|
|
5364
6170
|
const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5365
|
-
const dir =
|
|
5366
|
-
const instructionsPath =
|
|
5367
|
-
await
|
|
6171
|
+
const dir = join29(ctx.projectRoot, "openspec", "changes", changeId, ".fet");
|
|
6172
|
+
const instructionsPath = join29(dir, "verify-instructions.md");
|
|
6173
|
+
await mkdir11(dir, { recursive: true });
|
|
5368
6174
|
await atomicWrite(instructionsPath, renderVerifyInstructions(changeId, generatedAt));
|
|
5369
6175
|
const state = await ctx.stateStore.getOrCreateChange(changeId, "verify");
|
|
5370
6176
|
state.currentPhase = "verify";
|
|
@@ -5380,8 +6186,9 @@ async function writeInstructions(ctx, changeId) {
|
|
|
5380
6186
|
async function markDone(ctx, changeId) {
|
|
5381
6187
|
await assertChangeExists(ctx, changeId);
|
|
5382
6188
|
await assertTestPassed(ctx, changeId);
|
|
6189
|
+
await assertVisualPassed(ctx, changeId);
|
|
5383
6190
|
const declaredAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5384
|
-
const instructionsPath =
|
|
6191
|
+
const instructionsPath = join29(ctx.projectRoot, "openspec", "changes", changeId, ".fet", "verify-instructions.md");
|
|
5385
6192
|
const instructions = await readInstructions(ctx, instructionsPath, changeId);
|
|
5386
6193
|
const instructionsGeneratedAt = readFrontMatterValue(instructions, "generatedAt") ?? declaredAt;
|
|
5387
6194
|
const state = await ctx.stateStore.getOrCreateChange(changeId, "verify");
|
|
@@ -5405,8 +6212,8 @@ async function markDone(ctx, changeId) {
|
|
|
5405
6212
|
}
|
|
5406
6213
|
async function readInstructions(ctx, path, changeId) {
|
|
5407
6214
|
try {
|
|
5408
|
-
await
|
|
5409
|
-
const content = await
|
|
6215
|
+
await stat12(path);
|
|
6216
|
+
const content = await readFile21(path, "utf8");
|
|
5410
6217
|
const fileChangeId = readFrontMatterValue(content, "changeId");
|
|
5411
6218
|
if (fileChangeId !== changeId) {
|
|
5412
6219
|
throw new FetError({
|
|
@@ -5529,12 +6336,12 @@ function renderIdeModelPolicy(command, language = "zh-CN") {
|
|
|
5529
6336
|
import { resolve } from "path";
|
|
5530
6337
|
|
|
5531
6338
|
// src/adapters/codex/index.ts
|
|
5532
|
-
import { mkdir as
|
|
6339
|
+
import { mkdir as mkdir12, readFile as readFile22, stat as stat13 } from "fs/promises";
|
|
5533
6340
|
import { homedir } from "os";
|
|
5534
|
-
import { dirname as
|
|
6341
|
+
import { dirname as dirname11, join as join30 } from "path";
|
|
5535
6342
|
|
|
5536
6343
|
// src/adapters/commands.ts
|
|
5537
|
-
var FET_STANDALONE_COMMANDS = ["tdd", "test"];
|
|
6344
|
+
var FET_STANDALONE_COMMANDS = ["tdd", "test", "visual"];
|
|
5538
6345
|
var FET_WORKFLOW_COMMANDS = [
|
|
5539
6346
|
"explore",
|
|
5540
6347
|
"propose",
|
|
@@ -6617,7 +7424,7 @@ var CodexAdapter = class {
|
|
|
6617
7424
|
adapterVersion = 1;
|
|
6618
7425
|
async detect(projectRoot) {
|
|
6619
7426
|
return {
|
|
6620
|
-
detected: await exists6(
|
|
7427
|
+
detected: await exists6(join30(projectRoot, ".codex")) || await exists6(join30(projectRoot, "AGENTS.md")),
|
|
6621
7428
|
reason: "Codex adapter is available for projects that use AGENTS.md"
|
|
6622
7429
|
};
|
|
6623
7430
|
}
|
|
@@ -6656,7 +7463,7 @@ var CodexAdapter = class {
|
|
|
6656
7463
|
if (existing && !existing.includes("FET:MANAGED") && force) {
|
|
6657
7464
|
await createBackup(target);
|
|
6658
7465
|
}
|
|
6659
|
-
await
|
|
7466
|
+
await mkdir12(dirname11(target), { recursive: true });
|
|
6660
7467
|
await atomicWrite(target, file.content);
|
|
6661
7468
|
written.push(displayPath);
|
|
6662
7469
|
}
|
|
@@ -6683,9 +7490,9 @@ var CodexAdapter = class {
|
|
|
6683
7490
|
};
|
|
6684
7491
|
function resolveTarget(projectRoot, file) {
|
|
6685
7492
|
if (file.root === "codex-home") {
|
|
6686
|
-
return
|
|
7493
|
+
return join30(resolveCodexHome(), file.path);
|
|
6687
7494
|
}
|
|
6688
|
-
return
|
|
7495
|
+
return join30(projectRoot, file.path);
|
|
6689
7496
|
}
|
|
6690
7497
|
function displayPathFor(file) {
|
|
6691
7498
|
if (file.root === "codex-home") {
|
|
@@ -6694,18 +7501,18 @@ function displayPathFor(file) {
|
|
|
6694
7501
|
return file.path;
|
|
6695
7502
|
}
|
|
6696
7503
|
function resolveCodexHome() {
|
|
6697
|
-
return process.env.FET_CODEX_HOME ?? process.env.CODEX_HOME ??
|
|
7504
|
+
return process.env.FET_CODEX_HOME ?? process.env.CODEX_HOME ?? join30(homedir(), ".codex");
|
|
6698
7505
|
}
|
|
6699
7506
|
async function readExisting(path) {
|
|
6700
7507
|
try {
|
|
6701
|
-
return await
|
|
7508
|
+
return await readFile22(path, "utf8");
|
|
6702
7509
|
} catch {
|
|
6703
7510
|
return null;
|
|
6704
7511
|
}
|
|
6705
7512
|
}
|
|
6706
7513
|
async function exists6(path) {
|
|
6707
7514
|
try {
|
|
6708
|
-
await
|
|
7515
|
+
await stat13(path);
|
|
6709
7516
|
return true;
|
|
6710
7517
|
} catch {
|
|
6711
7518
|
return false;
|
|
@@ -6713,8 +7520,8 @@ async function exists6(path) {
|
|
|
6713
7520
|
}
|
|
6714
7521
|
|
|
6715
7522
|
// src/adapters/cursor/index.ts
|
|
6716
|
-
import { mkdir as
|
|
6717
|
-
import { dirname as
|
|
7523
|
+
import { mkdir as mkdir13, readFile as readFile23, stat as stat14 } from "fs/promises";
|
|
7524
|
+
import { dirname as dirname12, join as join31 } from "path";
|
|
6718
7525
|
|
|
6719
7526
|
// src/adapters/cursor/templates.ts
|
|
6720
7527
|
function cursorFigmaStopRuleFile(language = DEFAULT_LANGUAGE) {
|
|
@@ -6849,6 +7656,9 @@ After installation, verify with \`gitnexus --version\`, then run \`fet graph ini
|
|
|
6849
7656
|
if (command === "tdd" || command === "test") {
|
|
6850
7657
|
return renderTddTestSkill(command, usage, language);
|
|
6851
7658
|
}
|
|
7659
|
+
if (command === "visual") {
|
|
7660
|
+
return renderVisualSkill(usage, language);
|
|
7661
|
+
}
|
|
6852
7662
|
if (command === "propose" || command === "continue" || command === "ff") {
|
|
6853
7663
|
return renderPlanningSkill(command, usage, language);
|
|
6854
7664
|
}
|
|
@@ -6970,6 +7780,35 @@ ${uiContractBlock}
|
|
|
6970
7780
|
\u5B9E\u65BD\u524D\u987B\u5DF2\u8FD0\u884C \`fet tdd\`\uFF1B\u5B58\u5728 \`openspec/changes/<change-id>/.fet/tdd-manifest.yaml\` \u65F6\u6309\u6E05\u5355\u7F16\u5199\u6D4B\u8BD5\u4E0E\u5B9E\u73B0\uFF0C\u5E76\u5728 \`fet verify\` \u524D\u5148 \`fet test\`\u3002
|
|
6971
7781
|
`;
|
|
6972
7782
|
}
|
|
7783
|
+
function renderVisualSkill(usage, language) {
|
|
7784
|
+
const body = language === "en" ? `Default \`fet visual\` runs manifest refresh, Playwright capture (needs \`--base-url\`), and **layout-only** checks (no pixel match on dynamic API content). Use \`--plan\`, \`--capture-only\`, or \`--check-layout-only\` only when debugging.` : `\u9ED8\u8BA4 \`fet visual\` \u4F1A\u66F4\u65B0\u6E05\u5355\u3001Playwright \u622A\u56FE\uFF08\u9700 \`--base-url\`\uFF09\u5E76\u505A **layout-only** \u68C0\u67E5\uFF08\u4E0D\u5BF9\u52A8\u6001\u63A5\u53E3\u5185\u5BB9\u505A\u50CF\u7D20\u5BF9\u6BD4\uFF09\u3002\u4EC5\u5728\u8C03\u8BD5\u65F6\u4F7F\u7528 \`--plan\`\u3001\`--capture-only\`\u3001\`--check-layout-only\`\u3002`;
|
|
7785
|
+
return `<!-- FET:MANAGED
|
|
7786
|
+
schemaVersion: 1
|
|
7787
|
+
fetVersion: ${FET_VERSION}
|
|
7788
|
+
generator: cursor-adapter
|
|
7789
|
+
adapterVersion: 1
|
|
7790
|
+
command: ${usage}
|
|
7791
|
+
FET:END -->
|
|
7792
|
+
|
|
7793
|
+
---
|
|
7794
|
+
name: fet-visual
|
|
7795
|
+
description: ${language === "en" ? "Layout-only visual verification for a change" : "change \u7EA7 layout-only \u89C6\u89C9\u9A8C\u6536"}
|
|
7796
|
+
disable-model-invocation: true
|
|
7797
|
+
---
|
|
7798
|
+
|
|
7799
|
+
${renderIdeModelPolicy("visual", language)}
|
|
7800
|
+
|
|
7801
|
+
${languageInstruction(language)}
|
|
7802
|
+
|
|
7803
|
+
\u8BF7\u5728\u7EC8\u7AEF\u4E2D\u6267\u884C\uFF1A
|
|
7804
|
+
|
|
7805
|
+
\`\`\`sh
|
|
7806
|
+
${usage}
|
|
7807
|
+
\`\`\`
|
|
7808
|
+
|
|
7809
|
+
${body}
|
|
7810
|
+
`;
|
|
7811
|
+
}
|
|
6973
7812
|
function renderTddTestSkill(command, usage, language) {
|
|
6974
7813
|
const description = command === "tdd" ? language === "en" ? "Generate per-change TDD manifest and test instructions" : "\u751F\u6210\u672C change \u7684 TDD \u6E05\u5355\u4E0E\u6D4B\u8BD5\u6307\u5F15" : language === "en" ? "Run unit tests scoped to the change TDD manifest" : "\u6309 change TDD \u6E05\u5355\u8FD0\u884C\u5355\u6D4B";
|
|
6975
7814
|
const body = command === "tdd" ? language === "en" ? `After planning artifacts exist, run this before \`fet apply\`. It writes \`openspec/changes/<change-id>/.fet/tdd-manifest.yaml\`, \`tdd-spec.md\`, and \`tdd-instructions.md\`. Then create failing tests in the repo before implementation.` : `\u89C4\u5212\u4EA7\u7269\u5C31\u7EEA\u540E\u3001\`fet apply\` \u4E4B\u524D\u8FD0\u884C\u3002\u4F1A\u5199\u5165 \`openspec/changes/<change-id>/.fet/tdd-manifest.yaml\`\u3001\`tdd-spec.md\`\u3001\`tdd-instructions.md\`\uFF0C\u518D\u5728\u4ED3\u5E93\u4E2D\u7F16\u5199\u9884\u671F\u5931\u8D25\u7684\u6D4B\u8BD5\u3002` : language === "en" ? `Run after implementation. Requires a current \`tdd-manifest.yaml\`. Records pass/fail in FET state; \`fet verify\` is blocked until this passes (unless configured to skip).` : `\u5B9E\u73B0\u5B8C\u6210\u540E\u8FD0\u884C\u3002\u9700\u8981\u6709\u6548\u7684 \`tdd-manifest.yaml\`\u3002\u7ED3\u679C\u5199\u5165 FET \u72B6\u6001\uFF1B\u672A\u901A\u8FC7\u524D \`fet verify\` \u4F1A\u88AB\u62E6\u622A\uFF08\u9664\u975E\u914D\u7F6E\u4E3A skip\uFF09\u3002`;
|
|
@@ -7007,7 +7846,7 @@ var CursorAdapter = class {
|
|
|
7007
7846
|
adapterVersion = 1;
|
|
7008
7847
|
async detect(projectRoot) {
|
|
7009
7848
|
return {
|
|
7010
|
-
detected: await exists7(
|
|
7849
|
+
detected: await exists7(join31(projectRoot, ".cursor")),
|
|
7011
7850
|
reason: "Cursor adapter is available for any project"
|
|
7012
7851
|
};
|
|
7013
7852
|
}
|
|
@@ -7024,7 +7863,7 @@ var CursorAdapter = class {
|
|
|
7024
7863
|
const written = [];
|
|
7025
7864
|
const skipped = [];
|
|
7026
7865
|
for (const file of plan.files) {
|
|
7027
|
-
const target =
|
|
7866
|
+
const target = join31(projectRoot, file.path);
|
|
7028
7867
|
const existing = await readExisting2(target);
|
|
7029
7868
|
if (existing && !existing.includes("FET:MANAGED") && !force) {
|
|
7030
7869
|
throw new FetError({
|
|
@@ -7037,7 +7876,7 @@ var CursorAdapter = class {
|
|
|
7037
7876
|
if (existing && !existing.includes("FET:MANAGED") && force) {
|
|
7038
7877
|
await createBackup(target);
|
|
7039
7878
|
}
|
|
7040
|
-
await
|
|
7879
|
+
await mkdir13(dirname12(target), { recursive: true });
|
|
7041
7880
|
await atomicWrite(target, file.content);
|
|
7042
7881
|
written.push(file.path);
|
|
7043
7882
|
}
|
|
@@ -7047,7 +7886,7 @@ var CursorAdapter = class {
|
|
|
7047
7886
|
const plan = await this.planInstall(projectRoot);
|
|
7048
7887
|
const checks = [];
|
|
7049
7888
|
for (const file of plan.files) {
|
|
7050
|
-
const target =
|
|
7889
|
+
const target = join31(projectRoot, file.path);
|
|
7051
7890
|
const content = await readExisting2(target);
|
|
7052
7891
|
const managed = Boolean(content?.includes("FET:MANAGED"));
|
|
7053
7892
|
const versionMatches = Boolean(content?.includes(`adapterVersion: ${this.adapterVersion}`));
|
|
@@ -7063,14 +7902,14 @@ var CursorAdapter = class {
|
|
|
7063
7902
|
};
|
|
7064
7903
|
async function readExisting2(path) {
|
|
7065
7904
|
try {
|
|
7066
|
-
return await
|
|
7905
|
+
return await readFile23(path, "utf8");
|
|
7067
7906
|
} catch {
|
|
7068
7907
|
return null;
|
|
7069
7908
|
}
|
|
7070
7909
|
}
|
|
7071
7910
|
async function exists7(path) {
|
|
7072
7911
|
try {
|
|
7073
|
-
await
|
|
7912
|
+
await stat14(path);
|
|
7074
7913
|
return true;
|
|
7075
7914
|
} catch {
|
|
7076
7915
|
return false;
|
|
@@ -7082,13 +7921,13 @@ import { execFile as execFile4 } from "child_process";
|
|
|
7082
7921
|
import { promisify as promisify4 } from "util";
|
|
7083
7922
|
|
|
7084
7923
|
// src/openspec/inspector.ts
|
|
7085
|
-
import { readdir as readdir6, stat as
|
|
7086
|
-
import { join as
|
|
7924
|
+
import { readdir as readdir6, stat as stat15 } from "fs/promises";
|
|
7925
|
+
import { join as join32 } from "path";
|
|
7087
7926
|
async function inspectOpenSpecProject(projectRoot) {
|
|
7088
|
-
const openspecPath =
|
|
7089
|
-
const changesPath =
|
|
7090
|
-
const legacyArchivePath =
|
|
7091
|
-
const changesArchivePath =
|
|
7927
|
+
const openspecPath = join32(projectRoot, "openspec");
|
|
7928
|
+
const changesPath = join32(openspecPath, "changes");
|
|
7929
|
+
const legacyArchivePath = join32(openspecPath, "archive");
|
|
7930
|
+
const changesArchivePath = join32(changesPath, "archive");
|
|
7092
7931
|
return {
|
|
7093
7932
|
exists: await exists8(openspecPath),
|
|
7094
7933
|
changes: await listDirectories(changesPath, { exclude: ["archive"] }),
|
|
@@ -7096,13 +7935,13 @@ async function inspectOpenSpecProject(projectRoot) {
|
|
|
7096
7935
|
};
|
|
7097
7936
|
}
|
|
7098
7937
|
async function inspectOpenSpecChange(projectRoot, changeId) {
|
|
7099
|
-
const changePath =
|
|
7100
|
-
const tasksPath =
|
|
7101
|
-
const specsPath =
|
|
7938
|
+
const changePath = join32(projectRoot, "openspec", "changes", changeId);
|
|
7939
|
+
const tasksPath = join32(changePath, "tasks.md");
|
|
7940
|
+
const specsPath = join32(changePath, "specs");
|
|
7102
7941
|
return {
|
|
7103
7942
|
changeId,
|
|
7104
7943
|
exists: await exists8(changePath),
|
|
7105
|
-
hasProposal: await exists8(
|
|
7944
|
+
hasProposal: await exists8(join32(changePath, "proposal.md")),
|
|
7106
7945
|
hasTasks: await exists8(tasksPath),
|
|
7107
7946
|
hasSpecs: await exists8(specsPath),
|
|
7108
7947
|
tasksPath,
|
|
@@ -7120,7 +7959,7 @@ async function listDirectories(path, options = {}) {
|
|
|
7120
7959
|
}
|
|
7121
7960
|
async function exists8(path) {
|
|
7122
7961
|
try {
|
|
7123
|
-
await
|
|
7962
|
+
await stat15(path);
|
|
7124
7963
|
return true;
|
|
7125
7964
|
} catch {
|
|
7126
7965
|
return false;
|
|
@@ -7303,13 +8142,13 @@ function escapeRegExp(value) {
|
|
|
7303
8142
|
}
|
|
7304
8143
|
|
|
7305
8144
|
// src/scanner/routes.ts
|
|
7306
|
-
import { readdir as readdir7, stat as
|
|
7307
|
-
import { join as
|
|
8145
|
+
import { readdir as readdir7, stat as stat16 } from "fs/promises";
|
|
8146
|
+
import { join as join33, relative as relative5, sep } from "path";
|
|
7308
8147
|
async function scanRoutes(projectRoot) {
|
|
7309
8148
|
const candidates = ["src/routes", "src/pages", "app", "pages"];
|
|
7310
8149
|
const routes = [];
|
|
7311
8150
|
for (const candidate of candidates) {
|
|
7312
|
-
const root =
|
|
8151
|
+
const root = join33(projectRoot, candidate);
|
|
7313
8152
|
if (!await exists9(root)) {
|
|
7314
8153
|
continue;
|
|
7315
8154
|
}
|
|
@@ -7337,7 +8176,7 @@ async function listFiles(root) {
|
|
|
7337
8176
|
const entries = await readdir7(root, { withFileTypes: true });
|
|
7338
8177
|
const files = [];
|
|
7339
8178
|
for (const entry of entries) {
|
|
7340
|
-
const path =
|
|
8179
|
+
const path = join33(root, entry.name);
|
|
7341
8180
|
if (entry.isDirectory()) {
|
|
7342
8181
|
files.push(...await listFiles(path));
|
|
7343
8182
|
} else {
|
|
@@ -7348,7 +8187,7 @@ async function listFiles(root) {
|
|
|
7348
8187
|
}
|
|
7349
8188
|
async function exists9(path) {
|
|
7350
8189
|
try {
|
|
7351
|
-
await
|
|
8190
|
+
await stat16(path);
|
|
7352
8191
|
return true;
|
|
7353
8192
|
} catch {
|
|
7354
8193
|
return false;
|
|
@@ -7505,9 +8344,9 @@ async function createCommandContext(command, options) {
|
|
|
7505
8344
|
import { createInterface as createInterface2 } from "readline/promises";
|
|
7506
8345
|
|
|
7507
8346
|
// src/update/check.ts
|
|
7508
|
-
import { mkdir as
|
|
8347
|
+
import { mkdir as mkdir14, readFile as readFile24, writeFile } from "fs/promises";
|
|
7509
8348
|
import { homedir as homedir2 } from "os";
|
|
7510
|
-
import { dirname as
|
|
8349
|
+
import { dirname as dirname13, join as join34 } from "path";
|
|
7511
8350
|
var DEFAULT_CACHE_TTL_MS = 6 * 60 * 60 * 1e3;
|
|
7512
8351
|
function getFetUpdateCheckMode(env = process.env) {
|
|
7513
8352
|
const value = env.FET_UPDATE_CHECK?.trim().toLowerCase();
|
|
@@ -7580,11 +8419,11 @@ function formatFetUpdateWarning(availability, language) {
|
|
|
7580
8419
|
}
|
|
7581
8420
|
function cachePath() {
|
|
7582
8421
|
const home = process.env.FET_UPDATE_CHECK_CACHE_HOME?.trim() || homedir2();
|
|
7583
|
-
return
|
|
8422
|
+
return join34(home, ".fet", "update-check-cache.json");
|
|
7584
8423
|
}
|
|
7585
8424
|
async function readUpdateCheckCache() {
|
|
7586
8425
|
try {
|
|
7587
|
-
const raw = await
|
|
8426
|
+
const raw = await readFile24(cachePath(), "utf8");
|
|
7588
8427
|
const parsed = JSON.parse(raw);
|
|
7589
8428
|
if (typeof parsed.latestVersion !== "string" || typeof parsed.checkedAt !== "string") {
|
|
7590
8429
|
return null;
|
|
@@ -7600,7 +8439,7 @@ async function readUpdateCheckCache() {
|
|
|
7600
8439
|
}
|
|
7601
8440
|
async function writeUpdateCheckCache(cache) {
|
|
7602
8441
|
const path = cachePath();
|
|
7603
|
-
await
|
|
8442
|
+
await mkdir14(dirname13(path), { recursive: true });
|
|
7604
8443
|
await writeFile(path, `${JSON.stringify(cache, null, 2)}
|
|
7605
8444
|
`, "utf8");
|
|
7606
8445
|
}
|
|
@@ -7691,6 +8530,19 @@ addGlobalOptions(program.command("tdd").description("\u6839\u636E\u89C4\u5212\u4
|
|
|
7691
8530
|
addGlobalOptions(program.command("test").description("\u6309 change TDD \u6E05\u5355\u8FD0\u884C\u5355\u6D4B\u5E76\u8BB0\u5F55\u7EFF\u706F\u72B6\u6001").option("--plan", "\u4EC5\u8F93\u51FA\u5C06\u6267\u884C\u7684\u6D4B\u8BD5\u547D\u4EE4\uFF0C\u4E0D\u8FD0\u884C")).action(
|
|
7692
8531
|
wrap("test", (ctx, options) => testCommand(ctx, { plan: Boolean(options.plan) }))
|
|
7693
8532
|
);
|
|
8533
|
+
addGlobalOptions(
|
|
8534
|
+
program.command("visual").description("layout-only \u89C6\u89C9\u9A8C\u6536\uFF08\u9ED8\u8BA4\uFF1A\u66F4\u65B0\u6E05\u5355 + \u622A\u56FE + \u5E03\u5C40\u68C0\u67E5\uFF09").option("--plan", "\u4EC5\u8F93\u51FA\u6267\u884C\u8BA1\u5212").option("--capture-only", "\u4EC5\u6267\u884C\u622A\u56FE").option("--check-layout-only", "\u4EC5\u6267\u884C\u5E03\u5C40\u68C0\u67E5\uFF08\u4F9D\u8D56\u5DF2\u6709 capture\uFF09").option("--base-url <url>", "\u5E94\u7528 base URL\uFF0C\u7528\u4E8E Playwright \u622A\u56FE")
|
|
8535
|
+
).action(
|
|
8536
|
+
wrap(
|
|
8537
|
+
"visual",
|
|
8538
|
+
(ctx, options) => visualCommand(ctx, {
|
|
8539
|
+
plan: Boolean(options.plan),
|
|
8540
|
+
captureOnly: Boolean(options.captureOnly),
|
|
8541
|
+
checkLayoutOnly: Boolean(options.checkLayoutOnly),
|
|
8542
|
+
baseUrl: options.baseUrl
|
|
8543
|
+
})
|
|
8544
|
+
)
|
|
8545
|
+
);
|
|
7694
8546
|
addGlobalOptions(program.command("verify").description("\u6700\u7EC8\u8D28\u91CF\u9A8C\u8BC1").option("--done", "\u58F0\u660E\u624B\u52A8\u9A8C\u8BC1\u5DF2\u5B8C\u6210").option("--auto", "\u751F\u6210\u6216\u6267\u884C\u81EA\u52A8\u9A8C\u8BC1\u8BA1\u5212")).action(
|
|
7695
8547
|
wrap("verify", verifyCommand)
|
|
7696
8548
|
);
|