feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Wait for finishing CSS/Web animations on the page to complete, bounded by a
|
||||
* short timeout.
|
||||
*
|
||||
* Radix UI components animate in via CSS transitions. Playwright's
|
||||
* `toBeVisible()` resolves mid-animation, producing greyed-out or
|
||||
* partially-rendered screenshots. Call this before any `page.screenshot()`
|
||||
* or `locator.screenshot()` to guarantee a fully-rendered frame.
|
||||
*
|
||||
* Looping animations (spinners, pulsing presence dots) never settle, and
|
||||
* animations can also start or restart between sampling and awaiting their
|
||||
* `.finished` promise — so an unbounded `Promise.all` can hang until
|
||||
* Playwright aborts the `evaluate`. Race the settle against a short ceiling:
|
||||
* once the in-flight transitions have had time to land, take the frame.
|
||||
*/
|
||||
export async function waitForAnimations(
|
||||
page: Page,
|
||||
timeoutMs = 1000,
|
||||
): Promise<void> {
|
||||
await page.evaluate((ceiling) => {
|
||||
const settled = Promise.all(
|
||||
document.getAnimations().map((a) => a.finished.catch(() => undefined)),
|
||||
);
|
||||
const ceilingHit = new Promise<void>((resolve) =>
|
||||
setTimeout(resolve, ceiling),
|
||||
);
|
||||
return Promise.race([settled.then(() => undefined), ceilingHit]);
|
||||
}, timeoutMs);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,101 @@
|
||||
import { expect, type Locator } from "@playwright/test";
|
||||
|
||||
export async function expectCornerRadiusPx(
|
||||
locator: Locator,
|
||||
expectedRadiusPx: number,
|
||||
) {
|
||||
const measurement = await locator.evaluate((element) => {
|
||||
const style = window.getComputedStyle(element);
|
||||
const rootFontSize = Number.parseFloat(
|
||||
window.getComputedStyle(document.documentElement).fontSize,
|
||||
);
|
||||
|
||||
const resolveLength = (value: string) => {
|
||||
const probe = document.createElement("div");
|
||||
for (const sourceStyle of [
|
||||
window.getComputedStyle(document.documentElement),
|
||||
style,
|
||||
]) {
|
||||
for (let i = 0; i < sourceStyle.length; i += 1) {
|
||||
const name = sourceStyle.item(i);
|
||||
if (name.startsWith("--")) {
|
||||
probe.style.setProperty(name, sourceStyle.getPropertyValue(name));
|
||||
}
|
||||
}
|
||||
}
|
||||
probe.style.position = "absolute";
|
||||
probe.style.visibility = "hidden";
|
||||
probe.style.pointerEvents = "none";
|
||||
probe.style.width = value;
|
||||
document.body.append(probe);
|
||||
const resolved = window.getComputedStyle(probe).width;
|
||||
probe.remove();
|
||||
return resolved;
|
||||
};
|
||||
|
||||
const toPx = (value: string): number => {
|
||||
const trimmed = value.trim();
|
||||
if (/^-?\d+(?:\.\d+)?px$/.test(trimmed)) {
|
||||
return Number.parseFloat(trimmed);
|
||||
}
|
||||
if (/^-?\d+(?:\.\d+)?rem$/.test(trimmed)) {
|
||||
return Number.parseFloat(trimmed) * rootFontSize;
|
||||
}
|
||||
|
||||
const resolved = resolveLength(trimmed);
|
||||
if (resolved !== trimmed) {
|
||||
return toPx(resolved);
|
||||
}
|
||||
return Number.parseFloat(resolved);
|
||||
};
|
||||
|
||||
const rawRadius =
|
||||
style.borderTopLeftRadius ||
|
||||
style.getPropertyValue("border-top-left-radius") ||
|
||||
style.borderRadius ||
|
||||
style.getPropertyValue("border-radius") ||
|
||||
(element instanceof HTMLElement
|
||||
? element.style.borderTopLeftRadius || element.style.borderRadius
|
||||
: "");
|
||||
const radius = toPx(rawRadius);
|
||||
if (!Number.isFinite(radius)) {
|
||||
throw new Error(`Could not resolve border radius from "${rawRadius}".`);
|
||||
}
|
||||
return {
|
||||
className: element.getAttribute("class") ?? "",
|
||||
radius,
|
||||
rawRadius,
|
||||
};
|
||||
});
|
||||
|
||||
expect(
|
||||
measurement.radius,
|
||||
`Expected ${expectedRadiusPx}px corner radius, got ${measurement.rawRadius} on class "${measurement.className}".`,
|
||||
).toBeCloseTo(expectedRadiusPx, 0);
|
||||
}
|
||||
|
||||
export async function expectSmoothCorners(
|
||||
locator: Locator,
|
||||
expectedSmoothing = 0.6,
|
||||
) {
|
||||
await expect
|
||||
.poll(async () =>
|
||||
locator.evaluate((element, smoothing) => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const clipPath =
|
||||
element.style.clipPath ||
|
||||
element.style.getPropertyValue("-webkit-clip-path");
|
||||
|
||||
return (
|
||||
element.dataset.smoothCorners === "" &&
|
||||
element.dataset.smoothCornersSmoothing === String(smoothing) &&
|
||||
clipPath.startsWith('path("M ') &&
|
||||
clipPath.includes(" a ")
|
||||
);
|
||||
}, expectedSmoothing),
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Stub getUserMedia with a canvas-generated stream (animated gradient with a
|
||||
* moving circle) so the camera phases work headless and deterministically —
|
||||
* Playwright's bundled headless shell has no media capture support.
|
||||
*/
|
||||
export function installFakeCamera(
|
||||
page: Page,
|
||||
options: {
|
||||
cameraDelayMs?: number;
|
||||
failRequests?: number;
|
||||
holdCamera?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
return page.addInitScript(
|
||||
(cameraOptions) => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 640;
|
||||
canvas.height = 480;
|
||||
const context = canvas.getContext("2d");
|
||||
let hue = 0;
|
||||
setInterval(() => {
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
hue = (hue + 7) % 360;
|
||||
context.fillStyle = `hsl(${hue} 80% 60%)`;
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
context.fillStyle = "#ffffff";
|
||||
context.beginPath();
|
||||
context.arc(
|
||||
canvas.width / 2 + Math.sin(hue / 30) * 60,
|
||||
canvas.height / 2,
|
||||
90,
|
||||
0,
|
||||
Math.PI * 2,
|
||||
);
|
||||
context.fill();
|
||||
}, 90);
|
||||
const stream = canvas.captureStream(15);
|
||||
const mediaDevices = navigator.mediaDevices ?? ({} as MediaDevices);
|
||||
if (!navigator.mediaDevices) {
|
||||
Object.defineProperty(navigator, "mediaDevices", {
|
||||
configurable: true,
|
||||
value: mediaDevices,
|
||||
});
|
||||
}
|
||||
const devices: MediaDeviceInfo[] = [
|
||||
{
|
||||
deviceId: "builtin-camera",
|
||||
groupId: "mac",
|
||||
kind: "videoinput",
|
||||
label: "FaceTime HD Camera",
|
||||
toJSON() {
|
||||
return this;
|
||||
},
|
||||
} as MediaDeviceInfo,
|
||||
{
|
||||
deviceId: "iphone-continuity",
|
||||
groupId: "iphone",
|
||||
kind: "videoinput",
|
||||
label: "Kenny's iPhone Camera",
|
||||
toJSON() {
|
||||
return this;
|
||||
},
|
||||
} as MediaDeviceInfo,
|
||||
];
|
||||
const testWindow = window as Window & {
|
||||
__BUZZ_E2E_CAMERA_CONSTRAINTS__?: MediaStreamConstraints[];
|
||||
__BUZZ_E2E_CAMERA_REQUEST_COUNT__?: number;
|
||||
__BUZZ_E2E_RELEASE_CAMERA__?: () => void;
|
||||
};
|
||||
testWindow.__BUZZ_E2E_CAMERA_CONSTRAINTS__ = [];
|
||||
testWindow.__BUZZ_E2E_CAMERA_REQUEST_COUNT__ = 0;
|
||||
let releaseCamera: (() => void) | null = null;
|
||||
testWindow.__BUZZ_E2E_RELEASE_CAMERA__ = () => {
|
||||
releaseCamera?.();
|
||||
releaseCamera = null;
|
||||
};
|
||||
Object.defineProperty(mediaDevices, "enumerateDevices", {
|
||||
configurable: true,
|
||||
value: () => Promise.resolve(devices),
|
||||
});
|
||||
Object.defineProperty(mediaDevices, "addEventListener", {
|
||||
configurable: true,
|
||||
value: () => {},
|
||||
});
|
||||
Object.defineProperty(mediaDevices, "removeEventListener", {
|
||||
configurable: true,
|
||||
value: () => {},
|
||||
});
|
||||
Object.defineProperty(mediaDevices, "getUserMedia", {
|
||||
configurable: true,
|
||||
value: async (constraints: MediaStreamConstraints) => {
|
||||
testWindow.__BUZZ_E2E_CAMERA_CONSTRAINTS__?.push(constraints);
|
||||
testWindow.__BUZZ_E2E_CAMERA_REQUEST_COUNT__ =
|
||||
(testWindow.__BUZZ_E2E_CAMERA_REQUEST_COUNT__ ?? 0) + 1;
|
||||
if (
|
||||
testWindow.__BUZZ_E2E_CAMERA_REQUEST_COUNT__ <=
|
||||
cameraOptions.failRequests
|
||||
) {
|
||||
throw new DOMException("Camera access denied", "NotAllowedError");
|
||||
}
|
||||
if (cameraOptions.holdCamera) {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseCamera = resolve;
|
||||
});
|
||||
} else if (cameraOptions.cameraDelayMs > 0) {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, cameraOptions.cameraDelayMs),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(stream);
|
||||
},
|
||||
});
|
||||
},
|
||||
{
|
||||
cameraDelayMs: options.cameraDelayMs ?? 0,
|
||||
failRequests: options.failRequests ?? 0,
|
||||
holdCamera: options.holdCamera ?? false,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Single source of truth for E2E tests: derive preview-feature data from
|
||||
// /preview-features.json so we don't have to hand-maintain a parallel array.
|
||||
//
|
||||
// Production reads the same JSON via the `@features-manifest` vite alias
|
||||
// (see `desktop/src/shared/features/manifest.ts`). The localStorage key
|
||||
// format matches `OVERRIDES_KEY` in `desktop/src/shared/features/store.ts`
|
||||
// — bumping `version` in `preview-features.json` updates production AND
|
||||
// every spec automatically.
|
||||
import featuresManifest from "../../../preview-features.json" with {
|
||||
type: "json",
|
||||
};
|
||||
|
||||
interface FeatureDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
platforms?: string[];
|
||||
}
|
||||
|
||||
interface FeaturesManifest {
|
||||
version: number;
|
||||
features: FeatureDefinition[];
|
||||
}
|
||||
|
||||
const manifest = featuresManifest as FeaturesManifest;
|
||||
|
||||
/** IDs of every preview feature on desktop. */
|
||||
export const PREVIEW_FEATURE_IDS: string[] = manifest.features
|
||||
.filter((f) => !f.platforms || f.platforms.includes("desktop"))
|
||||
.map((f) => f.id);
|
||||
|
||||
/**
|
||||
* The localStorage key the production store uses for feature overrides.
|
||||
* Mirrors `OVERRIDES_KEY` in `src/shared/features/store.ts` so a manifest
|
||||
* version bump flows through to E2E seeding without manual updates.
|
||||
*/
|
||||
export const FEATURE_OVERRIDES_STORAGE_KEY = `buzz-feature-overrides-v${manifest.version}`;
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Simulate a native file drag entering the window. The backup test flow
|
||||
* listens for window-level dragenter with a "Files" payload and swaps in a
|
||||
* composer-style drop overlay over its host surface.
|
||||
*/
|
||||
export async function startWindowFileDrag(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(
|
||||
new File(["x"], "identity.ncryptsec", { type: "text/plain" }),
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new DragEvent("dragenter", { dataTransfer, bubbles: true }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Simulate the file drag leaving the window without dropping. */
|
||||
export async function endWindowFileDrag(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new DragEvent("dragend", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop a text file with the given contents onto the element with `testId`. */
|
||||
export async function dropFileOnTestId(
|
||||
page: Page,
|
||||
testId: string,
|
||||
contents: string,
|
||||
name = "identity.ncryptsec",
|
||||
): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ testId, contents, name }) => {
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(
|
||||
new File([contents], name, { type: "text/plain" }),
|
||||
);
|
||||
const target = document.querySelector(`[data-testid="${testId}"]`);
|
||||
if (!target) throw new Error(`drop target ${testId} not found`);
|
||||
target.dispatchEvent(
|
||||
new DragEvent("drop", {
|
||||
dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ testId, contents, name },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
export const E2E_IDENTITY_OVERRIDE_STORAGE_KEY =
|
||||
"buzz:e2e-identity-override.v1";
|
||||
|
||||
export async function seedActiveIdentity(
|
||||
page: Page,
|
||||
identity: { privateKey: string; pubkey: string; username: string },
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ identity: nextIdentity, storageKey }) => {
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(nextIdentity));
|
||||
},
|
||||
{ identity, storageKey: E2E_IDENTITY_OVERRIDE_STORAGE_KEY },
|
||||
);
|
||||
}
|
||||
|
||||
/** Continue past the created-key page without opening optional backup options. */
|
||||
export async function passThroughBackupStep(page: Page) {
|
||||
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
|
||||
await page.getByTestId("onboarding-next").click();
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
#!/usr/bin/env node
|
||||
//
|
||||
// Standalone Playwright screenshot helper for the Buzz desktop app.
|
||||
//
|
||||
// Launches headless Chromium with the E2E mock bridge pre-injected (same
|
||||
// setup as installMockBridge in bridge.ts), navigates to a route, optionally
|
||||
// clicks an element, and saves a screenshot.
|
||||
//
|
||||
// Usage:
|
||||
// node tests/helpers/screenshot.mjs [options]
|
||||
//
|
||||
// Options:
|
||||
// --name <name> Screenshot filename without extension (default: screenshot)
|
||||
// --route <path> Client-side route to navigate to (default: /)
|
||||
// --active-channel <name> Channel to navigate to and view (channel-aware navigation)
|
||||
// --click <selector> CSS selector or data-testid to click before capture
|
||||
// --right-click <selector> Right-click a selector (for context menus)
|
||||
// --hover <selector> Hover over a selector before capture
|
||||
// --clip <x,y,w,h> Crop to a region (e.g. 0,0,256,720 for sidebar)
|
||||
// --wait <ms> Milliseconds to wait before capture (default: 2000)
|
||||
// --viewport <WxH> Viewport dimensions (default: 1280x720)
|
||||
// --outdir <path> Output directory (default: test-results/screenshots)
|
||||
// --messages <path> JSON file with messages to inject before capture
|
||||
// --update-ready Mock an available update so the sidebar update card renders
|
||||
|
||||
import { parseArgs } from "node:util";
|
||||
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
||||
import { resolve, join } from "node:path";
|
||||
import { chromium } from "@playwright/test";
|
||||
|
||||
const { values: args } = parseArgs({
|
||||
options: {
|
||||
name: { type: "string", default: "screenshot" },
|
||||
route: { type: "string", default: "/" },
|
||||
"active-channel": { type: "string" },
|
||||
click: { type: "string" },
|
||||
"right-click": { type: "string" },
|
||||
hover: { type: "string" },
|
||||
clip: { type: "string" },
|
||||
wait: { type: "string", default: "2000" },
|
||||
viewport: { type: "string", default: "1280x720" },
|
||||
outdir: { type: "string", default: "test-results/screenshots" },
|
||||
messages: { type: "string" },
|
||||
"local-storage": { type: "string", multiple: true, default: [] },
|
||||
"update-ready": { type: "boolean", default: false },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
|
||||
// `--local-storage key=value` (repeatable) seeds a localStorage entry before the
|
||||
// app mounts, so device-level preferences (thread layout, transcript options, …)
|
||||
// can be captured in their non-default state.
|
||||
const localStorageSeeds = args["local-storage"].map((entry) => {
|
||||
const separatorIndex = entry.indexOf("=");
|
||||
if (separatorIndex < 1) {
|
||||
throw new Error(`--local-storage expects key=value, received "${entry}"`);
|
||||
}
|
||||
return [entry.slice(0, separatorIndex), entry.slice(separatorIndex + 1)];
|
||||
});
|
||||
|
||||
const activeChannel = args["active-channel"];
|
||||
const rightClick = args["right-click"];
|
||||
|
||||
const [vpWidth, vpHeight] = args.viewport.split("x").map(Number);
|
||||
const waitMs = Number(args.wait);
|
||||
const outdir = resolve(args.outdir);
|
||||
|
||||
if (!existsSync(outdir)) {
|
||||
mkdirSync(outdir, { recursive: true });
|
||||
}
|
||||
|
||||
function resolveSelector(value) {
|
||||
return value.startsWith("[") ? value : `[data-testid="${value}"]`;
|
||||
}
|
||||
|
||||
function bail(msg) {
|
||||
console.error(msg);
|
||||
process.exitCode = 1;
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
const BASE_URL = "http://127.0.0.1:4173";
|
||||
const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8);
|
||||
const ONBOARDING_PREFIX = "buzz-onboarding-complete.v1:";
|
||||
|
||||
const TEST_PUBKEYS = [
|
||||
DEFAULT_MOCK_PUBKEY,
|
||||
"e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34",
|
||||
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f",
|
||||
"bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260",
|
||||
"554cef57437abac34522ac2c9f0490d685b72c80478cf9f7ed6f9570ee8624ea",
|
||||
"df8e91b86fda13a9a67896df77232f7bdab2ba9c3e165378e1ba3d24c13a328e",
|
||||
];
|
||||
|
||||
// `BUZZ_HEADED=1` runs a real browser window instead of headless.
|
||||
//
|
||||
// Headless Chromium rasterizes in software (SwiftShader), which mis-renders
|
||||
// `backdrop-filter` when an ancestor establishes a rounded clip
|
||||
// (`overflow-hidden` + `border-radius`) — the blurred surface paints as solid
|
||||
// cyan garbage. The app's blurred chrome (panel headers, composers) hits this
|
||||
// inside the rounded focus drawer. Headed rendering is correct, as is the real
|
||||
// app's WKWebView, so this is a capture-only artifact. Default stays headless so
|
||||
// CI is unaffected.
|
||||
const browser = await chromium.launch({ headless: !process.env.BUZZ_HEADED });
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: vpWidth, height: vpHeight },
|
||||
});
|
||||
|
||||
// Seed default community (mirrors seedDefaultCommunity in bridge.ts)
|
||||
await page.addInitScript(() => {
|
||||
const communityId = "e2e-default-community";
|
||||
const community = {
|
||||
id: communityId,
|
||||
name: "E2E Test",
|
||||
relayUrl: "ws://localhost:3000",
|
||||
addedAt: new Date().toISOString(),
|
||||
};
|
||||
window.localStorage.setItem("buzz-communities", JSON.stringify([community]));
|
||||
window.localStorage.setItem("buzz-active-community-id", communityId);
|
||||
});
|
||||
|
||||
// Seed onboarding completion for all known identities
|
||||
await page.addInitScript(
|
||||
({ prefix, pubkeys }) => {
|
||||
for (const pk of pubkeys) {
|
||||
window.localStorage.setItem(`${prefix}${pk}`, "true");
|
||||
}
|
||||
},
|
||||
{ prefix: ONBOARDING_PREFIX, pubkeys: TEST_PUBKEYS },
|
||||
);
|
||||
|
||||
// Seed caller-supplied preferences. Must run before the bridge init script:
|
||||
// React reads persisted state on mount, and the bridge triggers mount.
|
||||
if (localStorageSeeds.length > 0) {
|
||||
await page.addInitScript((seeds) => {
|
||||
for (const [key, value] of seeds) {
|
||||
window.localStorage.setItem(key, value);
|
||||
}
|
||||
}, localStorageSeeds);
|
||||
}
|
||||
|
||||
// Install E2E mock bridge config + MockNotification (mirrors installBridge in bridge.ts)
|
||||
await page.addInitScript(
|
||||
({ updateReady }) => {
|
||||
class MockNotification extends EventTarget {
|
||||
static permission = "granted";
|
||||
static async requestPermission() {
|
||||
return "granted";
|
||||
}
|
||||
body;
|
||||
onclick = null;
|
||||
title;
|
||||
constructor(title, options) {
|
||||
super();
|
||||
this.title = title;
|
||||
this.body = options?.body ?? null;
|
||||
}
|
||||
close() {}
|
||||
}
|
||||
Object.defineProperty(window, "Notification", {
|
||||
configurable: true,
|
||||
value: MockNotification,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
window.__BUZZ_E2E__ = {
|
||||
mode: "mock",
|
||||
...(updateReady ? { mock: { updateAvailable: true } } : {}),
|
||||
};
|
||||
window.__BUZZ_E2E_APP_BADGE_COUNT__ = 0;
|
||||
},
|
||||
{ updateReady: args["update-ready"] },
|
||||
);
|
||||
|
||||
try {
|
||||
if (args.messages) {
|
||||
if (args.route !== "/" && !activeChannel) {
|
||||
console.warn("warning: --route is ignored when --messages is provided");
|
||||
}
|
||||
|
||||
let messages;
|
||||
try {
|
||||
messages = JSON.parse(readFileSync(resolve(args.messages), "utf8"));
|
||||
} catch (err) {
|
||||
bail(`Failed to read messages file: ${err.message}`);
|
||||
}
|
||||
|
||||
if (
|
||||
!Array.isArray(messages) ||
|
||||
messages.length === 0 ||
|
||||
messages.some(
|
||||
(m) =>
|
||||
typeof m.channelName !== "string" || typeof m.content !== "string",
|
||||
)
|
||||
) {
|
||||
bail(
|
||||
"messages file must be a non-empty array of { channelName, content, pubkey?, kind?, mentionPubkeys?, extraTags?, parentEventId? }",
|
||||
);
|
||||
}
|
||||
|
||||
const targetChannels = new Set(messages.map((m) => m.channelName));
|
||||
|
||||
if (!activeChannel && targetChannels.size > 1) {
|
||||
bail(
|
||||
"All messages must target the same channelName, or use --active-channel to specify the viewing channel",
|
||||
);
|
||||
}
|
||||
|
||||
const viewChannel = activeChannel ?? [...targetChannels][0];
|
||||
|
||||
for (const ch of [...targetChannels, viewChannel]) {
|
||||
if (!/^[a-z0-9-]+$/.test(ch)) {
|
||||
bail(`Invalid channel name: ${ch}`);
|
||||
}
|
||||
}
|
||||
|
||||
await page.goto(BASE_URL);
|
||||
await page.waitForSelector(`[data-testid="channel-${viewChannel}"]`, {
|
||||
timeout: 10000,
|
||||
});
|
||||
await page.click(`[data-testid="channel-${viewChannel}"]`);
|
||||
|
||||
for (const ch of targetChannels) {
|
||||
await page.waitForFunction(
|
||||
(name) =>
|
||||
window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
|
||||
channelName: name,
|
||||
}) ?? false,
|
||||
ch,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
await page.evaluate(
|
||||
(m) => {
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.(m);
|
||||
},
|
||||
{ ...msg, pubkey: msg.pubkey ?? DEFAULT_MOCK_PUBKEY },
|
||||
);
|
||||
}
|
||||
|
||||
await page.waitForTimeout(waitMs);
|
||||
} else if (activeChannel) {
|
||||
if (!/^[a-z0-9-]+$/.test(activeChannel)) {
|
||||
bail(`Invalid channel name: ${activeChannel}`);
|
||||
}
|
||||
|
||||
await page.goto(BASE_URL);
|
||||
await page.waitForSelector(`[data-testid="channel-${activeChannel}"]`, {
|
||||
timeout: 10000,
|
||||
});
|
||||
await page.click(`[data-testid="channel-${activeChannel}"]`);
|
||||
await page.waitForTimeout(waitMs);
|
||||
} else {
|
||||
const url = args.route === "/" ? BASE_URL : `${BASE_URL}/#${args.route}`;
|
||||
await page.goto(url);
|
||||
await page.waitForTimeout(waitMs);
|
||||
}
|
||||
|
||||
if (args.hover) {
|
||||
await page.hover(resolveSelector(args.hover));
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
if (args.click) {
|
||||
await page.click(resolveSelector(args.click));
|
||||
await page.waitForTimeout(500);
|
||||
} else if (rightClick) {
|
||||
await page.click(resolveSelector(rightClick), { button: "right" });
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// Wait for all CSS/Web animations to finish before capturing.
|
||||
// Radix components animate in via CSS — without this, screenshots
|
||||
// are taken mid-transition and appear greyed-out or partially rendered.
|
||||
await page.evaluate(() =>
|
||||
Promise.all(document.getAnimations().map((a) => a.finished)),
|
||||
);
|
||||
|
||||
const filepath = join(outdir, `${args.name}.png`);
|
||||
const clipOpts = args.clip
|
||||
? (() => {
|
||||
const [x, y, w, h] = args.clip.split(",").map(Number);
|
||||
return { clip: { x, y, width: w, height: h } };
|
||||
})()
|
||||
: {};
|
||||
await page.screenshot({ path: filepath, ...clipOpts });
|
||||
console.log(filepath);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { request } from "@playwright/test";
|
||||
|
||||
const tylerPubkey =
|
||||
"e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34";
|
||||
const isCi = Boolean(process.env.CI);
|
||||
// Multi-tenant: the relay resolves each request's tenant from its Host header
|
||||
// against the communities host map and fails closed (404) on an unmapped host.
|
||||
// The seed maps host 'localhost:3000' (matching the relay's RELAY_URL) — and
|
||||
// `localhost` != `127.0.0.1` to normalize_host — so this readiness check must
|
||||
// hit `localhost:3000`, not `127.0.0.1:3000`, or every /query 404s. This also
|
||||
// matches the rest of the desktop e2e suite (e2eBridge.ts / bridge.ts), which
|
||||
// already default to localhost.
|
||||
const relayBaseUrl = process.env.BUZZ_E2E_RELAY_URL ?? "http://localhost:3000";
|
||||
const seedTimeoutMs = Number.parseInt(
|
||||
process.env.BUZZ_E2E_SEED_TIMEOUT_MS ?? (isCi ? "60000" : "25000"),
|
||||
10,
|
||||
);
|
||||
const requestTimeoutMs = Number.parseInt(
|
||||
process.env.BUZZ_E2E_SEED_REQUEST_TIMEOUT_MS ?? (isCi ? "5000" : "2000"),
|
||||
10,
|
||||
);
|
||||
const retryDelayMs = Number.parseInt(
|
||||
process.env.BUZZ_E2E_SEED_RETRY_DELAY_MS ?? "1000",
|
||||
10,
|
||||
);
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
export async function assertRelaySeeded() {
|
||||
const context = await request.newContext();
|
||||
const deadline = Date.now() + seedTimeoutMs;
|
||||
let lastFailure = "no checks attempted";
|
||||
|
||||
try {
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
// The setup script inserts test data directly into the DB tables.
|
||||
// The relay reconciles these at startup by emitting kind:39000/39002
|
||||
// events. Query kind:39000 (channel metadata) via the HTTP bridge
|
||||
// and check for the expected "general" channel.
|
||||
const response = await context.post(`${relayBaseUrl}/query`, {
|
||||
headers: {
|
||||
"X-Pubkey": tylerPubkey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
data: [{ kinds: [39000], limit: 200 }],
|
||||
timeout: requestTimeoutMs,
|
||||
});
|
||||
|
||||
if (!response.ok()) {
|
||||
lastFailure = `HTTP ${response.status()} from POST /query`;
|
||||
} else {
|
||||
const events = (await response.json()) as Array<{
|
||||
tags: string[][];
|
||||
}>;
|
||||
const hasGeneral = events.some((event) =>
|
||||
event.tags.some((tag) => tag[0] === "name" && tag[1] === "general"),
|
||||
);
|
||||
if (hasGeneral) {
|
||||
return;
|
||||
}
|
||||
lastFailure = `seed data: got ${events.length} channels but no "general" — relay may still be reconciling`;
|
||||
}
|
||||
} catch (error) {
|
||||
lastFailure =
|
||||
error instanceof Error ? error.message : "unknown relay check error";
|
||||
}
|
||||
|
||||
await delay(retryDelayMs);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Relay test data was not ready after ${seedTimeoutMs}ms. Last check: ${lastFailure}. Start the relay and run scripts/setup-desktop-test-data.sh.`,
|
||||
);
|
||||
} finally {
|
||||
await context.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,702 @@
|
||||
import { hexToBytes } from "@noble/hashes/utils.js";
|
||||
import { finalizeEvent, getPublicKey } from "nostr-tools/pure";
|
||||
import type { Event as NostrEvent, EventTemplate } from "nostr-tools/pure";
|
||||
|
||||
import { TEST_IDENTITIES } from "./bridge";
|
||||
|
||||
// =============================================================================
|
||||
// Hard-dataset relay seeder — GUI read-model overhaul (Dawn's lane)
|
||||
// =============================================================================
|
||||
//
|
||||
// Publishes REAL signed Nostr events through the relay ingest path
|
||||
// (`POST /events`), never raw SQL. This is the load-bearing fidelity choice:
|
||||
// `thread_metadata` (depth, root, reply counts) is computed AT INGEST
|
||||
// (buzz-relay/src/handlers/ingest.rs). Eva's channel-window surface reads that
|
||||
// metadata; a raw-SQL bulk load would bypass computation and hand the window
|
||||
// surface empty/wrong summaries — a false green. See
|
||||
// PLANS/GUI_OVERHAUL_TEST_HARNESS_DAWN.md.
|
||||
//
|
||||
// Transport auth: the test relay runs BUZZ_REQUIRE_AUTH_TOKEN=false
|
||||
// (start-relay-for-tests.sh), so `POST /events` accepts a plain `X-Pubkey`
|
||||
// header (bridge.rs verify_bridge_auth dev fallback). The EVENT is still fully
|
||||
// signed — only the per-request NIP-98 auth envelope is skipped. `POST /events`
|
||||
// ingests ONE event per request, so bulk seeding parallelizes with a bounded
|
||||
// concurrency pool. Authors must be seeded channel members
|
||||
// (setup-desktop-test-data.sh seeds tyler/alice/bob/charlie into `general`),
|
||||
// which `enforce_relay_membership` requires.
|
||||
//
|
||||
// Canonical tag shapes (crates/buzz-sdk/src/builders.rs thread_tags + ingest):
|
||||
// top-level : ["h", channelId] — no e-tag → depth NULL
|
||||
// direct : ["e", parentId, "", "reply"] — reply alone; root=parent
|
||||
// nested : ["e", rootId, "", "root"],
|
||||
// ["e", parentId, "", "reply"] — depth N
|
||||
// reaction : kind 7, ["e", targetId], ["h", channelId], content = emoji
|
||||
// deletion : kind 5, ["e", targetId]
|
||||
// A reply carrying ONLY ["e", id, "", "root"] (no "reply" marker) is stored
|
||||
// WITHOUT thread_metadata (ingest.rs returns None) — the "legacy/spurious row"
|
||||
// shape used to probe contract-v1.1 item 7 (`depth IS NULL` = top-level).
|
||||
|
||||
const KIND_MESSAGE = 9;
|
||||
const KIND_REACTION = 7;
|
||||
const KIND_DELETION = 5;
|
||||
|
||||
const DEFAULT_RELAY_HTTP =
|
||||
process.env.BUZZ_E2E_RELAY_URL ?? "http://localhost:3000";
|
||||
|
||||
type IdentityName = keyof typeof TEST_IDENTITIES;
|
||||
|
||||
export type SeededEvent = {
|
||||
id: string;
|
||||
kind: number;
|
||||
pubkey: string;
|
||||
created_at: number;
|
||||
content: string;
|
||||
tags: string[][];
|
||||
};
|
||||
|
||||
/** A signer bound to one seeded identity. */
|
||||
class Signer {
|
||||
readonly pubkey: string;
|
||||
private readonly sk: Uint8Array;
|
||||
|
||||
constructor(privateKeyHex: string) {
|
||||
this.sk = hexToBytes(privateKeyHex);
|
||||
this.pubkey = getPublicKey(this.sk);
|
||||
}
|
||||
|
||||
sign(template: EventTemplate): NostrEvent {
|
||||
return finalizeEvent(template, this.sk);
|
||||
}
|
||||
}
|
||||
|
||||
const signerCache = new Map<string, Signer>();
|
||||
|
||||
function signerFor(name: IdentityName): Signer {
|
||||
const cached = signerCache.get(name);
|
||||
if (cached) return cached;
|
||||
const signer = new Signer(TEST_IDENTITIES[name].privateKey);
|
||||
signerCache.set(name, signer);
|
||||
return signer;
|
||||
}
|
||||
|
||||
export type SeedOptions = {
|
||||
relayHttpUrl?: string;
|
||||
/** Max in-flight POST /events requests. */
|
||||
concurrency?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* POST one signed event through the relay ingest path. Throws on non-2xx so a
|
||||
* broken seed fails loudly rather than producing a silently-partial dataset.
|
||||
*/
|
||||
async function publishEvent(
|
||||
event: NostrEvent,
|
||||
relayHttpUrl: string,
|
||||
): Promise<void> {
|
||||
const response = await fetch(`${relayHttpUrl}/events`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
// Dev-mode transport auth; the event body is fully signed regardless.
|
||||
"X-Pubkey": event.pubkey,
|
||||
},
|
||||
body: JSON.stringify(event),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => "<no body>");
|
||||
throw new Error(
|
||||
`POST /events failed (${response.status}) for kind ${event.kind} ${event.id.slice(0, 8)}: ${detail}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a batch with bounded concurrency, preserving ORDER GUARANTEES the
|
||||
* caller encodes as `barrier` boundaries: events within one barrier group may
|
||||
* publish concurrently, but a group only starts once every earlier group has
|
||||
* fully landed. Thread replies MUST NOT race their parents — ingest hard-errors
|
||||
* on an unknown parent (ingest.rs "reply parent not found") — so parents go in
|
||||
* an earlier group than their children.
|
||||
*/
|
||||
async function publishGroups(
|
||||
groups: NostrEvent[][],
|
||||
relayHttpUrl: string,
|
||||
concurrency: number,
|
||||
): Promise<void> {
|
||||
for (const group of groups) {
|
||||
let cursor = 0;
|
||||
const workers: Promise<void>[] = [];
|
||||
const worker = async () => {
|
||||
while (cursor < group.length) {
|
||||
const event = group[cursor];
|
||||
cursor += 1;
|
||||
await publishEvent(event, relayHttpUrl);
|
||||
}
|
||||
};
|
||||
for (let i = 0; i < Math.min(concurrency, group.length); i += 1) {
|
||||
workers.push(worker());
|
||||
}
|
||||
await Promise.all(workers);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event builders (canonical tag shapes) ────────────────────────────────────
|
||||
|
||||
function buildMessage(
|
||||
signer: Signer,
|
||||
channelId: string,
|
||||
content: string,
|
||||
createdAt: number,
|
||||
extraTags: string[][] = [],
|
||||
): NostrEvent {
|
||||
return signer.sign({
|
||||
kind: KIND_MESSAGE,
|
||||
content,
|
||||
created_at: createdAt,
|
||||
tags: [["h", channelId], ...extraTags],
|
||||
});
|
||||
}
|
||||
|
||||
function directReplyTags(parentId: string): string[][] {
|
||||
return [["e", parentId, "", "reply"]];
|
||||
}
|
||||
|
||||
function nestedReplyTags(rootId: string, parentId: string): string[][] {
|
||||
return [
|
||||
["e", rootId, "", "root"],
|
||||
["e", parentId, "", "reply"],
|
||||
];
|
||||
}
|
||||
|
||||
/** The malformed "root-only" reply shape — no `reply` marker → no metadata. */
|
||||
function legacyRootOnlyTags(rootId: string): string[][] {
|
||||
return [["e", rootId, "", "root"]];
|
||||
}
|
||||
|
||||
function buildReaction(
|
||||
signer: Signer,
|
||||
channelId: string,
|
||||
targetId: string,
|
||||
emoji: string,
|
||||
createdAt: number,
|
||||
): NostrEvent {
|
||||
return signer.sign({
|
||||
kind: KIND_REACTION,
|
||||
content: emoji,
|
||||
created_at: createdAt,
|
||||
tags: [
|
||||
["e", targetId],
|
||||
["h", channelId],
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function buildDeletion(
|
||||
signer: Signer,
|
||||
targetId: string,
|
||||
createdAt: number,
|
||||
): NostrEvent {
|
||||
return signer.sign({
|
||||
kind: KIND_DELETION,
|
||||
content: "",
|
||||
created_at: createdAt,
|
||||
tags: [["e", targetId]],
|
||||
});
|
||||
}
|
||||
|
||||
const toRow = (e: NostrEvent): SeededEvent => ({
|
||||
id: e.id,
|
||||
kind: e.kind,
|
||||
pubkey: e.pubkey,
|
||||
created_at: e.created_at,
|
||||
content: e.content,
|
||||
tags: e.tags,
|
||||
});
|
||||
|
||||
const AUTHORS: IdentityName[] = ["tyler", "alice", "bob", "charlie"];
|
||||
|
||||
// ── Scenario builders ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Each scenario returns { groups, expected }:
|
||||
// - groups: ordered barrier groups for publishGroups (parents before children)
|
||||
// - expected: the ground-truth SeededEvent[] the correctness suite asserts the
|
||||
// GUI must render (or, for aux/deleted, reason about) — the "every event the
|
||||
// relay returns must render" contract.
|
||||
|
||||
export type Scenario = {
|
||||
name: string;
|
||||
groups: NostrEvent[][];
|
||||
expected: SeededEvent[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Dense same-second wall: `count` top-level messages all at ONE created_at.
|
||||
* The exact keyset hazard — a bare `until` cursor can never advance past a
|
||||
* single second holding more rows than one page. All independent → one group.
|
||||
*/
|
||||
export function denseSecondWall(opts: {
|
||||
channelId: string;
|
||||
second: number;
|
||||
count: number;
|
||||
}): Scenario {
|
||||
const { channelId, second, count } = opts;
|
||||
const events: NostrEvent[] = [];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const signer = signerFor(AUTHORS[i % AUTHORS.length]);
|
||||
events.push(buildMessage(signer, channelId, `dense ${i}`, second));
|
||||
}
|
||||
return {
|
||||
name: "dense-second-wall",
|
||||
groups: [events],
|
||||
expected: events.map(toRow),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A single root with a `depth`-deep linear reply chain. Each level references
|
||||
* the previous as parent → must publish level-by-level (barrier per level), or
|
||||
* ingest rejects the child before its parent lands.
|
||||
*/
|
||||
export function deepThread(opts: {
|
||||
channelId: string;
|
||||
depth: number;
|
||||
startAt: number;
|
||||
}): Scenario {
|
||||
const { channelId, depth, startAt } = opts;
|
||||
const root = buildMessage(
|
||||
signerFor("tyler"),
|
||||
channelId,
|
||||
"thread root",
|
||||
startAt,
|
||||
[],
|
||||
);
|
||||
const groups: NostrEvent[][] = [[root]];
|
||||
const expected: SeededEvent[] = [toRow(root)];
|
||||
let parentId = root.id;
|
||||
const rootId = root.id;
|
||||
for (let level = 1; level <= depth; level += 1) {
|
||||
const signer = signerFor(AUTHORS[level % AUTHORS.length]);
|
||||
const tags =
|
||||
level === 1 ? directReplyTags(rootId) : nestedReplyTags(rootId, parentId);
|
||||
const reply = buildMessage(
|
||||
signer,
|
||||
channelId,
|
||||
`reply depth ${level}`,
|
||||
startAt + level,
|
||||
tags,
|
||||
);
|
||||
groups.push([reply]);
|
||||
expected.push(toRow(reply));
|
||||
parentId = reply.id;
|
||||
}
|
||||
return { name: "deep-thread", groups, expected };
|
||||
}
|
||||
|
||||
/**
|
||||
* Backdated events: `created_at` older than publish order — the author-clock
|
||||
* hazard that broke the created_at-anchored pager. Independent tops → one group.
|
||||
*/
|
||||
export function backdated(opts: {
|
||||
channelId: string;
|
||||
now: number;
|
||||
count: number;
|
||||
}): Scenario {
|
||||
const { channelId, now, count } = opts;
|
||||
const events: NostrEvent[] = [];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
// Publish newest-first but stamp them progressively OLDER: the wire arrival
|
||||
// order and the created_at order deliberately disagree.
|
||||
const createdAt = now - (i + 1) * 37;
|
||||
const signer = signerFor(AUTHORS[i % AUTHORS.length]);
|
||||
events.push(
|
||||
buildMessage(
|
||||
signer,
|
||||
channelId,
|
||||
`backdated ${i} @${createdAt}`,
|
||||
createdAt,
|
||||
),
|
||||
);
|
||||
}
|
||||
return { name: "backdated", groups: [events], expected: events.map(toRow) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Aux transitive closure (contract v1.1 item 1): a top-level row, a reaction on
|
||||
* it (aux), an edit-style follow, a deletion of the ROW, and — the two-hop case
|
||||
* — a DELETION OF THE REACTION. The window surface must return the reaction's
|
||||
* deletion even though it targets an aux id, not a row.
|
||||
*/
|
||||
export function auxClosure(opts: {
|
||||
channelId: string;
|
||||
startAt: number;
|
||||
}): Scenario {
|
||||
const { channelId, startAt } = opts;
|
||||
const row = buildMessage(
|
||||
signerFor("tyler"),
|
||||
channelId,
|
||||
"aux target row",
|
||||
startAt,
|
||||
);
|
||||
const reaction = buildReaction(
|
||||
signerFor("alice"),
|
||||
channelId,
|
||||
row.id,
|
||||
"🔥",
|
||||
startAt + 1,
|
||||
);
|
||||
const groups: NostrEvent[][] = [[row]];
|
||||
const expected: SeededEvent[] = [toRow(row)];
|
||||
groups.push([reaction]);
|
||||
expected.push(toRow(reaction));
|
||||
// Two-hop: delete the reaction (targets an aux id, not the row).
|
||||
const reactionDeletion = buildDeletion(
|
||||
signerFor("alice"),
|
||||
reaction.id,
|
||||
startAt + 2,
|
||||
);
|
||||
groups.push([reactionDeletion]);
|
||||
expected.push(toRow(reactionDeletion));
|
||||
return { name: "aux-closure", groups, expected };
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy/spurious-row probe (contract v1.1 item 7): a reply carrying only a
|
||||
* `root` marker (no `reply`) → stored WITHOUT thread_metadata. The correctness
|
||||
* suite asserts whether it wrongly surfaces as a top-level row, which is the
|
||||
* signal for whether a backfill migration is required.
|
||||
*/
|
||||
export function legacyReply(opts: {
|
||||
channelId: string;
|
||||
startAt: number;
|
||||
}): Scenario {
|
||||
const { channelId, startAt } = opts;
|
||||
const root = buildMessage(
|
||||
signerFor("tyler"),
|
||||
channelId,
|
||||
"legacy root",
|
||||
startAt,
|
||||
);
|
||||
const spurious = buildMessage(
|
||||
signerFor("bob"),
|
||||
channelId,
|
||||
"legacy reply (root-only marker)",
|
||||
startAt + 1,
|
||||
legacyRootOnlyTags(root.id),
|
||||
);
|
||||
return {
|
||||
name: "legacy-reply",
|
||||
groups: [[root], [spurious]],
|
||||
expected: [toRow(root), toRow(spurious)],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk mixed channel (3k+ events): a long span of top-level messages, a fraction
|
||||
* carrying short reply chains, reactions and a few deletions sprinkled across
|
||||
* rendered rows so `settled` (aux backfill committed — Sami's metric) reflects a
|
||||
* realistic fan-out cost, not a bare timeline. Groups: all roots + tops first
|
||||
* (independent), then replies (parents exist), then aux (targets exist).
|
||||
*/
|
||||
export function bulkChannel(opts: {
|
||||
channelId: string;
|
||||
topLevelCount: number;
|
||||
startAt: number;
|
||||
/** Fraction of top-level rows that get a 2-3 reply chain. */
|
||||
threadedFraction?: number;
|
||||
/** Fraction of rows that get a reaction. */
|
||||
reactionFraction?: number;
|
||||
}): Scenario {
|
||||
const {
|
||||
channelId,
|
||||
topLevelCount,
|
||||
startAt,
|
||||
threadedFraction = 0.25,
|
||||
reactionFraction = 0.4,
|
||||
} = opts;
|
||||
|
||||
const tops: NostrEvent[] = [];
|
||||
const replies: NostrEvent[] = [];
|
||||
const aux: NostrEvent[] = [];
|
||||
const expected: SeededEvent[] = [];
|
||||
|
||||
for (let i = 0; i < topLevelCount; i += 1) {
|
||||
const at = startAt + i * 5; // spread across time, some collisions below
|
||||
const signer = signerFor(AUTHORS[i % AUTHORS.length]);
|
||||
// Every ~50th row shares a second with its neighbour — light dense pockets.
|
||||
const createdAt = i % 50 === 0 && i > 0 ? at - 5 : at;
|
||||
const top = buildMessage(signer, channelId, `msg ${i}`, createdAt);
|
||||
tops.push(top);
|
||||
expected.push(toRow(top));
|
||||
|
||||
if (i % Math.max(1, Math.round(1 / threadedFraction)) === 0) {
|
||||
const chainLen = 2 + (i % 2); // 2 or 3 deep
|
||||
let parentId = top.id;
|
||||
const rootId = top.id;
|
||||
for (let level = 1; level <= chainLen; level += 1) {
|
||||
const rSigner = signerFor(AUTHORS[(i + level) % AUTHORS.length]);
|
||||
const tags =
|
||||
level === 1
|
||||
? directReplyTags(rootId)
|
||||
: nestedReplyTags(rootId, parentId);
|
||||
const reply = buildMessage(
|
||||
rSigner,
|
||||
channelId,
|
||||
`msg ${i} reply ${level}`,
|
||||
createdAt + level,
|
||||
tags,
|
||||
);
|
||||
replies.push(reply);
|
||||
expected.push(toRow(reply));
|
||||
parentId = reply.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (i % Math.max(1, Math.round(1 / reactionFraction)) === 0) {
|
||||
const rSigner = signerFor(AUTHORS[(i + 1) % AUTHORS.length]);
|
||||
const reaction = buildReaction(
|
||||
rSigner,
|
||||
channelId,
|
||||
top.id,
|
||||
i % 3 === 0 ? "👍" : "🎉",
|
||||
createdAt + 1,
|
||||
);
|
||||
aux.push(reaction);
|
||||
expected.push(toRow(reaction));
|
||||
}
|
||||
}
|
||||
|
||||
// Barrier ordering: tops must land before any reply; a depth-2 reply's parent
|
||||
// is a depth-1 reply, so replies split by depth into successive groups. A
|
||||
// depth-1 reply carries only a `reply` marker; deeper replies also carry a
|
||||
// `root` marker (nestedReplyTags). aux targets tops, so it goes last.
|
||||
const hasRootMarker = (e: NostrEvent) =>
|
||||
e.tags.some((t) => t[0] === "e" && t[3] === "root");
|
||||
const depth1 = replies.filter((r) => !hasRootMarker(r));
|
||||
const deeper = replies.filter((r) => hasRootMarker(r));
|
||||
|
||||
const groups: NostrEvent[][] = [tops];
|
||||
if (depth1.length) groups.push(depth1);
|
||||
if (deeper.length) groups.push(deeper);
|
||||
if (aux.length) groups.push(aux);
|
||||
|
||||
return { name: "bulk-channel", groups, expected };
|
||||
}
|
||||
|
||||
/**
|
||||
* Dense same-second wall buried behind `fillerCount` NEWER top-level events, so
|
||||
* the pre-overhaul client's bulk `since:0 limit:1000` head fetch cannot reach it
|
||||
* and MUST fall onto the bare-`until` history pager — where the dense second
|
||||
* strands its sub-page tail (the RED-on-main proof). Wren, 2026-07-03: the head
|
||||
* fetch front-loads the newest ~1000, so `fillerCount` must exceed that ceiling.
|
||||
*
|
||||
* Timestamps stay inside the relay's ±120s ingest window: filler is spread over
|
||||
* the seconds just before NOW, the wall sits one second below the oldest filler.
|
||||
* Filler and wall share ONE barrier group (all independent top-levels).
|
||||
*
|
||||
* `expected` is the WALL only — the contract under test is "every event behind
|
||||
* the front-load ceiling is still reachable". Filler is context, not asserted.
|
||||
*/
|
||||
export function denseWallBehindFiller(opts: {
|
||||
channelId: string;
|
||||
wallCount: number;
|
||||
fillerCount: number;
|
||||
/** Newest filler second; defaults to NOW. Wall sits `wallOffset`s below. */
|
||||
now?: number;
|
||||
/** Seconds the filler spans below `now`. Default 100 (well inside ±120s). */
|
||||
fillerSpanSeconds?: number;
|
||||
}): Scenario {
|
||||
const {
|
||||
channelId,
|
||||
wallCount,
|
||||
fillerCount,
|
||||
now = Math.floor(Date.now() / 1000),
|
||||
fillerSpanSeconds = 100,
|
||||
} = opts;
|
||||
|
||||
const events: NostrEvent[] = [];
|
||||
|
||||
// Filler: `fillerCount` rows spread across [now - fillerSpanSeconds, now].
|
||||
// Oldest filler second is `now - fillerSpanSeconds`; the wall sits below it.
|
||||
const oldestFillerSecond = now - fillerSpanSeconds;
|
||||
for (let i = 0; i < fillerCount; i += 1) {
|
||||
const signer = signerFor(AUTHORS[i % AUTHORS.length]);
|
||||
// Deterministic spread: newest first index → newest second, wrapping the
|
||||
// span. Exact distribution is irrelevant; only "all newer than wall" matters.
|
||||
const second = now - (i % (fillerSpanSeconds + 1));
|
||||
events.push(buildMessage(signer, channelId, `filler ${i}`, second));
|
||||
}
|
||||
|
||||
// Wall: `wallCount` rows all at one second strictly below the oldest filler.
|
||||
const wallSecond = oldestFillerSecond - 1;
|
||||
const wall: NostrEvent[] = [];
|
||||
for (let i = 0; i < wallCount; i += 1) {
|
||||
const signer = signerFor(AUTHORS[i % AUTHORS.length]);
|
||||
const event = buildMessage(signer, channelId, `wall ${i}`, wallSecond);
|
||||
events.push(event);
|
||||
wall.push(event);
|
||||
}
|
||||
|
||||
return {
|
||||
name: "dense-wall-behind-filler",
|
||||
groups: [events],
|
||||
expected: wall.map(toRow),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ancestor-island cursor poisoning (Dawn's Jul-2 root cause + Wren's Jul-3
|
||||
* proven repro `239cc161`): the disease the read-model overhaul deletes.
|
||||
*
|
||||
* On main, the cold channel load paints the newest `CHANNEL_HISTORY_LIMIT` (60)
|
||||
* top-level rows. If one of those rows is a reply whose root/parent is OUTSIDE
|
||||
* that window, `useLoadMissingAncestors` fetches that old root by id and merges
|
||||
* it into the SAME channel cache — a non-contiguous "island". The older-history
|
||||
* pager then anchors `oldestTimestamp = baseline[0].created_at` on the island
|
||||
* (the injected old root), so every scroll-up pages backward FROM the island and
|
||||
* permanently skips the real history between the island and the true frontier.
|
||||
* CLI `/query` returns those `gap-*` rows; the GUI never requests them → RED.
|
||||
*
|
||||
* Shape (all inside the ±120s ingest window — the boundary here is ROW COUNT
|
||||
* (60), not time, so timestamps stay tight around NOW):
|
||||
* - 1 old thread root at `now - oldRootOffset` (default 115s)
|
||||
* - `gapCount` `gap-*` top-levels between the old root and the newest window
|
||||
* - `newestCount` (> 60) `new-*` top-levels at the newest seconds, exactly ONE
|
||||
* of which is a reply whose root+parent point at the old root → the trigger
|
||||
*
|
||||
* Barrier ordering: the old root must land before the reply that references it
|
||||
* (ingest rejects an unknown parent), so the reply is its own later group.
|
||||
*
|
||||
* `expected` is the `gap-*` set — the contract is "every gap row CLI returns
|
||||
* must render". RED on main (0 reachable); GREEN on the windowed read model.
|
||||
*/
|
||||
export function ancestorIsland(opts: {
|
||||
channelId: string;
|
||||
gapCount: number;
|
||||
newestCount: number;
|
||||
now?: number;
|
||||
/** Seconds below NOW for the old island root. Default 115 (inside ±120s). */
|
||||
oldRootOffset?: number;
|
||||
/**
|
||||
* Per-run isolation tag. The suite seeds into shared `general`, so every run
|
||||
* accumulates rows; without a unique marker, a prior run's `gap` rows inflate
|
||||
* the reachable set and false-green the parity assertion (observed 2026-07-03:
|
||||
* a contaminated relay "passed" in 3.3s while a clean channel is RED with
|
||||
* `seen.size === 0`). Callers pass a unique nonce and assert only against this
|
||||
* run's expected set. Default is time+random so ad-hoc calls are still safe.
|
||||
*/
|
||||
nonce?: string;
|
||||
}): Scenario {
|
||||
const {
|
||||
channelId,
|
||||
gapCount,
|
||||
newestCount,
|
||||
now = Math.floor(Date.now() / 1000),
|
||||
oldRootOffset = 115,
|
||||
nonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
} = opts;
|
||||
|
||||
const oldRootSecond = now - oldRootOffset;
|
||||
const oldRoot = buildMessage(
|
||||
signerFor("tyler"),
|
||||
channelId,
|
||||
`island root ${nonce}`,
|
||||
oldRootSecond,
|
||||
);
|
||||
|
||||
// Gap rows: strictly between the old root and the newest window. Spread them
|
||||
// across the seconds just above the old root so none collide with it.
|
||||
const gapTop = now - 10; // newest gap second, still below the newest window
|
||||
const gapBottom = oldRootSecond + 1;
|
||||
const gapSpan = Math.max(1, gapTop - gapBottom);
|
||||
const gap: NostrEvent[] = [];
|
||||
const gapExpected: SeededEvent[] = [];
|
||||
for (let i = 0; i < gapCount; i += 1) {
|
||||
const signer = signerFor(AUTHORS[i % AUTHORS.length]);
|
||||
const second = gapBottom + (i % gapSpan);
|
||||
const event = buildMessage(signer, channelId, `gap ${nonce} ${i}`, second);
|
||||
gap.push(event);
|
||||
gapExpected.push(toRow(event));
|
||||
}
|
||||
|
||||
// Newest window: `newestCount` rows at the top seconds. One is a reply to the
|
||||
// old root (the ancestor-fetch trigger); the rest are plain top-levels.
|
||||
const newestBottom = now - 9;
|
||||
const newest: NostrEvent[] = [];
|
||||
const replyIndex = Math.floor(newestCount / 2);
|
||||
for (let i = 0; i < newestCount; i += 1) {
|
||||
const signer = signerFor(AUTHORS[i % AUTHORS.length]);
|
||||
const second = newestBottom + (i % 10);
|
||||
if (i === replyIndex) {
|
||||
newest.push(
|
||||
buildMessage(
|
||||
signer,
|
||||
channelId,
|
||||
`new ${nonce} ${i} (reply to island root)`,
|
||||
second,
|
||||
nestedReplyTags(oldRoot.id, oldRoot.id),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
newest.push(buildMessage(signer, channelId, `new ${nonce} ${i}`, second));
|
||||
}
|
||||
}
|
||||
|
||||
const reply = newest[replyIndex];
|
||||
const nonReplyNewest = newest.filter((_, i) => i !== replyIndex);
|
||||
|
||||
// Group 1: old root + gap + newest (except the cross-gap reply). Group 2: the
|
||||
// reply (its parent/root is the old root, which must land first).
|
||||
return {
|
||||
name: "ancestor-island",
|
||||
groups: [[oldRoot, ...gap, ...nonReplyNewest], [reply]],
|
||||
expected: gapExpected,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact-multiple final page (Eva 2026-07-03, `39006` window-bounds authority):
|
||||
* regression. Deterministic distinct seconds so ordering is unambiguous.
|
||||
*/
|
||||
export function exactMultiplePage(opts: {
|
||||
channelId: string;
|
||||
limitRows: number;
|
||||
pages: number;
|
||||
startAt: number;
|
||||
}): Scenario {
|
||||
const { channelId, limitRows, pages, startAt } = opts;
|
||||
const total = limitRows * pages;
|
||||
const events: NostrEvent[] = [];
|
||||
for (let i = 0; i < total; i += 1) {
|
||||
const signer = signerFor(AUTHORS[i % AUTHORS.length]);
|
||||
events.push(buildMessage(signer, channelId, `exact ${i}`, startAt + i));
|
||||
}
|
||||
return {
|
||||
name: "exact-multiple-page",
|
||||
groups: [events],
|
||||
expected: events.map(toRow),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish an already-built scenario, returning the ground-truth expected set.
|
||||
*/
|
||||
export async function seedScenario(
|
||||
scenario: Scenario,
|
||||
options: SeedOptions = {},
|
||||
): Promise<SeededEvent[]> {
|
||||
const relayHttpUrl = options.relayHttpUrl ?? DEFAULT_RELAY_HTTP;
|
||||
const concurrency = options.concurrency ?? 16;
|
||||
await publishGroups(scenario.groups, relayHttpUrl, concurrency);
|
||||
return scenario.expected;
|
||||
}
|
||||
|
||||
export const _internal = {
|
||||
buildMessage,
|
||||
buildReaction,
|
||||
buildDeletion,
|
||||
directReplyTags,
|
||||
nestedReplyTags,
|
||||
legacyRootOnlyTags,
|
||||
signerFor,
|
||||
publishGroups,
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
type SettingsSection =
|
||||
| "profile"
|
||||
| "notifications"
|
||||
| "voice"
|
||||
| "agents"
|
||||
| "channel-templates"
|
||||
| "compute"
|
||||
| "appearance"
|
||||
| "shortcuts"
|
||||
| "hosted-communities"
|
||||
| "tokens"
|
||||
| "community-members"
|
||||
| "mobile"
|
||||
| "updates";
|
||||
|
||||
export async function openProfileMenu(page: Page) {
|
||||
await page.getByTestId("open-settings").click();
|
||||
await expect(page.getByTestId("profile-popover")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function openSettings(page: Page, section?: SettingsSection) {
|
||||
await openProfileMenu(page);
|
||||
await page.getByTestId("profile-popover-settings").click();
|
||||
await expect(page.getByTestId("settings-view")).toBeVisible();
|
||||
|
||||
if (section) {
|
||||
await page.getByTestId(`settings-nav-${section}`).click();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// Custom screenshot helper for header zoom-fix comparison.
|
||||
// Supports --port (4173 baseline / 4174 branch), --zoom (CSS zoom factor), and --open-thread / --open-profile / --open-agent.
|
||||
import { chromium } from "@playwright/test";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
const { values: a } = parseArgs({
|
||||
options: {
|
||||
name: { type: "string", default: "shot" },
|
||||
port: { type: "string", default: "4173" },
|
||||
channel: { type: "string", default: "general" },
|
||||
zoom: { type: "string", default: "1" },
|
||||
"open-thread": { type: "boolean", default: false },
|
||||
"open-profile": { type: "boolean", default: false },
|
||||
"open-agent": { type: "boolean", default: false },
|
||||
viewport: { type: "string", default: "1280x800" },
|
||||
clip: { type: "string" },
|
||||
wait: { type: "string", default: "1500" },
|
||||
outdir: { type: "string", default: "/tmp/zoom-shots" },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
|
||||
const [vw, vh] = a.viewport.split("x").map(Number);
|
||||
mkdirSync(resolve(a.outdir), { recursive: true });
|
||||
|
||||
const BASE = `http://127.0.0.1:${a.port}`;
|
||||
const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8);
|
||||
const ONBOARDING_PREFIX = "buzz-onboarding-complete.v1:";
|
||||
const TEST_PUBKEYS = [
|
||||
DEFAULT_MOCK_PUBKEY,
|
||||
"e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34",
|
||||
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f",
|
||||
"bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260",
|
||||
"554cef57437abac34522ac2c9f0490d685b72c80478cf9f7ed6f9570ee8624ea",
|
||||
"df8e91b86fda13a9a67896df77232f7bdab2ba9c3e165378e1ba3d24c13a328e",
|
||||
];
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: vw, height: vh } });
|
||||
|
||||
await page.addInitScript(() => {
|
||||
const id = "e2e-default-community";
|
||||
const ws = {
|
||||
id,
|
||||
name: "E2E Test",
|
||||
relayUrl: "ws://localhost:3000",
|
||||
addedAt: new Date().toISOString(),
|
||||
};
|
||||
window.localStorage.setItem("buzz-communities", JSON.stringify([ws]));
|
||||
window.localStorage.setItem("buzz-active-community-id", id);
|
||||
});
|
||||
await page.addInitScript(
|
||||
({ prefix, pubkeys }) => {
|
||||
for (const pk of pubkeys)
|
||||
window.localStorage.setItem(`${prefix}${pk}`, "true");
|
||||
},
|
||||
{ prefix: ONBOARDING_PREFIX, pubkeys: TEST_PUBKEYS },
|
||||
);
|
||||
await page.addInitScript(() => {
|
||||
class MockNotification extends EventTarget {
|
||||
static permission = "granted";
|
||||
static async requestPermission() {
|
||||
return "granted";
|
||||
}
|
||||
constructor(t, o) {
|
||||
super();
|
||||
this.title = t;
|
||||
this.body = o?.body ?? null;
|
||||
this.onclick = null;
|
||||
}
|
||||
close() {}
|
||||
}
|
||||
Object.defineProperty(window, "Notification", {
|
||||
configurable: true,
|
||||
value: MockNotification,
|
||||
writable: true,
|
||||
});
|
||||
window.__BUZZ_E2E__ = { mode: "mock" };
|
||||
window.__BUZZ_E2E_APP_BADGE_COUNT__ = 0;
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(BASE);
|
||||
await page.waitForSelector(`[data-testid="channel-${a.channel}"]`, {
|
||||
timeout: 15000,
|
||||
});
|
||||
await page.click(`[data-testid="channel-${a.channel}"]`);
|
||||
await page.waitForSelector(`[data-testid="chat-title"]`, { timeout: 10000 });
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
if (a["open-profile"]) {
|
||||
// Click chat-title or members trigger to open profile? Better: avatar in chat header.
|
||||
const avatar = page
|
||||
.locator('[data-testid="chat-header"] [data-testid^="avatar-"]')
|
||||
.first();
|
||||
if ((await avatar.count()) > 0) await avatar.click();
|
||||
}
|
||||
|
||||
if (a["open-thread"]) {
|
||||
// Send a message via mock so we have something to thread.
|
||||
await page.evaluate(
|
||||
({ ch }) => {
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: ch,
|
||||
content: "Header spacing comparison anchor message",
|
||||
pubkey: "deadbeef".repeat(8),
|
||||
});
|
||||
},
|
||||
{ ch: a.channel },
|
||||
);
|
||||
await page.waitForTimeout(400);
|
||||
// Hover the message and click its reply trigger.
|
||||
const replyBtn = page.locator('[data-testid^="reply-message-"]').first();
|
||||
if ((await replyBtn.count()) > 0) {
|
||||
await replyBtn.scrollIntoViewIfNeeded();
|
||||
await replyBtn.click({ force: true });
|
||||
await page.waitForTimeout(600);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply CSS zoom on the html element AFTER interaction setup.
|
||||
const zoom = Number(a.zoom);
|
||||
if (zoom !== 1) {
|
||||
await page.evaluate((z) => {
|
||||
document.documentElement.style.zoom = String(z);
|
||||
}, zoom);
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
await page.waitForTimeout(Number(a.wait));
|
||||
|
||||
const filepath = join(resolve(a.outdir), `${a.name}.png`);
|
||||
const opts = {};
|
||||
if (a.clip) {
|
||||
const [x, y, w, h] = a.clip.split(",").map(Number);
|
||||
opts.clip = { x, y, width: w, height: h };
|
||||
}
|
||||
await page.screenshot({ path: filepath, ...opts });
|
||||
console.log(filepath);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
Reference in New Issue
Block a user