Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 83 additions & 13 deletions scripts/ci/run-webviews-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,16 @@
# CMUX_WEB_TEST_JOBS: files run at once (default: CMUX_CI_CPU_BUDGET, the cores a fleet
# worker granted the step, else the CPU count).
#
# Files that start a Vite dev server run one at a time after the rest: the agent pane and gallery
# configs (vite.config.acpmux-pane.mjs, vite.config.gallery-dev.ts), and files that spawn `vp dev`
# themselves (editor-page, ui-a11y, settings-scroll). Three of them starting at once (agent-history,
# agent-pane-peer-chat, chat-find) stall in the dependency optimizer's first bundle and never load
# the page, even with a dependency cache each, while each passes alone and any two pass together;
# editor-page timed out the same way beside ui-a11y and text-field-focus.
# Files that start a Vite dev server run after the rest: the agent pane and gallery configs
# (vite.config.acpmux-pane.mjs, vite.config.gallery-dev.ts), and files that spawn `vp dev`
# themselves (editor-page, ui-a11y, settings-scroll). On one node_modules/.vite two at once lose
# each other's optimized files, and three starting cold at once (agent-history,
# agent-pane-peer-chat, chat-find) stall in the optimizer's first bundle, even with a cache each;
# editor-page timed out the same way beside ui-a11y and text-field-focus. So one file per Vite
# config runs first, alone, and warms that config's cache (CMUX_VITE_CACHE_DIR, which the dev
# configs read); the rest then run CMUX_WEB_TEST_DEV_JOBS at once (default a quarter of the jobs), each
# on its own copy of its config's warm cache. A config whose warming file fails keeps its other
# files one at a time on a cold cache each.
#
# Run from webviews/ (CI, local dev) it tests what is there. Run from anywhere else in the
# checkout (a cmux-ci step starts at the checkout root) it first makes the web bundles
Expand Down Expand Up @@ -55,12 +59,30 @@ run_with_timeout() {
fi
}

if [ "${1:-}" = --one-cached ]; then
# xargs -n 3 appends FILE CACHE SEED after the fixed arguments.
exec bash "$0" --one "$4" "$2" "$3" "$5" "${6:-}"
fi
if [ "${1:-}" = --one ]; then
# Worker: run one file, write its log and status into the results directory.
file="$2" results="$3" timeout_seconds="$4"
# Worker: run one file, write its log and status into the results directory. A dev server
# file gets a Vite cache of its own (CMUX_VITE_CACHE_DIR), a copy of a warm one when given.
file="$2" results="$3" timeout_seconds="$4" cache="${5:-}" seed="${6:-}"
log="$results/$(printf '%s' "$file" | tr '/' '_').log"
status=0
run_with_timeout "$timeout_seconds" bun test "./$file" > "$log" 2>&1 < /dev/null || status=$?
if [ -n "$cache" ]; then
# `|| true` on cache upkeep: a failed copy or cleanup (a server outliving its timed-out file)
# must still leave this file's result in the summary, not stop the lane under set -e.
rm -rf "$cache" || true
mkdir -p "$(dirname "$cache")"
if [ -n "$seed" ] && [ -d "$seed" ]; then cp -R "$seed" "$cache" || true; fi
mkdir -p "$cache"
CMUX_VITE_CACHE_DIR="$cache" run_with_timeout "$timeout_seconds" bun test "./$file" > "$log" 2>&1 < /dev/null ||
status=$?
# A warming file's cache (no seed) is the one the rest copy; any other one only costs disk.
if [ -n "$seed" ]; then rm -rf "$cache" || true; fi
else
run_with_timeout "$timeout_seconds" bun test "./$file" > "$log" 2>&1 < /dev/null || status=$?
fi
case "$status" in
0) result=PASS ;;
124 | 137) result=TIMEOUT ;;
Expand Down Expand Up @@ -107,15 +129,63 @@ if [ "$count" -eq 0 ]; then
fi
# Two dev servers at once (composer-mention, composer-send-stop) optimize dependencies into the
# same node_modules/.vite, and each loses files the other wrote. `"dev",` is the argument a file
# passes when it spawns `vp dev` itself. `--null`, not `-Z`: BSD grep reads -Z as decompress.
dev_servers='vite\.config\.(acpmux-pane\.mjs|gallery-dev\.ts)|"dev",'
# passes when it spawns `vp dev` itself, `configFile: …` one that calls createServer. `--null`, not `-Z`: BSD grep reads -Z as decompress.
dev_servers='vite\.config\.(acpmux-pane\.mjs|gallery-dev\.ts)|"dev",|configFile: path\.join\(webviews, "vite\.config'
xargs -0 grep -lE --null -- "$dev_servers" < "$results/files" > "$results/serial" || true
serial="$(tr -cd '\0' < "$results/serial" | wc -c | tr -d ' ')"
xargs -0 grep -LE --null -- "$dev_servers" < "$results/files" > "$results/parallel" || true
echo "running $count webviews test files, $jobs at a time ($serial dev server files one at a time after), ${timeout_seconds}s each"
# A quarter of the jobs: each dev server file runs a Vite server and a browser, and at jobs / 2 the
# 8-core runner was CPU bound (no faster per file) and frame-budget plays (instructions-notice)
# and attach timing (agent-pane-seen) failed.
dev_jobs="${CMUX_WEB_TEST_DEV_JOBS:-$((jobs > 3 ? jobs / 4 : 1))}"
echo "running $count webviews test files, $jobs at a time ($serial dev server files after, $dev_jobs at a time), ${timeout_seconds}s each"
: > "$results/summary"
xargs -0 -P "$jobs" -I{} bash "$self" --one {} "$results" "$timeout_seconds" < "$results/parallel"
xargs -0 -P 1 -I{} bash "$self" --one {} "$results" "$timeout_seconds" < "$results/serial"

