fix: navigate before loading page data
This commit is contained in:
@@ -0,0 +1,496 @@
|
||||
/// <reference lib="dom" />
|
||||
import { assert, assertEquals } from "jsr:@std/assert@1.0.19";
|
||||
import { type Browser, chromium, type Page, type Request, type Route } from "npm:playwright@1.59.1";
|
||||
import { loadScenario } from "../src/scenario.ts";
|
||||
import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts";
|
||||
|
||||
// Explicit browser boundary: production static assets + synthetic loopback API.
|
||||
// No real Backend auth, Worker execution, or application test hooks.
|
||||
// The caller supplies the built workspace via the standard suite layout; for isolated
|
||||
// runs, copy this suite into a tree whose web/workspace/build points at isolated assets.
|
||||
// This suite NEVER runs a build, mutates assets, or falls back to another static root.
|
||||
const staticRoot = new URL("../../../web/workspace/build/", import.meta.url).pathname;
|
||||
const output = new URL("../../../.yoi/dev/navigation-visual/", import.meta.url).pathname;
|
||||
const phase = "after";
|
||||
const listPath = "/w/tickets/tickets";
|
||||
const detailPath = "/w/tickets/tickets/T-2-read-only-investigation";
|
||||
const otherDetailPath = "/w/tickets/tickets/T-3-independent-review-before-merge";
|
||||
|
||||
async function withFixture(run: (browser: Browser, base: string) => Promise<void>) {
|
||||
// A missing build is an explicit failure; no alternate root or implicit build.
|
||||
await Deno.stat(`${staticRoot}index.html`);
|
||||
const scenarioPath = new URL("../scenarios/tickets.json", import.meta.url).pathname;
|
||||
const scenario = await loadScenario(scenarioPath);
|
||||
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
|
||||
const port = (listener.addr as Deno.NetAddr).port;
|
||||
listener.close();
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
scenario.processes![0].args![4] = String(port);
|
||||
scenario.processes![0].args![5] = staticRoot;
|
||||
scenario.processes![0].readyUrl = `${base}/health`;
|
||||
const processes = await startOwnedProcesses(
|
||||
scenario.processes!,
|
||||
scenarioPath,
|
||||
`${output}${phase}/logs/${port}`,
|
||||
[],
|
||||
);
|
||||
let browser: Browser | null = null;
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
await run(browser, base);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
assertEquals(await stopOwnedProcesses(processes), [], "owned fixture cleanup");
|
||||
}
|
||||
}
|
||||
|
||||
async function bounded<T>(promise: Promise<T>): Promise<T> {
|
||||
let timer: number | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("Response gate timed out")), 15_000);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the real synthetic response, but do not deliver it to the browser until release.
|
||||
// held means response bytes exist in Playwright, NOT requestfinished in the browser.
|
||||
async function responseGate(
|
||||
page: Page,
|
||||
pattern: string | RegExp,
|
||||
matches: (request: Request) => boolean = () => true,
|
||||
replacement?: { status: number; body: string; contentType: string },
|
||||
transform?: (body: Record<string, unknown>) => unknown,
|
||||
) {
|
||||
const held = Promise.withResolvers<void>();
|
||||
const releaseSignal = Promise.withResolvers<void>();
|
||||
const delivered = Promise.withResolvers<void>();
|
||||
let closed = true;
|
||||
let intercepted = false;
|
||||
let browserFinished = false;
|
||||
let gatedRequest: Request | undefined;
|
||||
const onFinished = (request: Request) => {
|
||||
if (request === gatedRequest) browserFinished = true;
|
||||
};
|
||||
page.on("requestfinished", onFinished);
|
||||
const handler = async (route: Route) => {
|
||||
if (intercepted || !matches(route.request())) return await route.fallback();
|
||||
intercepted = true;
|
||||
gatedRequest = route.request();
|
||||
try {
|
||||
const response = replacement ? undefined : await route.fetch();
|
||||
const json = transform && response ? transform(await response.json()) : undefined;
|
||||
held.resolve();
|
||||
await releaseSignal.promise;
|
||||
if (replacement) await route.fulfill(replacement);
|
||||
else await route.fulfill({ response, ...(json === undefined ? {} : { json }) });
|
||||
delivered.resolve();
|
||||
} catch (error) {
|
||||
held.reject(error);
|
||||
delivered.reject(error);
|
||||
}
|
||||
};
|
||||
await page.route(pattern, handler);
|
||||
// Attach handlers immediately so cancellation during teardown isn't an unhandled rejection.
|
||||
void held.promise.catch(() => {});
|
||||
void delivered.promise.catch(() => {});
|
||||
return {
|
||||
held: () => bounded(held.promise),
|
||||
assertClosed() {
|
||||
assert(intercepted, "API response must be intercepted first");
|
||||
assert(closed, "response gate must remain CLOSED during navigation assertions");
|
||||
assertEquals(browserFinished, false, "browser must not have finished the held request");
|
||||
},
|
||||
release() {
|
||||
closed = false;
|
||||
releaseSignal.resolve();
|
||||
},
|
||||
delivered: () => bounded(delivered.promise),
|
||||
async dispose() {
|
||||
closed = false;
|
||||
releaseSignal.resolve();
|
||||
await page.unroute(pattern, handler);
|
||||
page.off("requestfinished", onFinished);
|
||||
},
|
||||
};
|
||||
}
|
||||
type Gate = Awaited<ReturnType<typeof responseGate>>;
|
||||
|
||||
async function loading(page: Page, gate: Gate, path: string, title: RegExp, updating = false) {
|
||||
await gate.held();
|
||||
gate.assertClosed();
|
||||
await page.waitForURL((url) => url.pathname + url.search === path, { timeout: 5_000 });
|
||||
await page.getByRole("heading", { name: title, level: 1 }).waitFor({ timeout: 5_000 });
|
||||
await page.getByRole("status").filter({ hasText: updating ? /^Updating…$/ : /Loading/ })
|
||||
.first().waitFor({ timeout: 5_000 });
|
||||
gate.assertClosed();
|
||||
}
|
||||
|
||||
// Synthetic links exercise the public SvelteKit delegated link handler, not a full reload.
|
||||
// Used only where the fixture has no product link (cross-workspace and stale slug scenarios).
|
||||
async function navigate(page: Page, path: string) {
|
||||
await page.evaluate((path) => {
|
||||
const link = document.createElement("a");
|
||||
link.href = path;
|
||||
link.dataset.sveltekitPreloadData = "off";
|
||||
document.body.append(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}, path);
|
||||
}
|
||||
async function clickTickets(page: Page) {
|
||||
// Actual product sidebar link, even when the mobile sidebar is folded.
|
||||
await page.locator("a.sidebar-link").filter({ hasText: /^Tickets$/ })
|
||||
.evaluate((link) => (link as HTMLAnchorElement).click());
|
||||
}
|
||||
async function paint(page: Page) {
|
||||
await page.evaluate(() =>
|
||||
new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
|
||||
)
|
||||
);
|
||||
}
|
||||
async function capture(page: Page, name: string, gate?: Gate) {
|
||||
gate?.assertClosed();
|
||||
await paint(page);
|
||||
const directory = `${output}${phase}`;
|
||||
await Deno.mkdir(directory, { recursive: true });
|
||||
await page.screenshot({ path: `${directory}/${name}.png`, fullPage: true });
|
||||
// Safe, fixture-only evidence. Deliberately no cookies, storage, headers, trace or HAR.
|
||||
const context = await page.evaluate(() => ({
|
||||
path: location.pathname + location.search,
|
||||
title: document.title,
|
||||
headings: [...document.querySelectorAll("h1")].map((node) => node.textContent),
|
||||
statuses: [...document.querySelectorAll('[role="status"]')].map((node) => node.textContent),
|
||||
liveRegions: [...document.querySelectorAll('[aria-live="polite"]')].map((node) =>
|
||||
node.textContent
|
||||
),
|
||||
alerts: [...document.querySelectorAll('[role="alert"]')].map((node) => node.textContent),
|
||||
viewport: { width: innerWidth, height: innerHeight },
|
||||
theme: matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light",
|
||||
pageOverflow: document.documentElement.scrollWidth > innerWidth,
|
||||
}));
|
||||
assertEquals(context.pageOverflow, false, "navigation state must not overflow viewport");
|
||||
await Deno.writeTextFile(
|
||||
`${directory}/${name}.json`,
|
||||
JSON.stringify(
|
||||
{
|
||||
...context,
|
||||
responseGate: gate ? "closed" : "resolved",
|
||||
staticRoot,
|
||||
boundary: "synthetic loopback fixture; no real Backend authentication",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
gate?.assertClosed();
|
||||
}
|
||||
async function rows(page: Page) {
|
||||
await page.locator("a.ticket-list-row").first().waitFor();
|
||||
}
|
||||
async function search(page: Page, query: string) {
|
||||
await page.getByRole("searchbox", { name: "Search tickets", exact: true }).fill(query);
|
||||
await page.getByRole("button", { name: "Search", exact: true }).click();
|
||||
}
|
||||
const queryPattern = "**/api/w/tickets/tickets/query";
|
||||
|
||||
Deno.test("immediate navigation: Orchestrator -> Tickets -> detail before responses, visual matrix", async () => {
|
||||
await withFixture(async (browser, base) => {
|
||||
for (const colorScheme of ["light", "dark"] as const) {
|
||||
for (const width of [1440, 768, 390, 320]) {
|
||||
const context = await browser.newContext({
|
||||
colorScheme,
|
||||
viewport: { width, height: 900 },
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(String(error)));
|
||||
await page.goto(`${base}/w/tickets/orchestrator`);
|
||||
await page.locator(".orchestrator-assignment").first().waitFor();
|
||||
const listGate = await responseGate(page, queryPattern);
|
||||
try {
|
||||
await clickTickets(page);
|
||||
await loading(page, listGate, listPath, /^Tickets$/);
|
||||
assertEquals(await page.locator(".orchestrator-assignment").count(), 0);
|
||||
await capture(page, `${colorScheme}-${width}-tickets-gated`, listGate);
|
||||
listGate.release();
|
||||
await rows(page);
|
||||
await capture(page, `${colorScheme}-${width}-tickets-resolved`);
|
||||
} finally {
|
||||
await listGate.dispose();
|
||||
}
|
||||
const detailGate = await responseGate(page, "**/api/w/tickets/tickets/T-2");
|
||||
try {
|
||||
await page.locator(`a.ticket-list-row[href="${detailPath}"]`).click();
|
||||
await loading(page, detailGate, detailPath, /^Ticket$/);
|
||||
assertEquals(await page.locator("a.ticket-list-row").count(), 0);
|
||||
assertEquals(
|
||||
await page.getByRole("button", { name: "Release assignment", exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
await capture(page, `${colorScheme}-${width}-detail-gated`, detailGate);
|
||||
detailGate.release();
|
||||
await page.getByRole("heading", { name: "Read-only investigation", exact: true })
|
||||
.waitFor();
|
||||
await capture(page, `${colorScheme}-${width}-detail-resolved`);
|
||||
} finally {
|
||||
await detailGate.dispose();
|
||||
}
|
||||
assertEquals(errors, []);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("immediate navigation: query and back/forward commit URLs with response gates closed", async () => {
|
||||
await withFixture(async (browser, base) => {
|
||||
const page = await browser.newPage();
|
||||
await page.goto(`${base}${listPath}`);
|
||||
await rows(page);
|
||||
const open = await responseGate(page, queryPattern);
|
||||
try {
|
||||
await search(page, "state:closed");
|
||||
await loading(page, open, `${listPath}?q=state%3Aclosed`, /^Tickets$/, true);
|
||||
open.release();
|
||||
await page.locator(".ticket-list-key").filter({ hasText: /^T-4$/ }).waitFor();
|
||||
} finally {
|
||||
await open.dispose();
|
||||
}
|
||||
for (const direction of ["back", "forward"] as const) {
|
||||
const gate = await responseGate(page, queryPattern);
|
||||
try {
|
||||
// Don't await goBack/goForward's optional load lifecycle while API is gated.
|
||||
await page.evaluate(
|
||||
(direction) => direction === "back" ? history.back() : history.forward(),
|
||||
direction,
|
||||
);
|
||||
const path = direction === "back" ? listPath : `${listPath}?q=state%3Aclosed`;
|
||||
await loading(page, gate, path, /^Tickets$/, true);
|
||||
await capture(page, `query-${direction}-gated`, gate);
|
||||
gate.release();
|
||||
await page.getByRole("searchbox", { name: "Search tickets", exact: true })
|
||||
.waitFor();
|
||||
await page.waitForFunction(
|
||||
(value) =>
|
||||
(document.querySelector('input[type="search"]') as HTMLInputElement)?.value === value,
|
||||
direction === "back" ? "state:open" : "state:closed",
|
||||
);
|
||||
await rows(page);
|
||||
if (direction === "forward") {
|
||||
assertEquals(await page.locator(".ticket-list-key").allTextContents(), ["T-4"]);
|
||||
}
|
||||
} finally {
|
||||
await gate.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("immediate navigation: rapid A -> B rejects late A data and stale canonical redirects", async () => {
|
||||
await withFixture(async (browser, base) => {
|
||||
const page = await browser.newPage();
|
||||
await page.goto(`${base}${listPath}`);
|
||||
await rows(page);
|
||||
// First canonical A (late data), then an obsolete slug A (late canonical redirect).
|
||||
for (const pathA of [detailPath, "/w/tickets/tickets/T-2-obsolete-title"]) {
|
||||
const gateA = await responseGate(page, "**/api/w/tickets/tickets/T-2");
|
||||
const gateB = await responseGate(page, "**/api/w/tickets/tickets/T-3");
|
||||
try {
|
||||
await navigate(page, pathA);
|
||||
await loading(page, gateA, pathA, /^Ticket$/);
|
||||
await navigate(page, otherDetailPath);
|
||||
await loading(page, gateB, otherDetailPath, /^Ticket$/);
|
||||
gateA.assertClosed();
|
||||
gateB.release();
|
||||
await page.getByRole("heading", { name: "Independent review before merge", exact: true })
|
||||
.waitFor();
|
||||
const response = page.waitForResponse((response) =>
|
||||
new URL(response.url()).pathname === "/api/w/tickets/tickets/T-2"
|
||||
);
|
||||
gateA.release();
|
||||
await response;
|
||||
await gateA.delivered();
|
||||
await paint(page);
|
||||
assertEquals(new URL(page.url()).pathname, otherDetailPath);
|
||||
assert(
|
||||
await page.getByRole("heading", {
|
||||
name: "Independent review before merge",
|
||||
exact: true,
|
||||
}).isVisible(),
|
||||
);
|
||||
assertEquals(
|
||||
await page.getByRole("heading", { name: "Read-only investigation", exact: true })
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
await capture(
|
||||
page,
|
||||
pathA === detailPath
|
||||
? "late-old-response-resolved"
|
||||
: "stale-canonical-redirect-resolved",
|
||||
);
|
||||
} finally {
|
||||
await gateA.dispose();
|
||||
await gateB.dispose();
|
||||
}
|
||||
await navigate(page, listPath);
|
||||
await rows(page);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("immediate navigation: slow workspace switch clears old identity and permission projection", async () => {
|
||||
await withFixture(async (browser, base) => {
|
||||
const page = await browser.newPage();
|
||||
// Same existing fixture server, distinct identities and explicit opposite permissions.
|
||||
await page.route("**/api/w/*/workspace", async (route) => {
|
||||
const response = await route.fetch();
|
||||
const body = await response.json();
|
||||
const old = body.workspace_id === "tickets";
|
||||
body.display_name = old ? "Old privileged workspace" : "New read-only workspace";
|
||||
body.permissions = {
|
||||
manage_repositories: old,
|
||||
manage_secrets: old,
|
||||
manage_runtimes: old,
|
||||
delete_workspace: old,
|
||||
};
|
||||
await route.fulfill({ response, json: body });
|
||||
});
|
||||
await page.goto(`${base}${detailPath}`);
|
||||
await page.getByRole("heading", { name: "Read-only investigation", exact: true }).waitFor();
|
||||
await page.getByText("Old privileged workspace", { exact: true }).first().waitFor();
|
||||
assert(
|
||||
await page.getByRole("button", { name: "Release assignment", exact: true }).isVisible(),
|
||||
);
|
||||
// route.fetch bypasses other Playwright handlers: transform this held response too.
|
||||
const gate = await responseGate(
|
||||
page,
|
||||
"**/api/w/tickets-readonly/workspace",
|
||||
() => true,
|
||||
undefined,
|
||||
(body) => ({ ...body, display_name: "New read-only workspace" }),
|
||||
);
|
||||
const target = detailPath.replace("/w/tickets/", "/w/tickets-readonly/");
|
||||
try {
|
||||
await navigate(page, target);
|
||||
await loading(page, gate, target, /^Workspace$/);
|
||||
assertEquals(await page.getByText("Old privileged workspace", { exact: true }).count(), 0);
|
||||
assertEquals(
|
||||
await page.getByRole("button", { name: "Release assignment", exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
assertEquals(
|
||||
await page.locator('a[href="/w/tickets/settings"], a[href="/w/tickets"]').count(),
|
||||
0,
|
||||
);
|
||||
await capture(page, "workspace-switch-gated", gate);
|
||||
gate.release();
|
||||
await page.getByRole("heading", { name: "Read-only investigation", exact: true }).waitFor();
|
||||
await page.getByText("New read-only workspace", { exact: true }).first().waitFor();
|
||||
assertEquals(
|
||||
await page.getByRole("button", { name: "Release assignment", exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
await capture(page, "workspace-switch-resolved");
|
||||
} finally {
|
||||
await gate.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("immediate navigation: cold load and unrelated global catalog response cannot block destination", async () => {
|
||||
await withFixture(async (browser, base) => {
|
||||
const page = await browser.newPage();
|
||||
const catalog = await responseGate(page, /\/api\/workspaces(?:\?|$)/);
|
||||
const tickets = await responseGate(page, queryPattern);
|
||||
try {
|
||||
await page.goto(`${base}${listPath}`, { waitUntil: "domcontentloaded" });
|
||||
await catalog.held();
|
||||
await loading(page, tickets, listPath, /^Tickets$/);
|
||||
catalog.assertClosed();
|
||||
await capture(page, "cold-load-gated", tickets);
|
||||
tickets.release();
|
||||
await rows(page);
|
||||
// Global catalog remains unavailable while the page is fully usable.
|
||||
catalog.assertClosed();
|
||||
const detail = await responseGate(page, "**/api/w/tickets/tickets/T-2");
|
||||
try {
|
||||
await page.locator(`a.ticket-list-row[href="${detailPath}"]`).click();
|
||||
await loading(page, detail, detailPath, /^Ticket$/);
|
||||
catalog.assertClosed();
|
||||
detail.release();
|
||||
await page.getByRole("heading", { name: "Read-only investigation", exact: true })
|
||||
.waitFor();
|
||||
catalog.assertClosed();
|
||||
await capture(page, "global-catalog-delayed-detail-resolved");
|
||||
} finally {
|
||||
await detail.dispose();
|
||||
}
|
||||
catalog.release();
|
||||
} finally {
|
||||
await catalog.dispose();
|
||||
await tickets.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("immediate navigation: failed read stays at destination and gated Retry recovers", async () => {
|
||||
await withFixture(async (browser, base) => {
|
||||
const page = await browser.newPage();
|
||||
await page.goto(`${base}/w/tickets/orchestrator`);
|
||||
await page.locator(".orchestrator-assignment").first().waitFor();
|
||||
const failure = await responseGate(page, queryPattern, () => true, {
|
||||
status: 503,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "Navigation fixture unavailable" }),
|
||||
});
|
||||
try {
|
||||
await clickTickets(page);
|
||||
await loading(page, failure, listPath, /^Tickets$/);
|
||||
failure.release();
|
||||
await page.getByRole("alert").filter({
|
||||
hasText: /Ticket Browser API request failed with HTTP 503/,
|
||||
})
|
||||
.waitFor();
|
||||
assertEquals(new URL(page.url()).pathname, listPath);
|
||||
assertEquals(
|
||||
await page.getByText("No tickets match this query.", { exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
await capture(page, "read-error");
|
||||
} finally {
|
||||
await failure.dispose();
|
||||
}
|
||||
const retry = await responseGate(page, queryPattern);
|
||||
try {
|
||||
await page.getByRole("button", { name: "Retry", exact: true }).click();
|
||||
// Product Retry performs a local query read, not SvelteKit invalidation.
|
||||
// Its polite live-region status must be visible while that response stays CLOSED.
|
||||
await retry.held();
|
||||
retry.assertClosed();
|
||||
assertEquals(new URL(page.url()).pathname, listPath);
|
||||
await page.getByRole("heading", { name: "Tickets", level: 1, exact: true }).waitFor();
|
||||
await page.locator('.ticket-list-page-state[aria-live="polite"]')
|
||||
.filter({ hasText: /^Loading…$/ }).waitFor();
|
||||
retry.assertClosed();
|
||||
await capture(page, "read-retry-gated", retry);
|
||||
retry.release();
|
||||
await rows(page);
|
||||
assertEquals(await page.getByRole("alert").count(), 0);
|
||||
await capture(page, "read-retry-resolved");
|
||||
} finally {
|
||||
await retry.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@
|
||||
"web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts",
|
||||
"check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts",
|
||||
"test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests",
|
||||
"test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/ticket_shell_test.ts browser-tests/ticket_principal_test.ts browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/workdir_removal_shell_test.ts browser-tests/console_pending_shell_test.ts browser-tests/console_live_shell_test.ts browser-tests/console_live_admission_shell_test.ts"
|
||||
"test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/deferred_navigation_test.ts browser-tests/ticket_shell_test.ts browser-tests/ticket_principal_test.ts browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/workdir_removal_shell_test.ts browser-tests/console_pending_shell_test.ts browser-tests/console_live_shell_test.ts browser-tests/console_live_admission_shell_test.ts"
|
||||
},
|
||||
"fmt": {
|
||||
"lineWidth": 100,
|
||||
|
||||
Reference in New Issue
Block a user