| 1 | // Run: tsx src/__tests__/workspace-preview-css.test.ts |
| 2 | |
| 3 | import { readFileSync } from "node:fs"; |
| 4 | import { dirname, resolve } from "node:path"; |
| 5 | import { fileURLToPath } from "node:url"; |
| 6 | import { JSDOM } from "jsdom"; |
| 7 | |
| 8 | const testDir = dirname(fileURLToPath(import.meta.url)); |
| 9 | const styles = readFileSync(resolve(testDir, "../styles.css"), "utf8"); |
| 10 | const appGo = readFileSync(resolve(testDir, "../../../app.go"), "utf8"); |
| 11 | const localeNotices = [ |
| 12 | [readFileSync(resolve(testDir, "../locales/en.ts"), "utf8"), '"workspace.truncated": "Preview truncated to the first 2 MiB."'], |
| 13 | [readFileSync(resolve(testDir, "../locales/zh.ts"), "utf8"), '"workspace.truncated": "预览已截断到前 2 MiB。"'], |
| 14 | [readFileSync(resolve(testDir, "../locales/zh-TW.ts"), "utf8"), '"workspace.truncated": "預覽已截斷到前 2 MiB。"'], |
| 15 | ]; |
| 16 | const localeSearchLabels = [ |
| 17 | [readFileSync(resolve(testDir, "../locales/en.ts"), "utf8"), '"workspace.searchPlaceholder": "Find"'], |
| 18 | [readFileSync(resolve(testDir, "../locales/zh.ts"), "utf8"), '"workspace.searchPlaceholder": "查找"'], |
| 19 | [readFileSync(resolve(testDir, "../locales/zh-TW.ts"), "utf8"), '"workspace.searchPlaceholder": "尋找"'], |
| 20 | ]; |
| 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 | function computedDeclaration(html: string, selector: string, property: string): string { |
| 59 | const dom = new JSDOM(html); |
| 60 | const style = dom.window.document.createElement("style"); |
| 61 | style.textContent = styles; |
| 62 | dom.window.document.head.append(style); |
| 63 | const element = dom.window.document.querySelector(selector); |
| 64 | if (!element) throw new Error(`Missing selector in test DOM: ${selector}`); |
| 65 | return dom.window.getComputedStyle(element).getPropertyValue(property).trim(); |
| 66 | } |
| 67 | |
| 68 | console.log("\nworkspace preview css"); |
| 69 | |
| 70 | eq(finalDeclaration(".workspace-preview__body--code", "overflow"), "hidden", "code preview body does not create a nested scroller"); |
| 71 | eq(finalDeclaration(".workspace-preview__body--code", "display"), "flex", "code preview body hosts an editor-like viewport"); |
| 72 | eq(finalDeclaration(".workspace-preview__body--code", "flex-direction"), "column", "truncated code notes stack above the code viewport"); |
| 73 | eq( |
| 74 | computedDeclaration( |
| 75 | `<html data-theme-style="default"><head></head><body><aside class="workspace-panel workspace-panel--embedded"><div class="workspace-preview__body workspace-preview__body--code"></div></aside></body></html>`, |
| 76 | ".workspace-preview__body--code", |
| 77 | "padding", |
| 78 | ), |
| 79 | "0px", |
| 80 | "code preview body keeps zero padding under embedded and themed cascade", |
| 81 | ); |
| 82 | eq(finalDeclaration(".workspace-preview__body--code .workspace-note", "flex"), "0 0 auto", "code truncation note keeps its own row"); |
| 83 | eq(finalDeclaration(".workspace-preview__body--code .code-block", "display"), "flex", "code block fills the preview viewport"); |
| 84 | eq(finalDeclaration(".workspace-preview__body--code .code-block__wrap", "display"), "flex", "code wrapper keeps the preview viewport height"); |
| 85 | eq(finalDeclaration(".workspace-preview__body--code .code-block__wrap", "flex"), "1 1 auto", "code wrapper participates in the preview flex height"); |
| 86 | eq(finalDeclaration(".workspace-preview__body--code .code-block__wrap", "flex-direction"), "column", "search toolbar stacks above the code viewport"); |
| 87 | eq(finalDeclaration(".workspace-preview__body--code .code-block__wrap", "min-height"), "0", "code wrapper can shrink around the scrollable code viewport"); |
| 88 | eq(finalDeclaration(".workspace-preview__body--code .code-search", "position"), "static", "workspace search toolbar participates in layout"); |
| 89 | eq(finalDeclaration(".workspace-preview__body--code .code-search", "z-index"), "var(--z-local-handle)", "workspace search toolbar stays clickable above the copy affordance"); |
| 90 | eq(finalDeclaration(".workspace-preview__body--code .code-search", "flex"), "0 0 auto", "workspace search toolbar keeps a dedicated row"); |
| 91 | eq(finalDeclaration(".workspace-preview__body--code .code-search", "flex-wrap"), "wrap", "narrow workspace search toolbars can form a second control row"); |
| 92 | eq(finalDeclaration(".workspace-preview__body--code .code-search", "width"), "100%", "workspace search toolbar spans the code viewport"); |
| 93 | eq(finalDeclaration(".workspace-preview__body--code .code-search", "border-width"), "0 0 1px", "workspace search toolbar uses a divider instead of a floating card"); |
| 94 | eq(finalDeclaration(".workspace-preview__body--code .code-search", "box-shadow"), "none", "workspace search toolbar does not retain popup elevation"); |
| 95 | eq(finalDeclaration(".code-search__actions", "display"), "flex", "search actions remain one coherent wrapping group"); |
| 96 | eq(finalDeclaration(".code-search__actions", "margin-left"), "auto", "wrapped search actions stay aligned to the toolbar edge"); |
| 97 | eq(finalDeclaration(".workspace-preview__body--code .code", "overflow"), "auto", "code viewport owns horizontal and vertical scrolling"); |
| 98 | eq(finalDeclaration(".workspace-preview__body--code .code", "min-height"), "0", "code viewport can shrink inside the preview pane"); |
| 99 | eq(finalDeclaration(".workspace-preview__body--code .code", "margin"), "0", "code viewport scrollbar sits at the visible pane bottom"); |
| 100 | eq(finalDeclaration(".code-search__input", "min-width"), "60px", "search input reserves a minimum readable width"); |
| 101 | eq( |
| 102 | computedDeclaration( |
| 103 | `<html data-theme-style="default"><head></head><body><div class="workspace-preview__body workspace-preview__body--code"><div class="code code--lines"></div></div></body></html>`, |
| 104 | ".code--lines", |
| 105 | "padding", |
| 106 | ), |
| 107 | "0px", |
| 108 | "themed workspace code keeps the line-number gutter flush", |
| 109 | ); |
| 110 | eq( |
| 111 | finalDeclaration(".workspace-panel--with-tree-rail:not(.workspace-panel--tree-hidden)", "grid-template-columns"), |
| 112 | "var(--workspace-tree-rail-width) var(--workspace-tree-width) minmax(var(--workspace-preview-min-width), 1fr)", |
| 113 | "split mode keeps a narrow tree toggle rail beside the file tree", |
| 114 | ); |
| 115 | eq(finalDeclaration(".workspace-panel--with-tree-rail:not(.workspace-panel--tree-hidden) .workspace-files", "grid-column"), "2", "file tree sits beside the rail"); |
| 116 | eq(finalDeclaration(".workspace-panel--with-tree-rail:not(.workspace-panel--tree-hidden) .workspace-preview", "grid-column"), "3", "preview sits after rail and tree"); |
| 117 | eq( |
| 118 | finalDeclaration(".workspace-panel--with-tree-rail .workspace-tree-resizer", "left"), |
| 119 | "calc(var(--workspace-tree-rail-width) + var(--workspace-tree-width) - 4px)", |
| 120 | "tree resizer accounts for the persistent rail", |
| 121 | ); |
| 122 | eq( |
| 123 | finalDeclaration(".workspace-panel--tree-hidden", "grid-template-columns"), |
| 124 | "var(--workspace-tree-rail-width) minmax(0, 1fr)", |
| 125 | "preview-only mode keeps a narrow tree toggle rail", |
| 126 | ); |
| 127 | eq(finalDeclaration(".workspace-panel--tree-hidden .workspace-preview", "grid-column"), "2", "preview sits beside the rail"); |
| 128 | eq( |
| 129 | /const filePreviewLimit = 2 \* 1024 \* 1024/.test(appGo), |
| 130 | true, |
| 131 | "backend workspace preview limit remains 2 MiB", |
| 132 | ); |
| 133 | eq( |
| 134 | localeNotices.every(([source, expected]) => source.includes(expected)), |
| 135 | true, |
| 136 | "all locale truncation notices match the 2 MiB backend limit", |
| 137 | ); |
| 138 | eq( |
| 139 | localeNotices.every(([source]) => !source.includes("256 KB")), |
| 140 | true, |
| 141 | "locale truncation notices do not retain the previous 256 KB limit", |
| 142 | ); |
| 143 | eq( |
| 144 | localeSearchLabels.every(([source, expected]) => source.includes(expected)), |
| 145 | true, |
| 146 | "all locales provide workspace search labels", |
| 147 | ); |
| 148 | |
| 149 | console.log(`\n${passed} passed, ${failed} failed`); |
| 150 | if (failed > 0) process.exit(1); |
| 151 |