# vite_config FILE: the Vite config whose dependency cache FILE's dev server fills.
vite_config() {
if grep -q 'vite\.config\.acpmux-pane\.mjs' "$1"; then echo pane
elif grep -q 'vite\.config\.gallery-dev\.ts' "$1"; then echo gallery
elif grep -q 'vite\.config\.settings-page\.mjs' "$1"; then echo settings
else echo default
fi
}
# One file per config warms its cache, alone; the others wait for it.
vite="$results/vite"
warmed=" "
: > "$results/dev-rest"
while IFS= read -r -d '' file; do
config="$(vite_config "$file")"
case "$warmed" in
*" $config "*) printf '%s\0%s\0' "$config" "$file" >> "$results/dev-rest" ;;
*)
warmed="$warmed$config "
printf '%s %s\n' "$config" "$file" >> "$results/warmers"
bash "$self" --one "$file" "$results" "$timeout_seconds" "$vite/seed-$config"
;;
esac
done < "$results/serial"
# The rest copy their config's warm cache and run at once; a config whose warming file did not
# pass has no trustworthy cache, so its files run one at a time, each on a cold cache of its own.
: > "$results/dev-warm"
: > "$results/dev-cold"
index=0
while IFS= read -r -d '' config && IFS= read -r -d '' file; do
index=$((index + 1))
warmer="$(sed -n "s/^$config //p" "$results/warmers")"
if grep -qxF "PASS $warmer $results/$(printf '%s' "$warmer" | tr '/' '_').log" "$results/summary"; then
printf '%s\0%s\0%s\0' "$file" "$vite/$index" "$vite/seed-$config" >> "$results/dev-warm"
else
printf '%s\0%s\0%s\0' "$file" "$vite/$index" "$vite/none" >> "$results/dev-cold"
fi
done < "$results/dev-rest"
# Without input, xargs would run the command once with no file (BSD xargs has no -r).
if [ -s "$results/dev-warm" ]; then
xargs -0 -n 3 -P "$dev_jobs" bash "$self" --one-cached "$results" "$timeout_seconds" < "$results/dev-warm"
fi
if [ -s "$results/dev-cold" ]; then
xargs -0 -n 3 -P 1 bash "$self" --one-cached "$results" "$timeout_seconds" < "$results/dev-cold"
fi

