返回 DeepSeek-Reasonix
footer-decision-overflow.test.ts
根目录 / desktop / frontend / src / __tests__ / footer-decision-overflow.test.ts
1 // Run: tsx src/__tests__/footer-decision-overflow.test.ts
2 //
3 // Contract: the footer becomes a scroll/clip container ONLY while a decision
4 // surface (plan/tool approval, ask, clear-context) is shown. Scoping the
5 // overflow to `.footer--decision` keeps tall approval cards scrolling inside a
6 // capped footer (#7030) while leaving the composer state unclipped, so the
7 // upward-popping "/" and "@" menus (.slashmenu — position:absolute; bottom:
8 // calc(100% + 6px)) can expand to full height. An unconditional overflow-y on
9 // .footer clipped those menus to a thin strip above the input box (regression
10 // shipped after desktop-v1.18.0 via the #7030 fix in #7069).
11
12 import { readFileSync } from "node:fs";
13 import { dirname, resolve } from "node:path";
14 import { fileURLToPath } from "node:url";
15
16 const testDir = dirname(fileURLToPath(import.meta.url));
17 // Strip comments so declaration parsing never matches prose inside them.
18 const styles = readFileSync(resolve(testDir, "../styles.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
19 const appSource = readFileSync(resolve(testDir, "../App.tsx"), "utf8");
20 const composerSource = readFileSync(resolve(testDir, "../components/Composer.tsx"), "utf8");
21
22 let passed = 0;
23 let failed = 0;
24
25 function eq(a: unknown, b: unknown, label: string) {
26 if (a === b) {
27 process.stdout.write(` PASS ${label}\n`);
28 passed += 1;
29 } else {
30 process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
31 failed += 1;
32 }
33 }
34
35 function matchingBlocks(selector: string): string[] {
36 const blocks: string[] = [];
37 const rule = /([^{}]+)\{([^{}]*)\}/g;
38 let match: RegExpExecArray | null;
39 while ((match = rule.exec(styles)) !== null) {
40 const selectors = match[1].split(",").map((part) => part.trim());
41 if (selectors.includes(selector)) blocks.push(match[2]);
42 }
43 return blocks;
44 }
45
46 function finalDeclaration(selector: string, property: string): string | undefined {
47 let value: string | undefined;
48 for (const block of matchingBlocks(selector)) {
49 const declaration = new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, "g");
50 let match: RegExpExecArray | null;
51 while ((match = declaration.exec(block)) !== null) {
52 value = match[1].trim();
53 }
54 }
55 return value;
56 }
57
58 console.log("\nfooter decision overflow");
59
60 // AC1 — composer state: the base footer is not a scroll/clip container, so the
61 // upward-popping menus are free to expand.
62 eq(finalDeclaration(".footer", "overflow-y"), undefined, "base .footer does not scroll/clip (composer menus can expand)");
63 eq(finalDeclaration(".footer", "max-height"), undefined, "base .footer is not height-capped in the composer state");
64 eq(finalDeclaration(".footer", "flex"), "0 0 auto", "base .footer keeps its flex sizing");
65
66 // AC2 — decision state: capped + scrollable so tall approval cards stay
67 // reachable and never slip behind the status bar (#7030 stays fixed).
68 eq(finalDeclaration(".footer--decision", "overflow-y"), "auto", "decision footer scrolls tall approval cards");
69 eq(finalDeclaration(".footer--decision", "max-height"), "calc(100% - var(--topicbar-height))", "decision footer is capped above the status bar");
70
71 // AC3 — the menu stays anchored above the composer, but its preferred 360px cap
72 // is now bounded by the real viewport space measured by Composer.
73 eq(finalDeclaration(".slashmenu", "position"), "absolute", ".slashmenu still anchors to the composer");
74 eq(finalDeclaration(".slashmenu", "bottom"), "calc(100% + 6px)", ".slashmenu still pops above the composer");
75 eq(finalDeclaration(".slashmenu", "max-height"), "min(360px, var(--composer-menu-available-height, 50vh))", ".slashmenu uses the measured available height");
76
77 // AC4 — App.tsx applies footer--decision exactly when a decision surface owns
78 // the footer, and the pre-existing footer--compact wiring stays intact.
79 eq(/decisionSurface \? "footer--decision" : ""/.test(appSource), true, "App.tsx toggles footer--decision on decisionSurface");
80 eq(/terminalPanelOpen && !sidebarCreation \? "footer--compact" : ""/.test(appSource), true, "footer--compact wiring stays intact");
81 eq(/ref=\{composerWrapRef\}/.test(composerSource), true, "Composer measures the real menu anchor");
82 eq(/observeComposerMenuViewport\(anchor\)/.test(composerSource), true, "Composer observes geometry while a menu is open");
83
84 // AC5 — mutual-exclusivity guard: the composer host is hidden while a decision
85 // owns the footer, so a menu can never open in the decision state.
86 eq(finalDeclaration(".composer-decision-host--hidden", "display"), "none !important", "composer host stays hidden under a decision (menus cannot open)");
87
88 // AC6 — guard the detection gap noted in #7128's first review and covered by
89 // #7138: no scoped footer variant except the decision modifier may reintroduce
90 // clipping or a height cap around the composer menus.
91 function footerTarget(selectorPart: string): string {
92 return selectorPart.split(/[\s>+~]+/).pop() ?? "";
93 }
94
95 function targetsFooterElement(selectorPart: string): boolean {
96 return /\.footer(?!__)(--[a-z-]+)?(?=$|[.:\[])/.test(footerTarget(selectorPart));
97 }
98
99 function isDecisionTarget(selectorPart: string): boolean {
100 return footerTarget(selectorPart).includes(".footer--decision");
101 }
102
103 function declValue(body: string, property: string): string | undefined {
104 const declaration = new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, "g");
105 let value: string | undefined;
106 let match: RegExpExecArray | null;
107 while ((match = declaration.exec(body)) !== null) value = match[1].trim();
108 return value;
109 }
110
111 function footerClipOffenders(): string[] {
112 const offenders: string[] = [];
113 const rule = /([^{}]+)\{([^{}]*)\}/g;
114 let match: RegExpExecArray | null;
115 while ((match = rule.exec(styles)) !== null) {
116 const selectorList = match[1].trim();
117 const parts = selectorList.split(",").map((part) => part.trim());
118 const footerParts = parts.filter(targetsFooterElement);
119 if (footerParts.length === 0 || footerParts.every(isDecisionTarget)) continue;
120 const body = match[2];
121 const clips =
122 (declValue(body, "overflow") ?? "visible") !== "visible" ||
123 (declValue(body, "overflow-y") ?? "visible") !== "visible" ||
124 (declValue(body, "overflow-x") ?? "visible") !== "visible";
125 if (clips || declValue(body, "max-height") !== undefined) offenders.push(selectorList);
126 }
127 return offenders;
128 }
129
130 eq(footerClipOffenders().join(", "), "", "no footer rule except .footer--decision clips or caps the footer");
131
132 // AC7 — repeated pasted blocks and attachment rows stay bounded inside the
133 // composer footer. Pasted blocks must not flex-shrink vertically, otherwise
134 // the capped list compresses each block instead of becoming scrollable.
135 eq(finalDeclaration(".composer__pasted", "max-height"), "min(30vh, 240px)", "pasted block list is viewport-bounded");
136 eq(finalDeclaration(".composer__pasted", "overflow-y"), "auto", "pasted block list scrolls internally");
137 eq(finalDeclaration(".composer__pasted-block", "flex-shrink"), "0", "pasted blocks keep their readable height");
138 eq(finalDeclaration(".composer-context", "max-height"), "min(30vh, 200px)", "attachment list is viewport-bounded");
139 eq(finalDeclaration(".composer-context", "overflow-y"), "auto", "attachment list scrolls internally");
140
141 console.log(`\n${passed} passed, ${failed} failed`);
142 if (failed > 0) process.exit(1);
143
143 lines TYPESCRIPT