mirror of
https://github.com/Dicklesworthstone/coding_agent_session_search.git
synced 2026-09-22 11:59:25 +08:00
f85dcbe20b
Application pages JS, service worker, CSS, and the e2e/unit test tree were rewritten by the same formatter pass as the vendor files: quotes, wrapping, trailing commas, and import sort. Viewer behavior is unchanged. - src/pages_assets/*.js, styles.css, sw.js, virtual-list.test.html - tests/e2e, tests/html_export, tests/pages-*.mjs, tests/performance - tests/accessibility, tests/playwright.config.ts Co-authored-by: Grok <noreply@x.ai>
354 lines
9.5 KiB
TypeScript
354 lines
9.5 KiB
TypeScript
import {
|
|
type BrowserContext,
|
|
test as base,
|
|
type ConsoleMessage,
|
|
expect,
|
|
type Page,
|
|
type Request,
|
|
} from "@playwright/test";
|
|
import { existsSync, readFileSync } from "fs";
|
|
import path from "path";
|
|
import { fileURLToPath } from "url";
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
// Load environment variables from .env.test
|
|
const envPath = path.resolve(__dirname, "../.env.test");
|
|
if (existsSync(envPath)) {
|
|
const envContent = readFileSync(envPath, "utf-8");
|
|
for (const rawLine of envContent.split("\n")) {
|
|
const line = rawLine.trim();
|
|
if (!line || line.startsWith("#")) {
|
|
continue;
|
|
}
|
|
const [key, ...valueParts] = line.split("=");
|
|
if (key && valueParts.length > 0) {
|
|
process.env[key] = valueParts.join("=");
|
|
}
|
|
}
|
|
}
|
|
|
|
type ConsoleEntry = {
|
|
type: string;
|
|
text: string;
|
|
location?: { url?: string; lineNumber?: number; columnNumber?: number };
|
|
time: string;
|
|
};
|
|
|
|
type PageErrorEntry = {
|
|
name?: string;
|
|
message: string;
|
|
stack?: string;
|
|
time: string;
|
|
};
|
|
|
|
type RequestFailureEntry = {
|
|
url: string;
|
|
method: string;
|
|
resourceType: string;
|
|
failure?: string;
|
|
time: string;
|
|
};
|
|
|
|
function nowIso(): string {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
function readJsonIfExists(filePath?: string): unknown | null {
|
|
if (!filePath || !existsSync(filePath)) {
|
|
return null;
|
|
}
|
|
try {
|
|
return JSON.parse(readFileSync(filePath, "utf-8"));
|
|
} catch (err) {
|
|
return { error: "Failed to parse JSON log", details: String(err) };
|
|
}
|
|
}
|
|
|
|
function shouldAttachLogs(status: string | undefined, expected: string | undefined): boolean {
|
|
const always = process.env.E2E_LOG_ALWAYS === "1" || process.env.E2E_LOG_ALWAYS === "true";
|
|
return always || status !== expected;
|
|
}
|
|
|
|
/**
|
|
* Test fixtures for HTML export tests.
|
|
*/
|
|
export interface TestFixtures {
|
|
exportPath: string;
|
|
encryptedExportPath: string;
|
|
toolCallsExportPath: string;
|
|
largeExportPath: string;
|
|
unicodeExportPath: string;
|
|
noCdnExportPath: string;
|
|
previewUrl: string;
|
|
password: string;
|
|
}
|
|
|
|
/**
|
|
* Extended test with HTML export fixtures.
|
|
*/
|
|
export const test = base.extend<TestFixtures>({
|
|
page: async ({ page }, use, testInfo) => {
|
|
const consoleEntries: ConsoleEntry[] = [];
|
|
const pageErrors: PageErrorEntry[] = [];
|
|
const requestFailures: RequestFailureEntry[] = [];
|
|
|
|
const onConsole = (msg: ConsoleMessage) => {
|
|
consoleEntries.push({
|
|
type: msg.type(),
|
|
text: msg.text(),
|
|
location: msg.location(),
|
|
time: nowIso(),
|
|
});
|
|
};
|
|
|
|
const onPageError = (error: Error) => {
|
|
pageErrors.push({
|
|
name: error.name,
|
|
message: error.message,
|
|
stack: error.stack,
|
|
time: nowIso(),
|
|
});
|
|
};
|
|
|
|
const onRequestFailed = (request: Request) => {
|
|
requestFailures.push({
|
|
url: request.url(),
|
|
method: request.method(),
|
|
resourceType: request.resourceType(),
|
|
failure: request.failure()?.errorText,
|
|
time: nowIso(),
|
|
});
|
|
};
|
|
|
|
page.on("console", onConsole);
|
|
page.on("pageerror", onPageError);
|
|
page.on("requestfailed", onRequestFailed);
|
|
|
|
await use(page);
|
|
|
|
page.off("console", onConsole);
|
|
page.off("pageerror", onPageError);
|
|
page.off("requestfailed", onRequestFailed);
|
|
|
|
if (shouldAttachLogs(testInfo.status, testInfo.expectedStatus)) {
|
|
let pageUrl: string | null = null;
|
|
try {
|
|
pageUrl = page.url();
|
|
} catch {
|
|
pageUrl = null;
|
|
}
|
|
const setupLog = readJsonIfExists(process.env.TEST_EXPORT_SETUP_LOG);
|
|
const startTime = (testInfo as typeof testInfo & { startTime?: Date }).startTime;
|
|
const logPayload = {
|
|
test: {
|
|
title: testInfo.title,
|
|
file: testInfo.file,
|
|
project: testInfo.project?.name,
|
|
status: testInfo.status,
|
|
expectedStatus: testInfo.expectedStatus,
|
|
retry: testInfo.retry,
|
|
},
|
|
runtime: {
|
|
workerIndex: testInfo.workerIndex,
|
|
parallelIndex: testInfo.parallelIndex,
|
|
startTime: startTime?.toISOString(),
|
|
durationMs: testInfo.duration,
|
|
},
|
|
environment: {
|
|
node: process.version,
|
|
platform: process.platform,
|
|
arch: process.arch,
|
|
exportsDir: process.env.TEST_EXPORTS_DIR,
|
|
exportPaths: {
|
|
basic: process.env.TEST_EXPORT_TEST_BASIC,
|
|
encrypted: process.env.TEST_EXPORT_TEST_ENCRYPTED,
|
|
toolCalls: process.env.TEST_EXPORT_TEST_TOOL_CALLS,
|
|
large: process.env.TEST_EXPORT_TEST_LARGE,
|
|
unicode: process.env.TEST_EXPORT_TEST_UNICODE,
|
|
noCdn: process.env.TEST_EXPORT_TEST_NO_CDN,
|
|
},
|
|
},
|
|
page: {
|
|
url: pageUrl,
|
|
},
|
|
setup: setupLog,
|
|
logs: {
|
|
console: consoleEntries,
|
|
pageErrors,
|
|
requestFailures,
|
|
},
|
|
};
|
|
|
|
await testInfo.attach(`browser-logs-${testInfo.project?.name ?? "default"}`, {
|
|
body: Buffer.from(JSON.stringify(logPayload, null, 2)),
|
|
contentType: "application/json",
|
|
});
|
|
}
|
|
},
|
|
|
|
exportPath: async ({}, use) => {
|
|
const exportPath = process.env.TEST_EXPORT_TEST_BASIC || "";
|
|
await use(exportPath);
|
|
},
|
|
|
|
encryptedExportPath: async ({}, use) => {
|
|
const exportPath = process.env.TEST_EXPORT_TEST_ENCRYPTED || "";
|
|
await use(exportPath);
|
|
},
|
|
|
|
toolCallsExportPath: async ({}, use) => {
|
|
const exportPath = process.env.TEST_EXPORT_TEST_TOOL_CALLS || "";
|
|
await use(exportPath);
|
|
},
|
|
|
|
largeExportPath: async ({}, use) => {
|
|
const exportPath = process.env.TEST_EXPORT_TEST_LARGE || "";
|
|
await use(exportPath);
|
|
},
|
|
|
|
unicodeExportPath: async ({}, use) => {
|
|
const exportPath = process.env.TEST_EXPORT_TEST_UNICODE || "";
|
|
await use(exportPath);
|
|
},
|
|
|
|
noCdnExportPath: async ({}, use) => {
|
|
const exportPath = process.env.TEST_EXPORT_TEST_NO_CDN || "";
|
|
await use(exportPath);
|
|
},
|
|
|
|
previewUrl: async ({}, use) => {
|
|
const previewUrl = process.env.TEST_PAGES_PREVIEW_URL || "";
|
|
await use(previewUrl);
|
|
},
|
|
|
|
password: async ({}, use) => {
|
|
await use(process.env.TEST_EXPORT_PASSWORD || "test-password-123");
|
|
},
|
|
});
|
|
|
|
export { expect };
|
|
|
|
/**
|
|
* Navigate to a local file with appropriate options for file:// URLs.
|
|
* Uses domcontentloaded for faster, more reliable navigation.
|
|
*/
|
|
export async function gotoFile(page: Page, filePath: string): Promise<void> {
|
|
await page.goto(`file://${filePath}`, { waitUntil: "domcontentloaded" });
|
|
}
|
|
|
|
export async function grantClipboardPermissionsIfSupported(
|
|
context: BrowserContext,
|
|
browserName: string,
|
|
permissions: Array<"clipboard-read" | "clipboard-write"> = ["clipboard-read", "clipboard-write"],
|
|
): Promise<boolean> {
|
|
if (browserName !== "chromium") {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
await context.grantPermissions(permissions);
|
|
return true;
|
|
} catch (err) {
|
|
console.log(`[browser-capability] Clipboard permission grant unavailable: ${String(err)}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function focusFirstKeyboardReachableElement(
|
|
page: Page,
|
|
maxTabs = 8,
|
|
): Promise<boolean> {
|
|
for (let i = 0; i < maxTabs; i++) {
|
|
await page.keyboard.press("Tab");
|
|
const hasFocus = await page.evaluate(() => {
|
|
const el = document.activeElement;
|
|
return !!el && el !== document.body && el !== document.documentElement;
|
|
});
|
|
if (hasFocus) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Utility to collect console errors during test.
|
|
*/
|
|
export async function collectConsoleErrors(page: Page): Promise<string[]> {
|
|
const errors: string[] = [];
|
|
page.on("console", (msg) => {
|
|
if (msg.type() === "error") {
|
|
errors.push(msg.text());
|
|
}
|
|
});
|
|
return errors;
|
|
}
|
|
|
|
/**
|
|
* Start collecting browser failures before navigation. Load-time failures are
|
|
* the most important ones for self-contained exports, so callers must create
|
|
* this collector before `gotoFile`/`page.goto` and assert both arrays after
|
|
* deferred scripts have settled.
|
|
*/
|
|
export function collectBrowserErrors(page: Page): {
|
|
consoleErrors: string[];
|
|
pageErrors: string[];
|
|
} {
|
|
const consoleErrors: string[] = [];
|
|
const pageErrors: string[] = [];
|
|
page.on("console", (message) => {
|
|
if (message.type() === "error") {
|
|
consoleErrors.push(message.text());
|
|
}
|
|
});
|
|
page.on("pageerror", (error) => {
|
|
pageErrors.push(error.message);
|
|
});
|
|
return { consoleErrors, pageErrors };
|
|
}
|
|
|
|
/**
|
|
* Utility to wait for page to be fully loaded including lazy resources.
|
|
* For file:// URLs, we use domcontentloaded which is faster and more reliable.
|
|
*/
|
|
export async function waitForPageReady(page: Page): Promise<void> {
|
|
// For local file URLs, domcontentloaded is sufficient and more reliable
|
|
await page.waitForLoadState("domcontentloaded");
|
|
// Stabilize animations/transitions to avoid flake from entrance effects
|
|
await page.addStyleTag({
|
|
content: `
|
|
*,
|
|
*::before,
|
|
*::after {
|
|
animation-duration: 0s !important;
|
|
animation-delay: 0s !important;
|
|
transition-duration: 0s !important;
|
|
transition-delay: 0s !important;
|
|
scroll-behavior: auto !important;
|
|
}
|
|
.message {
|
|
opacity: 1 !important;
|
|
transform: none !important;
|
|
}
|
|
`,
|
|
});
|
|
// Short wait for any immediate scripts to run
|
|
await page.waitForTimeout(150);
|
|
}
|
|
|
|
/**
|
|
* Count messages in the rendered HTML.
|
|
*/
|
|
export async function countMessages(page: Page): Promise<number> {
|
|
return page.locator(".message").count();
|
|
}
|
|
|
|
/**
|
|
* Get the current theme from the page.
|
|
*/
|
|
export async function getCurrentTheme(page: Page): Promise<string> {
|
|
return (await page.locator("html").getAttribute("data-theme")) || "unknown";
|
|
}
|