Files
Mohamed Boudra adf27de7d0 Keep Explorer consistent across phone, tablet and desktop layouts (#7424)
* Upgrade to Expo 57 and unify Explorer across layouts

* feat(app): retain Explorer across layout and appearance changes

Keep one workspace Explorer host and destination-aware navigation so folding, theme updates and compact sheets preserve the current panel. Scope pull request discovery and timeline demand to active surfaces.

* fix(app): keep Explorer resize handles stretched through the dock

The shared frame moved the divider into an absolute column, leaving its horizontal handle without height. Restore the row so pointer drags reach it, preserving the existing resize and performance contract.

* fix(app): keep Explorer drag widths out of the shared stylesheet

Carry the existing inline sizing policy into the shared frame so pointer previews do not accumulate CSS rules.

* fix(test): align isolated test runtimes with Expo 57

Hoisted React consumers need the matching DOM renderer at the repository root. Automated Metro processes must not install native DevTools into disposable homes, and development toast overlays must not cover compact actions.

Keep Explorer journeys on the shared host and synchronize setup fixtures on daemon output rather than a one-second file-marker window. A delayed real child start reproduces the readiness failure on every platform.

* fix(test): await retained shell layout after composer resize

The gesture host now stays mounted across breakpoints, so its presence no longer identifies compact layout. Report the current layout on the retained host and synchronize composer journeys with that state.

* fix(app): preserve browser interactions in retained workspace hosts

Initially disabled gesture hosts still registered pointer and wheel listeners, while hover cards used window positions inside a workspace-local sheet portal. Respect initial gesture enablement and position cards relative to their measured floating host.

Keep developer-toast dismissal independent of pointer input and replacement toast visibility so modal, hover, and focus checks retain their input state.

* fix(test): share developer toast handling with Electron browser runs

The standalone Electron browser runner bypassed the browser fixture, allowing developer errors to cover Settings. Use the same non-pointer dismissal in both runners while retaining their console logs.
2026-10-20 11:59:49 +07:00

195 lines
6.3 KiB
TypeScript

import { warmMetro } from "./metro-warmup.mjs";
export { warmMetro } from "./metro-warmup.mjs";
import { spawn, type ChildProcess } from "node:child_process";
import { killProcessTree } from "./helpers/spawn-node";
import { existsSync } from "node:fs";
import path from "node:path";
import net from "node:net";
import dotenv from "dotenv";
export interface WaitForServerOptions {
host?: string;
timeoutMs?: number;
label: string;
childProcess?: ChildProcess | null;
getRecentOutput?: () => string;
}
type ServerProbe = (host: string, port: number) => Promise<void>;
const RESERVED_LOCAL_PORTS = new Set([
6767, // Installed daemon.
6768, // Developer daemon.
61680, // OpenCode's default local server.
]);
function createLineBuffer(maxLines = 120): { add: (line: string) => void; dump: () => string } {
const lines: string[] = [];
return {
add(line) {
lines.push(line);
if (lines.length > maxLines) lines.shift();
},
dump: () => lines.join("\n"),
};
}
function formatRecentOutput(getRecentOutput?: () => string): string {
const output = getRecentOutput?.().trim();
return output ? `\nRecent output:\n${output}` : "";
}
async function getAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to acquire port")));
return;
}
server.close(() => resolve(address.port));
});
});
}
async function getAvailableE2EPort(): Promise<number> {
for (;;) {
const port = await getAvailablePort();
if (!RESERVED_LOCAL_PORTS.has(port)) return port;
}
}
async function connectToServer(host: string, port: number): Promise<void> {
await new Promise<void>((resolve, reject) => {
const socket = net.connect(port, host, () => {
socket.end();
resolve();
});
socket.setTimeout(1_000, () => {
socket.destroy();
reject(new Error(`Connection timed out to ${host}:${port}`));
});
socket.on("error", reject);
});
}
async function waitForServer(
port: number,
options: WaitForServerOptions,
probe: ServerProbe = connectToServer,
): Promise<void> {
const { host = "127.0.0.1", timeoutMs = 15_000, label, childProcess, getRecentOutput } = options;
const deadline = Date.now() + timeoutMs;
let lastConnectionError: unknown = null;
while (Date.now() < deadline) {
if (childProcess?.exitCode !== null && childProcess?.exitCode !== undefined) {
const signal = childProcess.signalCode ? `, signal ${childProcess.signalCode}` : "";
throw new Error(
`${label} exited before listening on ${host}:${port} (exit code ${childProcess.exitCode}${signal}).${formatRecentOutput(getRecentOutput)}`,
);
}
try {
await probe(host, port);
return;
} catch (error) {
lastConnectionError = error;
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
const reason =
lastConnectionError instanceof Error
? ` Last connection error: ${lastConnectionError.message}`
: "";
throw new Error(
`${label} did not start on ${host}:${port} within ${timeoutMs}ms.${reason}${formatRecentOutput(getRecentOutput)}`,
);
}
async function probeMetro(host: string, port: number): Promise<void> {
const response = await fetch(`http://${host}:${port}/status`, {
signal: AbortSignal.timeout(1_000),
});
const body = (await response.text()).trim();
if (response.status !== 200 || body !== "packager-status:running") {
throw new Error(
`Expected Metro status on ${host}:${port}, received HTTP ${response.status}: ${JSON.stringify(body.slice(0, 200))}`,
);
}
}
export async function waitForMetro(port: number, options: WaitForServerOptions): Promise<void> {
await waitForServer(port, options, probeMetro);
}
function startMetro(port: number, buffer: ReturnType<typeof createLineBuffer>): ChildProcess {
const appDir = path.resolve(__dirname, "../..");
const expoCli = require.resolve("expo/bin/cli");
// Spawns Node directly to bypass Windows .cmd shim execution restrictions without shell: true.
const child = spawn(process.execPath, [expoCli, "start", "--web", "--port", String(port)], {
cwd: appDir,
env: {
...process.env,
BROWSER: "none",
// Automation never opens native DevTools or installs its desktop shell.
EXPO_UNSTABLE_HEADLESS: "1",
...(process.env.E2E_DESKTOP_RUNTIME === "1" ? { PASEO_WEB_PLATFORM: "electron" } : {}),
},
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
const log = (chunk: Buffer, stream: "stdout" | "stderr") => {
for (const line of chunk.toString().split("\n").filter(Boolean)) {
buffer.add(`[${stream}] ${line}`);
console[stream === "stdout" ? "log" : "error"](`[metro] ${line}`);
}
};
child.stdout?.on("data", (chunk: Buffer) => log(chunk, "stdout"));
child.stderr?.on("data", (chunk: Buffer) => log(chunk, "stderr"));
return child;
}
async function loadHarnessEnvironment(repoRoot: string): Promise<void> {
const envTestPath = path.join(repoRoot, ".env.test");
if (existsSync(envTestPath)) dotenv.config({ path: envTestPath });
}
export default async function globalSetup() {
if (process.env.PASEO_REPLICA_CACHE_MEASUREMENT === "1") {
if (!process.env.PASEO_REPLICA_CACHE_MEASUREMENT_URL) {
throw new Error("PASEO_REPLICA_CACHE_MEASUREMENT_URL must be set for live measurement");
}
return;
}
const repoRoot = path.resolve(__dirname, "../../../..");
await loadHarnessEnvironment(repoRoot);
const metroPort = await getAvailableE2EPort();
const metroOutput = createLineBuffer();
let metroProcess: ChildProcess | null = null;
try {
metroProcess = startMetro(metroPort, metroOutput);
await waitForMetro(metroPort, {
label: "Metro web server",
timeoutMs: 120_000,
childProcess: metroProcess,
getRecentOutput: metroOutput.dump,
});
await warmMetro(metroPort);
process.env.E2E_METRO_PORT = String(metroPort);
console.log(`[e2e] Metro warmed on port ${metroPort}`);
return async () => {
await killProcessTree(metroProcess);
console.log("[e2e] Metro stopped");
};
} catch (error) {
await killProcessTree(metroProcess);
throw error;
}
}