mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
* 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.
746 lines
28 KiB
TypeScript
746 lines
28 KiB
TypeScript
import { tmpdir } from "node:os";
|
|
import { randomUUID } from "node:crypto";
|
|
import path from "node:path";
|
|
import type { Page } from "@playwright/test";
|
|
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
|
|
import { expect, test } from "../support/fixtures";
|
|
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
|
import { addConnectedHostAndReload } from "../support/helpers/hosts";
|
|
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
|
import { openSettingsSection } from "../support/helpers/settings";
|
|
import { getServerId } from "../support/helpers/server-id";
|
|
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
|
|
import {
|
|
installUsageReportsFixture,
|
|
type UsageListRequest,
|
|
type UsageReportsFixture,
|
|
} from "../support/helpers/usage-reports";
|
|
import {
|
|
installLoginUsage,
|
|
expectLoginUsageFreshness,
|
|
installCodexWindowUsage,
|
|
expectCodexReportedWindows,
|
|
refreshLoginUsage,
|
|
hoverUsageWindow,
|
|
} from "../support/helpers/usage-login";
|
|
import {
|
|
claudeAndCodexReports,
|
|
expectPinnedUsage,
|
|
openCompactSidebar,
|
|
openUsageFromIcon,
|
|
openUsageFromItem,
|
|
pinRow,
|
|
openUsageOptions,
|
|
closeUsageOptions,
|
|
expectUsageOptionsFitContent,
|
|
showUsageAs,
|
|
refreshAllUsage,
|
|
togglePin,
|
|
usageItem,
|
|
} from "../support/helpers/usage-sidebar-item";
|
|
|
|
const emptyHome = path.join(tmpdir(), `paseo-usage-empty-${randomUUID()}`);
|
|
test.use({
|
|
e2eDaemonEnvironment: {
|
|
HOME: emptyHome,
|
|
USERPROFILE: emptyHome,
|
|
CODEX_HOME: emptyHome,
|
|
CLAUDE_CONFIG_DIR: emptyHome,
|
|
XDG_DATA_HOME: emptyHome,
|
|
PI_CODING_AGENT_DIR: emptyHome,
|
|
COPILOT_TOKEN: "",
|
|
GITHUB_TOKEN: "",
|
|
GITHUB_PAT: "",
|
|
CURSOR_ACCESS_TOKEN: "",
|
|
CURSOR_TOKEN: "",
|
|
GROK_API_KEY: "",
|
|
GROK_TOKEN: "",
|
|
KIMI_TOKEN: "",
|
|
KIMI_API_KEY: "",
|
|
KIMI_CODE_HOME: emptyHome,
|
|
MINIMAX_API_KEY: "",
|
|
ZAI_API_KEY: "",
|
|
GLM_API_KEY: "",
|
|
},
|
|
});
|
|
|
|
// These tests read the sidebar summary and pin windows, which both need the Usage item on.
|
|
test.beforeEach(async ({ page }) => {
|
|
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
|
|
});
|
|
|
|
function forcedRefreshes(usage: UsageReportsFixture): UsageListRequest[] {
|
|
return usage.listRequests().filter((request) => request.forceRefresh);
|
|
}
|
|
|
|
// Two hours reads "2h ago" for an hour, so the assertion cannot race the clock.
|
|
function twoHoursAgo(): string {
|
|
return new Date(Date.now() - 2 * 60 * 60_000).toISOString();
|
|
}
|
|
|
|
/** Set PASEO_QA_SCREENSHOT_DIR to keep a QA screenshot. */
|
|
async function qaScreenshot(page: Page, name: string) {
|
|
const directory = process.env.PASEO_QA_SCREENSHOT_DIR;
|
|
if (!directory) return;
|
|
await page.waitForTimeout(600);
|
|
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
|
|
await page.screenshot({ path: path.join(directory, `${name}.png`) });
|
|
}
|
|
|
|
function hostFilter(page: Page) {
|
|
return page.locator('[data-testid="usage-host-filter-trigger"]:visible');
|
|
}
|
|
|
|
function weeklyReport(sourceId: string, usedPct: number): UsageReportEntry {
|
|
return {
|
|
id: `${sourceId}:a`,
|
|
account: {},
|
|
fetchedAt: twoHoursAgo(),
|
|
sourceId,
|
|
sourceLabel: `${sourceId} plan`,
|
|
report: { status: "available", windows: [{ id: "weekly", label: "Weekly", usedPct }] },
|
|
};
|
|
}
|
|
|
|
function installReversedUsageRefresh(page: Page) {
|
|
const initial = [weeklyReport("opencode", 31), weeklyReport("openai", 12)];
|
|
const refreshed = [weeklyReport("openai", 22), weeklyReport("opencode", 41)];
|
|
return installUsageReportsFixture(page, {
|
|
lists: [(request) => (request.forceRefresh ? refreshed : initial)],
|
|
});
|
|
}
|
|
|
|
async function openUsageSummaryAtWidth(page: Page, width: number) {
|
|
await page.setViewportSize({ width, height: 900 });
|
|
await gotoAppShell(page);
|
|
if (width === 390) await openCompactSidebar(page);
|
|
await expectPinnedUsage(page, ["31% Weekly", "12% Weekly"]);
|
|
await openUsageFromItem(page);
|
|
}
|
|
|
|
async function expectUsageProviderOrder(page: Page, summary: string[]) {
|
|
const group = page.getByTestId(`usage-host-${getServerId()}`);
|
|
await expect(group.getByText(/^(opencode|openai) plan$/)).toHaveText([
|
|
"opencode plan",
|
|
"openai plan",
|
|
]);
|
|
await expectPinnedUsage(page, summary);
|
|
}
|
|
|
|
async function refreshUsageAndWaitForReports(page: Page, usage: UsageReportsFixture) {
|
|
const requestCount = usage.listRequests().length;
|
|
await refreshAllUsage(page);
|
|
await usage.waitForListRequests(requestCount + 1);
|
|
const group = page.getByTestId(`usage-host-${getServerId()}`);
|
|
await expect(group.getByText("41% used", { exact: true })).toBeVisible();
|
|
await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toBeEnabled();
|
|
}
|
|
|
|
test.describe("usage modal", () => {
|
|
test("opens from the sidebar on the host's reports, with no host filter for one host", async ({
|
|
page,
|
|
}) => {
|
|
test.setTimeout(120_000);
|
|
const serverId = getServerId();
|
|
const usage = await installUsageReportsFixture(page, {
|
|
lists: [
|
|
[
|
|
{
|
|
id: "alpha:a",
|
|
account: {},
|
|
fetchedAt: twoHoursAgo(),
|
|
sourceId: "alpha",
|
|
sourceLabel: "Alpha plan",
|
|
report: {
|
|
status: "available",
|
|
windows: [{ id: "weekly", label: "Weekly", usedPct: 31 }],
|
|
},
|
|
},
|
|
{
|
|
id: "beta:b",
|
|
account: {},
|
|
fetchedAt: "2026-01-01T00:00:00.000Z",
|
|
sourceId: "beta",
|
|
sourceLabel: "Beta plan",
|
|
report: {
|
|
status: "unavailable",
|
|
problem: { kind: "no_quota", detail: "No active coding plan" },
|
|
},
|
|
},
|
|
],
|
|
],
|
|
});
|
|
|
|
await gotoAppShell(page);
|
|
await openUsageFromItem(page);
|
|
await usage.waitForListRequests(1);
|
|
|
|
const group = page.getByTestId(`usage-host-${serverId}`);
|
|
await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
|
|
await expect(hostFilter(page)).toHaveCount(0);
|
|
await qaScreenshot(page, "usage-modal-one-host");
|
|
await expect(group.getByText("31% used")).toBeVisible();
|
|
await expect(group.getByText("Beta plan", { exact: true })).toBeVisible();
|
|
await expect(group.getByText("Unavailable", { exact: true })).toBeVisible();
|
|
|
|
await group.getByTestId("usage-refresh").first().hover();
|
|
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago");
|
|
await expect(group.getByTestId("usage-freshness")).toHaveCount(0);
|
|
});
|
|
|
|
for (const width of [1440, 390]) {
|
|
test(`keeps sidebar and modal provider order across a reversed refresh at width ${width}`, async ({
|
|
page,
|
|
}) => {
|
|
const usage = await installReversedUsageRefresh(page);
|
|
await openUsageSummaryAtWidth(page, width);
|
|
await expectUsageProviderOrder(page, ["31% Weekly", "12% Weekly"]);
|
|
await qaScreenshot(page, `usage-order-${width}-before-refresh`);
|
|
|
|
await refreshUsageAndWaitForReports(page, usage);
|
|
await expectUsageProviderOrder(page, ["41% Weekly", "22% Weekly"]);
|
|
await qaScreenshot(page, `usage-order-${width}-after-refresh`);
|
|
});
|
|
}
|
|
|
|
test("refreshes one report from its card", async ({ page }) => {
|
|
test.setTimeout(120_000);
|
|
const serverId = getServerId();
|
|
const beta: UsageReportEntry = {
|
|
id: "beta:b",
|
|
account: {},
|
|
fetchedAt: twoHoursAgo(),
|
|
sourceId: "beta",
|
|
sourceLabel: "Beta plan",
|
|
report: {
|
|
status: "available",
|
|
windows: [{ id: "weekly", label: "Weekly", usedPct: 12 }],
|
|
},
|
|
};
|
|
const alpha = (usedPct: number, fetchedAt: string): UsageReportEntry => ({
|
|
id: "alpha:a",
|
|
account: {},
|
|
fetchedAt,
|
|
sourceId: "alpha",
|
|
sourceLabel: "Alpha plan",
|
|
report: {
|
|
status: "available",
|
|
windows: [{ id: "weekly", label: "Weekly", usedPct }],
|
|
},
|
|
});
|
|
// The sidebar summary and the modal each load reports; only a card's Refresh forces one.
|
|
const usage = await installUsageReportsFixture(page, {
|
|
lists: [
|
|
(request) =>
|
|
request.forceRefresh
|
|
? [alpha(58, new Date().toISOString())]
|
|
: [alpha(31, twoHoursAgo()), beta],
|
|
],
|
|
});
|
|
|
|
await gotoAppShell(page);
|
|
await openUsageFromItem(page);
|
|
const group = page.getByTestId(`usage-host-${serverId}`);
|
|
await expect(group.getByText("31% used")).toBeVisible({ timeout: 10_000 });
|
|
|
|
const alphaRefresh = group.getByTestId("usage-refresh").first();
|
|
await alphaRefresh.click();
|
|
await expect
|
|
.poll(() => forcedRefreshes(usage))
|
|
.toEqual([{ forceRefresh: true, reportIds: ["alpha:a"] }]);
|
|
await expect(group.getByText("58% used")).toBeVisible();
|
|
await expect(group.getByText("12% used")).toBeVisible();
|
|
|
|
await group.getByTestId("usage-refresh").nth(1).hover();
|
|
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago");
|
|
await alphaRefresh.hover();
|
|
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated just now");
|
|
});
|
|
|
|
test("shows the host once it connects after a cold load on a phone", async ({ page }) => {
|
|
test.setTimeout(120_000);
|
|
const serverId = getServerId();
|
|
const usage = await installUsageReportsFixture(page, {
|
|
lists: [
|
|
[
|
|
{
|
|
id: "alpha:a",
|
|
account: {},
|
|
fetchedAt: twoHoursAgo(),
|
|
sourceId: "alpha",
|
|
sourceLabel: "Alpha plan",
|
|
report: { status: "available", windows: [] },
|
|
},
|
|
],
|
|
],
|
|
});
|
|
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await gotoAppShell(page);
|
|
await openCompactSidebar(page);
|
|
await openUsageFromIcon(page);
|
|
await usage.waitForListRequests(1);
|
|
|
|
const group = page.getByTestId(`usage-host-${serverId}`);
|
|
await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
|
|
await expect(group.getByTestId("usage-freshness")).toHaveText("Updated 2h ago");
|
|
});
|
|
|
|
test("tells the user to update a host without usage support", async ({ page }) => {
|
|
test.setTimeout(120_000);
|
|
const serverId = getServerId();
|
|
const usage = await installUsageReportsFixture(page, { usageSupported: false });
|
|
|
|
await gotoAppShell(page);
|
|
// Without reports the footer has no Usage item; its Usage icon opens Usage.
|
|
await openUsageFromIcon(page);
|
|
|
|
await expect(
|
|
page.getByTestId(`usage-host-${serverId}`).getByText(/^Update .+ to see usage$/),
|
|
).toBeVisible({ timeout: 10_000 });
|
|
await qaScreenshot(page, "phase7-usage-update-host");
|
|
await expect(page.getByTestId("usage-options-menu")).toHaveCount(0);
|
|
await expect(page.getByTestId("usage-refresh-all")).toHaveCount(0);
|
|
expect(usage.listRequests()).toHaveLength(0);
|
|
});
|
|
|
|
test("a host picked in the Usage modal is the sidebar's host too, after a reload", async ({
|
|
page,
|
|
}) => {
|
|
test.setTimeout(420_000);
|
|
const primaryServerId = getServerId();
|
|
const secondary = await startIsolatedHostDaemon(
|
|
`srv_usage_${randomUUID().replaceAll("-", "").slice(0, 12)}`,
|
|
);
|
|
try {
|
|
await installUsageReportsFixture(page, { lists: [[weeklyReport("alpha", 31)]] });
|
|
await installUsageReportsFixture(page, {
|
|
port: secondary.port,
|
|
lists: [[weeklyReport("beta", 12)]],
|
|
});
|
|
await gotoAppShell(page);
|
|
await addConnectedHostAndReload(page, {
|
|
serverId: secondary.serverId,
|
|
label: "Secondary box",
|
|
port: secondary.port,
|
|
});
|
|
|
|
// Nothing picked and no workspace open: the first host.
|
|
await expectPinnedUsage(page, ["31% Weekly"]);
|
|
await openUsageFromItem(page);
|
|
await expect(page.getByTestId(`usage-host-${primaryServerId}`)).toBeVisible();
|
|
|
|
await hostFilter(page).click();
|
|
await page.getByTestId(`usage-host-filter-item-${secondary.serverId}`).click();
|
|
await expect(
|
|
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12% used"),
|
|
).toBeVisible({ timeout: 30_000 });
|
|
await expect(hostFilter(page)).toContainText("Secondary box");
|
|
await qaScreenshot(page, "usage-modal-picked-host");
|
|
await expectPinnedUsage(page, ["12% Weekly"]);
|
|
|
|
// The e2e seed resets the host list on every load, so reopening re-adds the second host.
|
|
await addConnectedHostAndReload(page, {
|
|
serverId: secondary.serverId,
|
|
label: "Secondary box",
|
|
port: secondary.port,
|
|
});
|
|
await expectPinnedUsage(page, ["12% Weekly"]);
|
|
await openUsageFromItem(page);
|
|
await expect(
|
|
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12% used"),
|
|
).toBeVisible({ timeout: 30_000 });
|
|
} finally {
|
|
await secondary.close().catch(() => undefined);
|
|
}
|
|
});
|
|
});
|
|
|
|
const loginWindows: UsageReportEntry["report"] = {
|
|
status: "available",
|
|
windows: [
|
|
{ id: "five_hour", label: "Session", shortLabel: "5h", summary: true, usedPct: 31 },
|
|
{ id: "weekly", label: "Weekly", shortLabel: "wk", summary: true, usedPct: 54 },
|
|
],
|
|
};
|
|
|
|
test("expired login refreshes to windows with visible pin toggles", async ({ page }) => {
|
|
const fixture = await installLoginUsage({
|
|
status: "unavailable",
|
|
problem: {
|
|
kind: "expired",
|
|
expiresAt: new Date(Date.now() - 3_600_000).toISOString(),
|
|
refreshedBy: "claude",
|
|
},
|
|
});
|
|
try {
|
|
await gotoAppShell(page);
|
|
await openUsageFromIcon(page);
|
|
await expect(
|
|
page.getByText("Claude: Login expired 1h ago. Run claude to refresh it.", { exact: true }),
|
|
).toBeVisible();
|
|
await expect(page.getByText("Unavailable", { exact: true })).toBeVisible();
|
|
await qaScreenshot(page, "usage-expired-login");
|
|
await fixture.setReport(loginWindows);
|
|
await refreshLoginUsage(page);
|
|
await expect(
|
|
page.getByText("Claude: Login expired 1h ago. Run claude to refresh it.", { exact: true }),
|
|
).toHaveCount(0);
|
|
await expectPinnedUsage(page, ["31% 5h", "54% wk"]);
|
|
const row = page.getByRole("checkbox", { name: /^Pin Claude Weekly, / });
|
|
await expect(row).toBeChecked();
|
|
await expect(row.getByTestId("usage-pin-glyph-pinned")).toHaveCSS("opacity", "1");
|
|
await hoverUsageWindow(page, "Weekly");
|
|
await expect(page.getByText("Unpin", { exact: true })).toBeVisible();
|
|
await expect(row.getByTestId("usage-pin-glyph-pinned")).toHaveCSS("opacity", "1");
|
|
await expect(row.getByTestId("usage-pin-glyph-pinned").locator("svg")).not.toHaveAttribute(
|
|
"fill",
|
|
"none",
|
|
);
|
|
await qaScreenshot(page, "usage-pin-hover");
|
|
await togglePin(page.getByTestId("usage-report-login-journey:account"), "Claude", "Weekly");
|
|
await expect(row.getByTestId("usage-pin-glyph-unpinned")).toHaveCSS("opacity", "1");
|
|
await expect(row.getByTestId("usage-pin-glyph-unpinned").locator("svg")).toHaveAttribute(
|
|
"fill",
|
|
"none",
|
|
);
|
|
await hoverUsageWindow(page, "Weekly");
|
|
await expect(page.getByText("Pin", { exact: true })).toBeVisible();
|
|
await expectPinnedUsage(page, ["31% 5h"]);
|
|
await qaScreenshot(page, "usage-pin-unselected");
|
|
await togglePin(page.getByTestId("usage-report-login-journey:account"), "Claude", "Weekly");
|
|
await expect(row.getByTestId("usage-pin-glyph-pinned")).toHaveCSS("opacity", "1");
|
|
await expectPinnedUsage(page, ["31% 5h", "54% wk"]);
|
|
} finally {
|
|
await fixture.cleanup();
|
|
}
|
|
});
|
|
|
|
test("compact usage rows always show the pin glyph", async ({ page }) => {
|
|
const fixture = await installLoginUsage(loginWindows);
|
|
try {
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await gotoAppShell(page);
|
|
await openCompactSidebar(page);
|
|
await openUsageFromIcon(page);
|
|
const card = page.getByTestId("usage-report-login-journey:account");
|
|
await expect(card.getByTestId("usage-pin-glyph-pinned")).toHaveCount(2);
|
|
await expect(card.getByTestId("usage-pin-glyph-pinned").nth(0)).toHaveCSS("opacity", "1");
|
|
await expect(card.getByTestId("usage-pin-glyph-pinned").nth(1)).toHaveCSS("opacity", "1");
|
|
await qaScreenshot(page, "usage-pin-compact");
|
|
} finally {
|
|
await fixture.cleanup();
|
|
}
|
|
});
|
|
|
|
for (const shape of ["seven-day-only", "Spark"] as const) {
|
|
test(`Codex ${shape} windows use provider durations on the card and sidebar`, async ({
|
|
page,
|
|
}) => {
|
|
const reset = Math.floor(Date.now() / 1000) + 5 * 86400;
|
|
const source = await installCodexWindowUsage({
|
|
rate_limit: {
|
|
primary_window: { used_percent: 11, limit_window_seconds: 604800, reset_at: reset },
|
|
secondary_window: null,
|
|
},
|
|
additional_rate_limits:
|
|
shape === "Spark"
|
|
? [
|
|
{
|
|
limit_name: "GPT-5.3-Codex-Spark",
|
|
metered_feature: "codex_bengalfox",
|
|
rate_limit: {
|
|
primary_window: {
|
|
used_percent: 0,
|
|
limit_window_seconds: 18000,
|
|
reset_at: Math.floor(Date.now() / 1000) + 3600,
|
|
},
|
|
secondary_window: {
|
|
used_percent: 0,
|
|
limit_window_seconds: 604800,
|
|
reset_at: reset,
|
|
},
|
|
},
|
|
},
|
|
]
|
|
: [],
|
|
});
|
|
try {
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await gotoAppShell(page);
|
|
await expect(usageItem(page)).toBeVisible();
|
|
await openUsageFromItem(page);
|
|
await qaScreenshot(page, `codex-${shape}-card-and-summary`);
|
|
await expectCodexReportedWindows(page, shape);
|
|
await qaScreenshot(page, `codex-${shape}-pins`);
|
|
} finally {
|
|
await source.cleanup();
|
|
}
|
|
});
|
|
}
|
|
|
|
for (const theme of ["light", "dark"] as const) {
|
|
for (const [size, viewport] of Object.entries({
|
|
desktop: { width: 1440, height: 900 },
|
|
compact: { width: 390, height: 844 },
|
|
})) {
|
|
test(`Usage Settings cog ${size} ${theme}`, async ({ page }, testInfo) => {
|
|
test.setTimeout(120_000);
|
|
await page.addInitScript((value) => {
|
|
const key = "@paseo:app-settings";
|
|
const current = JSON.parse(localStorage.getItem(key) ?? "{}");
|
|
localStorage.setItem(key, JSON.stringify({ ...current, theme: value }));
|
|
}, theme);
|
|
const usage = await installUsageReportsFixture(page, {
|
|
lists: [() => claudeAndCodexReports()],
|
|
});
|
|
await page.setViewportSize(viewport);
|
|
await gotoAppShell(page);
|
|
if (size === "compact") await openCompactSidebar(page);
|
|
await openUsageFromIcon(page);
|
|
const screen = page.getByTestId(`usage-host-${getServerId()}`);
|
|
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 30_000 });
|
|
const settings = page.getByTestId("usage-options-menu");
|
|
await expect(settings).toHaveAccessibleName("Settings");
|
|
await expect(page.getByTestId("usage-display-used")).toHaveCount(0);
|
|
await captureSettingsState(page, testInfo, `${size}-${theme}-cog`);
|
|
|
|
await openUsageOptions(page);
|
|
await expectUsageOptionsFitContent(page);
|
|
await expect(page.getByText("Pinned windows show in the sidebar footer")).toBeVisible();
|
|
await expect(page.getByTestId("usage-display-used")).toHaveAttribute("aria-selected", "true");
|
|
await captureSettingsState(page, testInfo, `${size}-${theme}-settings`);
|
|
await showUsageAs(page, "remaining");
|
|
await expect(screen.getByText("69% left")).toBeVisible();
|
|
await showUsageAs(page, "used");
|
|
await expect(pinRow(screen, "Claude", "Session")).toHaveAccessibleName(
|
|
/^Pin Claude Session, 31% used · resets /,
|
|
);
|
|
await openUsageOptions(page);
|
|
await closeUsageOptions(page);
|
|
await refreshAllUsage(page);
|
|
await expect.poll(() => forcedRefreshes(usage)).toEqual([{ forceRefresh: true }]);
|
|
});
|
|
}
|
|
}
|
|
|
|
async function captureSettingsState(
|
|
page: Page,
|
|
testInfo: import("@playwright/test").TestInfo,
|
|
name: string,
|
|
) {
|
|
await qaScreenshot(page, name);
|
|
await testInfo.attach(name, { body: await page.screenshot(), contentType: "image/png" });
|
|
}
|
|
|
|
test("an account with all logins failing shows each harness and remedy, then only usage when one succeeds", async ({
|
|
page,
|
|
}) => {
|
|
const fixture = await installLoginUsage([
|
|
{ harness: "Codex", report: { status: "error", error: "Usage API returned 500. Try again." } },
|
|
{
|
|
harness: "OpenCode",
|
|
report: {
|
|
status: "unavailable",
|
|
problem: { kind: "rejected", status: 401, refreshedBy: "opencode" },
|
|
},
|
|
},
|
|
{
|
|
harness: "Pi",
|
|
report: { status: "unavailable", problem: { kind: "no_quota", detail: "No active plan" } },
|
|
},
|
|
{
|
|
harness: "OMP",
|
|
report: {
|
|
status: "unavailable",
|
|
problem: { kind: "rejected", status: 403, refreshedBy: "omp" },
|
|
},
|
|
},
|
|
]);
|
|
try {
|
|
await gotoAppShell(page);
|
|
await openUsageFromIcon(page);
|
|
await expectLoginErrors(page, [
|
|
"Codex: Usage API returned 500. Try again.",
|
|
"OpenCode: Login rejected (HTTP 401). Run opencode to refresh it.",
|
|
"Pi: No active plan",
|
|
"OMP: Login rejected (HTTP 403). Run omp to refresh it.",
|
|
]);
|
|
await fixture.setReport([
|
|
{
|
|
harness: "Codex",
|
|
report: { status: "error", error: "Usage API returned 500. Try again." },
|
|
},
|
|
{
|
|
harness: "OpenCode",
|
|
report: { status: "available", windows: [{ id: "weekly", label: "Weekly", usedPct: 42 }] },
|
|
},
|
|
]);
|
|
await refreshLoginUsage(page);
|
|
await expectLoginUsageOnly(page, "42% used");
|
|
} finally {
|
|
await fixture.cleanup();
|
|
}
|
|
});
|
|
|
|
async function expectLoginErrors(page: Page, lines: string[]) {
|
|
const card = page.getByTestId("usage-report-login-journey:account");
|
|
await expect(card.getByTestId("usage-login-error")).toHaveText(lines.join("\n"));
|
|
}
|
|
|
|
async function expectLoginUsageOnly(page: Page, percentage: string) {
|
|
const card = page.getByTestId("usage-report-login-journey:account");
|
|
await expect(card.getByText(new RegExp(`^${percentage}(?:$| ·)`))).toBeVisible();
|
|
await expect(card.getByTestId("usage-login-error")).toHaveCount(0);
|
|
}
|
|
|
|
for (const compact of [false, true]) {
|
|
test(`failed polls retain usage and successful freshness (${compact ? "footer" : "tooltip"})`, async ({
|
|
page,
|
|
}) => {
|
|
const fixture = await installLoginUsage({ status: "error", error: "Initial failure" });
|
|
try {
|
|
await page.clock.install();
|
|
await page.setViewportSize(
|
|
compact ? { width: 390, height: 844 } : { width: 1440, height: 900 },
|
|
);
|
|
await gotoAppShell(page);
|
|
if (compact) await openCompactSidebar(page);
|
|
await openUsageFromIcon(page);
|
|
await expectLoginErrors(page, ["Claude: Initial failure"]);
|
|
await test.step("developer errors do not interrupt freshness inspection", async () => {
|
|
await page.evaluate(() => console.error("E2E: developer toast while Usage is open"));
|
|
await expectLoginUsageFreshness(page, "No successful update", compact);
|
|
});
|
|
|
|
await fixture.setReport({
|
|
status: "available",
|
|
windows: [{ id: "weekly", label: "Weekly", usedPct: 42, resetsAt: "2000-01-01T00:00:00Z" }],
|
|
});
|
|
// Move only the browser clock; real daemon fetches continue using the host clock.
|
|
await page.clock.setFixedTime(new Date(Date.now() + 3_900_000));
|
|
await refreshLoginUsage(page);
|
|
await expectLoginUsageOnly(page, "42% used");
|
|
await advanceUsageClock(page, 60_000);
|
|
await expectLoginUsageFreshness(page, "Updated 1h ago", compact);
|
|
|
|
await fixture.setReport({ status: "error", error: "Rate limited" });
|
|
await refreshLoginUsage(page);
|
|
await expectRetainedLoginUsage(page, "Claude: Rate limited");
|
|
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
|
|
await refreshLoginUsage(page);
|
|
await expectRetainedLoginUsage(page, "Claude: Rate limited");
|
|
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
|
|
|
|
await fixture.setReport({
|
|
status: "unavailable",
|
|
problem: { kind: "no_quota", detail: "No active quota" },
|
|
});
|
|
await refreshLoginUsage(page);
|
|
await expectRetainedLoginUsage(page, "Claude: No active quota");
|
|
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
|
|
|
|
await fixture.setReport({
|
|
status: "available",
|
|
windows: [{ id: "weekly", label: "Weekly", usedPct: 43 }],
|
|
});
|
|
await page.clock.setFixedTime(new Date(Date.now() + 150_000));
|
|
await refreshLoginUsage(page);
|
|
await expectLoginUsageOnly(page, "43% used");
|
|
await advanceUsageClock(page, 60_000);
|
|
await expect(page.getByText("42% used", { exact: true })).toHaveCount(0);
|
|
await expectLoginUsageFreshness(page, "Updated 2m ago", compact);
|
|
} finally {
|
|
await fixture.cleanup();
|
|
}
|
|
});
|
|
}
|
|
|
|
async function expectRetainedLoginUsage(page: Page, message: string) {
|
|
const card = page.getByTestId("usage-report-login-journey:account");
|
|
await expect(card.getByText(/^42% used(?:$| ·)/)).toBeVisible();
|
|
await expect(card.getByTestId("usage-login-error")).toHaveText(message);
|
|
await expect(card.getByText("Reset time passed", { exact: false })).toBeVisible();
|
|
}
|
|
|
|
async function switchToJapanese(page: Page) {
|
|
await page.getByRole("button", { name: "Close", exact: true }).click();
|
|
await openSettings(page);
|
|
await openSettingsSection(page, "general");
|
|
await page.getByRole("button", { name: "System", exact: true }).click();
|
|
await page.getByRole("menuitem", { name: "日本語 - Japanese", exact: true }).click();
|
|
await page.getByRole("button", { name: "戻る", exact: true }).click();
|
|
}
|
|
|
|
async function openJapaneseUsage(page: Page) {
|
|
await page.getByRole("button", { name: "使用状況", exact: true }).click();
|
|
await expect(page.getByTestId("usage-modal").first()).toBeVisible();
|
|
}
|
|
|
|
test("usage percentages state their meaning and follow the selected language", async ({ page }) => {
|
|
test.setTimeout(120_000);
|
|
const login = await installLoginUsage({
|
|
status: "available",
|
|
windows: [{ id: "session", label: "Session", usedPct: 70, summary: true }],
|
|
});
|
|
try {
|
|
await gotoAppShell(page);
|
|
await openUsageFromIcon(page);
|
|
await expect(page.getByText("70% used", { exact: true })).toBeVisible();
|
|
await switchToJapanese(page);
|
|
await expect(usageItem(page)).toHaveAccessibleName(/Claude セッション 70% 使用済み/);
|
|
await openJapaneseUsage(page);
|
|
await expect(page.getByText("70% 使用済み", { exact: true })).toBeVisible();
|
|
await expect(page.getByRole("button", { name: "すべて更新", exact: true })).toBeVisible();
|
|
await page.getByRole("button", { name: "設定", exact: true }).last().click();
|
|
await expect(page.getByText("割合", { exact: true })).toBeVisible();
|
|
await expect(page.getByText("使用済み", { exact: true })).toBeVisible();
|
|
await expect(page.getByText("残り", { exact: true })).toBeVisible();
|
|
} finally {
|
|
await login.cleanup();
|
|
}
|
|
});
|
|
|
|
test("open usage clocks advance without refreshing or reopening", async ({ page }) => {
|
|
test.setTimeout(120_000);
|
|
const now = new Date();
|
|
const reset = new Date(now.getTime() + 30.5 * 60_000).toISOString();
|
|
const login = await installLoginUsage({
|
|
status: "available",
|
|
windows: [
|
|
{ id: "session", label: "Session", usedPct: 42, resetsAt: reset },
|
|
{ id: "weekly", label: "Weekly", usedPct: 80, runsOutAt: reset, shortfallPct: 10 },
|
|
],
|
|
balances: [{ id: "credits", label: "Credits", unit: "usd", remaining: 12, resetsAt: reset }],
|
|
});
|
|
try {
|
|
await openCompactUsageWithClock(page, now);
|
|
const card = page.getByTestId("usage-report-login-journey:account");
|
|
await expect(card.getByTestId("usage-freshness")).toHaveText("Updated just now");
|
|
await expect(card.getByText("resets 30m", { exact: false }).first()).toBeVisible();
|
|
// The shared minute clock is wall-aligned, and the daemon timestamps use real time.
|
|
// After two browser minutes the latest displayed tick is one or two minutes old.
|
|
await advanceUsageClock(page, 120_000);
|
|
await expect(card.getByTestId("usage-freshness")).toHaveText(/^Updated [12]m ago$/);
|
|
await expect(card.getByText(/resets (28|29)m/).first()).toBeVisible();
|
|
await expect(card.getByText(/runs out (28|29)m/)).toBeVisible();
|
|
await expect(card.getByTestId("provider-usage-balance-credits-reset")).toHaveText(
|
|
/^resets (28|29)m$/,
|
|
);
|
|
} finally {
|
|
await login.cleanup();
|
|
}
|
|
});
|
|
|
|
async function openCompactUsageWithClock(page: Page, now: Date) {
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await page.clock.install({ time: now });
|
|
await gotoAppShell(page);
|
|
await openCompactSidebar(page);
|
|
await openUsageFromIcon(page);
|
|
}
|
|
|
|
async function advanceUsageClock(page: Page, milliseconds: number) {
|
|
await page.clock.fastForward(milliseconds);
|
|
}
|