failed=0
while read -r result file log; do
Expand Down
58 changes: 47 additions & 11 deletions tests/test_webviews_tests_per_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,28 @@
FAKE_BUN = """#!/usr/bin/env bash
[ "$1" = test ] || exit 9
printf '%s\\n' "$2" >> "$BUN_CALLS"
# A file that starts a Vite dev server holds the shared optimizer cache while it runs.
if grep -q -e '"dev",' -e 'vite.config.gallery-dev.ts' "$2"; then
if mkdir "$DEV_SERVER_LOCK" 2> /dev/null; then
# A file that starts a Vite dev server optimizes dependencies into its Vite cache while it runs: two
# at once on one cache lose each other's files, and a cold cache is the slow first bundle.
if grep -q -e '"dev",' -e 'createServer(' -e 'vite.config.gallery-dev.ts' -e 'vite.config.acpmux-pane.mjs' "$2"; then
config=default
grep -q 'vite.config.settings-page.mjs' "$2" && config=settings
grep -q 'vite.config.gallery-dev.ts' "$2" && config=gallery
grep -q 'vite.config.acpmux-pane.mjs' "$2" && config=pane
cache="${CMUX_VITE_CACHE_DIR:-$SHARED_VITE_CACHE}"
warm=cold
[ -e "$cache/warm-$config" ] && warm=warm
printf '%s %s %s\\n' "$2" "$cache" "$warm" >> "$BUN_DEV"
mkdir "$DEV_RUNNING/$$"
ls "$DEV_RUNNING" | wc -l >> "$BUN_PEAK"
if mkdir "$cache.lock" 2> /dev/null; then
sleep 0.5
rmdir "$DEV_SERVER_LOCK"
mkdir -p "$cache"
touch "$cache/warm-$config"
rmdir "$cache.lock"
else
printf 'overlap %s\\n' "$2" >> "$BUN_OVERLAPS"
fi
rmdir "$DEV_RUNNING/$$"
fi
case "$2" in
*hang*) sleep 60 ;;
Expand Down Expand Up @@ -77,10 +91,17 @@ def run_runner(self, files, without_timeout=False, contents=None):
calls.write_text("", encoding="utf-8")
self.overlaps = temp / "overlaps"
self.overlaps.write_text("", encoding="utf-8")
self.dev, self.peak, running = temp / "dev", temp / "peak", temp / "running"
self.dev.write_text("", encoding="utf-8")
self.peak.write_text("", encoding="utf-8")
running.mkdir()
(web / "node_modules").mkdir()
self.shared_cache = web / "node_modules" / ".vite"
system_path = path_without_timeout(temp) if without_timeout else os.environ["PATH"]
env = {**os.environ, "PATH": f"{bin_dir}:{system_path}", "BUN_CALLS": str(calls),
"BUN_OVERLAPS": str(self.overlaps), "DEV_SERVER_LOCK": str(temp / "dev-server.lock"),
"CMUX_WEB_TEST_FILE_TIMEOUT": "2", "CMUX_WEB_TEST_JOBS": "4"}
"BUN_OVERLAPS": str(self.overlaps), "SHARED_VITE_CACHE": str(web / "node_modules" / ".vite"),
"BUN_DEV": str(self.dev), "BUN_PEAK": str(self.peak), "DEV_RUNNING": str(running),
"CMUX_WEB_TEST_FILE_TIMEOUT": "2", "CMUX_WEB_TEST_JOBS": "8"}
started = time.monotonic()
result = subprocess.run(["bash", str(RUNNER)], cwd=web, env=env, capture_output=True, text=True, timeout=40)
return result, time.monotonic() - started, sorted(calls.read_text(encoding="utf-8").split())
Expand Down Expand Up @@ -113,27 +134,42 @@ def test_passing_files_pass(self):
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(calls, ["./src/b.test.tsx", "./test/a.test.ts"])

def test_files_that_start_a_dev_server_never_run_at_once(self):
# editor-page, ui-a11y and text-field-focus start `vp dev` with the default config. Run at
# once on a fresh checkout, they optimize dependencies into one node_modules/.vite and the
# loser's page never loads: editor-page timed out on react-apps-check for #19746.
def test_dev_server_files_run_at_once_each_on_its_own_warm_vite_cache(self):
# Every file that starts a dev server once ran one at a time after the rest (#19812): on
# one node_modules/.vite two at once lost each other's optimized files, and editor-page
# timed out beside ui-a11y and text-field-focus (#19746). That lane added 2.5 minutes. Each
# file now has its own cache, copied from one warmed per config, so several run at once.
default_server = 'spawn(vp, ["dev", "--port", String(port), "--strictPort"]);\n'
settings_server = 'spawn(vp, [\n "dev",\n "--config",\n "vite.config.settings-page.mjs",\n]);\n'
gallery_server = 'import config from "../vite.config.gallery-dev.ts";\n'
pane_server = 'spawn(vp, ["dev", "--config", "vite.config.acpmux-pane.mjs", "--port", String(port)]);\n'
created_server = 'vite = await createServer({\n configFile: path.join(webviews, "vite.config.ts"),\n'
contents = {
"test/editor-page.test.ts": default_server,
"test/ui-a11y.test.ts": default_server,
"test/text-field-focus.test.ts": default_server,
"test/new-tab-typing.test.ts": created_server,
"test/settings-scroll.test.ts": settings_server,
"test/composer-mention.test.ts": gallery_server,
"test/composer-send-stop.test.ts": gallery_server,
"test/chat-find.test.ts": pane_server,
"test/agent-history.test.ts": pane_server,
"test/agent-pane-peer-chat.test.ts": pane_server,
}
files = [*contents, "test/a.test.ts", "src/b.test.tsx"]
result, _, calls = self.run_runner(files, contents=contents)
output = result.stdout + result.stderr
self.assertEqual(result.returncode, 0, output)
self.assertEqual(calls, sorted(f"./{name}" for name in files))
self.assertEqual(self.overlaps.read_text(encoding="utf-8"), "", output)
self.assertIn("(5 dev server files one at a time after)", output)
runs = [line.split() for line in self.dev.read_text(encoding="utf-8").splitlines()]
self.assertEqual(sorted(name for name, _, _ in runs), sorted(f"./{name}" for name in contents))
caches = [cache for _, cache, _ in runs]
self.assertEqual(len(set(caches)), len(caches), "each dev server file has its own Vite cache")
self.assertNotIn(str(self.shared_cache), caches)
# One file per config warms its cache first; every other file starts from a copy of it.
self.assertEqual(sum(warm == "cold" for _, _, warm in runs), 4, runs)
self.assertGreater(max(int(n) for n in self.peak.read_text(encoding="utf-8").split()), 1, output)

def test_no_test_files_is_an_error(self):
result, _, _ = self.run_runner([])
Expand Down
7 changes: 5 additions & 2 deletions webviews/dev-server/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import {
splitStyles,
} from "./markdownHost";
import { galleryHost, galleryModules } from "./galleryHost";
import { viteCacheDir } from "./viteCacheDir.mjs";

const webviewsRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
const repoRoot = path.join(webviewsRoot, "..");
Expand Down Expand Up @@ -173,8 +174,10 @@ function devServerShell(): Plugin {
return {
// One dependency cache per server. A cache shared with another server on the same
// node_modules (vite.config.acpmux-pane.mjs, another slot) is re-optimized under it, and
// open pages then fail to load modules and requests mid-load.
cacheDir: `node_modules/${dependencyCacheName(config.server?.port ?? DEV_SERVER_PORT)}`,
// open pages then fail to load modules and requests mid-load. The webviews test runner
// names one of its own (CMUX_VITE_CACHE_DIR), a copy of a warm one, for fresh browsers
// only, so its modules skip the no-cache revalidation below.
cacheDir: viteCacheDir ?? `node_modules/${dependencyCacheName(config.server?.port ?? DEV_SERVER_PORT)}`,
// Every surface's entry, so the first scan finds all dependencies instead of a page
// discovering one late and triggering an optimizer reload.
// Monaco stays unbundled: its language configurations (pages/editor/view.ts) import its API
Expand Down
4 changes: 4 additions & 0 deletions webviews/dev-server/viteCacheDir.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Where Vite keeps its optimized dependencies. scripts/ci/run-webviews-tests.sh gives each test
// file that starts a dev server its own directory, so files that run at once never optimize into
// one node_modules/.vite and lose each other's files. Unset, Vite's default.
export const viteCacheDir = process.env.CMUX_VITE_CACHE_DIR || undefined;
41 changes: 41 additions & 0 deletions webviews/test/vite-cache-dir.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// The webviews test runner (scripts/ci/run-webviews-tests.sh) runs dev server files at once, each
// on its own copy of a warm Vite cache, named by CMUX_VITE_CACHE_DIR. Every config a test serves
// must then optimize into that directory: one that keeps another (vite.config.ts's dev server
// plugin set a cache per port) runs cold beside the others, and cold servers started together
// stall in the optimizer (#19746).
import { describe, expect, test } from "bun:test";
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

const webviews = path.join(import.meta.dir, "..");
const resolve = `
const { resolveConfig } = await import("vite");
const config = await resolveConfig({ configFile: process.env.CONFIG, server: { port: 0 } }, "serve", "development");
console.log("cacheDir=" + config.cacheDir);
`;

describe("CMUX_VITE_CACHE_DIR", () => {
for (const name of [
"vite.config.ts",
"vite.config.acpmux-pane.mjs",
"vite.config.gallery-dev.ts",
"vite.config.settings-page.mjs",
]) {
test(`${name} optimizes into it`, () => {
const cache = fs.mkdtempSync(path.join(os.tmpdir(), "cmux-vite-cache-"));
try {
const result = spawnSync(process.execPath, ["-e", resolve], {
cwd: webviews,
env: { ...process.env, CONFIG: path.join(webviews, name), CMUX_VITE_CACHE_DIR: cache },
encoding: "utf8",
timeout: 60_000,
});
expect(result.stdout.match(/^cacheDir=(.*)$/m)?.[1], result.stderr).toBe(cache);
} finally {
fs.rmSync(cache, { recursive: true, force: true });
}
}, 70_000);
}
});
2 changes: 2 additions & 0 deletions webviews/vite.config.acpmux-pane.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { reactWithCompiler } from "./reactCompiler.mjs";
import { defineConfig } from "vite";
import { fileURLToPath } from "node:url";
import path from "node:path";
import { viteCacheDir } from "./dev-server/viteCacheDir.mjs";

// Dev server for the real agent pane (src/agent-session/acpmux) with hot
// reload. A Debug or tagged cmux-next launched with
Expand All @@ -17,6 +18,7 @@ const port = Number(process.env.CMUX_AGENT_PANE_DEV_PORT) || 4176;

export default defineConfig({
root: paneRoot,
cacheDir: viteCacheDir,
server: {
host: "127.0.0.1",
port,
Expand Down
2 changes: 2 additions & 0 deletions webviews/vite.config.settings-page.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { reactWithCompiler } from "./reactCompiler.mjs";
import { defineConfig } from "vite";
import { fileURLToPath } from "node:url";
import path from "node:path";
import { viteCacheDir } from "./dev-server/viteCacheDir.mjs";

// Dev server for the Settings page (src/pages/settings) with hot reload. Outside the app the page
// runs on the in-memory fake transport (src/pages/settings/fakeTransport.ts). Serve only: the
Expand All @@ -11,6 +12,7 @@ const webviewsRoot = path.resolve(fileURLToPath(new URL(".", import.meta.url)));
const repoRoot = path.resolve(webviewsRoot, "..");

export default defineConfig({
cacheDir: viteCacheDir,
root: path.join(webviewsRoot, "src/pages/settings"),
server: {
host: "127.0.0.1",
Expand Down
2 changes: 2 additions & 0 deletions webviews/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { defineConfig } from "vite-plus";
import { cmuxCheckConfig } from "../config/vite-plus/check";
import { fileURLToPath } from "node:url";
import { cmuxDevServer, DEV_SERVER_PORT } from "./dev-server/plugins";
import { viteCacheDir } from "./dev-server/viteCacheDir.mjs";

const outDir = process.env.CMUX_WEBVIEWS_OUT_DIR ?? "../Resources/markdown-viewer/webviews-app";
// The code editor's copy of Pierre's extension map (pages/editor/pierre-filetypes.d.ts). The query
Expand All @@ -14,6 +15,7 @@ const pierreFiletypes =
PIERRE_FILETYPES_QUERY;

export default defineConfig({
cacheDir: viteCacheDir,
// `vp check` reads `lint` and `fmt` from here; `vite build` ignores them.
// ts-rs emits src/diff/generated; scripts/generate-diff-sidecar-types.sh --check
// owns it. Stylesheets are inlined verbatim into the shipped agent pane, so
Expand Down
Loading