quotacap 0.0.19 → 0.0.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -68
- package/dist/adapters/agy.d.ts +3 -0
- package/dist/adapters/agy.js +81 -0
- package/dist/adapters/claude.d.ts +7 -5
- package/dist/adapters/claude.js +6 -6
- package/dist/adapters/codex.d.ts +3 -3
- package/dist/adapters/codex.js +128 -49
- package/dist/adapters/grok.d.ts +3 -3
- package/dist/adapters/grok.js +114 -70
- package/dist/adapters/index.d.ts +5 -2
- package/dist/adapters/index.js +36 -9
- package/dist/adapters/kimi.d.ts +3 -3
- package/dist/adapters/kimi.js +64 -86
- package/dist/adapters/manual.d.ts +4 -4
- package/dist/adapters/manual.js +2 -2
- package/dist/adapters/pty.d.ts +23 -0
- package/dist/adapters/pty.js +487 -0
- package/dist/adapters/types.d.ts +7 -3
- package/dist/advisory/engine.d.ts +19 -2
- package/dist/advisory/engine.js +130 -10
- package/dist/advisory/snapshot.d.ts +16 -0
- package/dist/advisory/snapshot.js +213 -0
- package/dist/advisory/types.d.ts +46 -4
- package/dist/advisory/validate.d.ts +5 -0
- package/dist/advisory/validate.js +18 -0
- package/dist/cli/advise.d.ts +3 -0
- package/dist/cli/advise.js +62 -0
- package/dist/cli/clients.d.ts +18 -0
- package/dist/cli/clients.js +11 -0
- package/dist/cli/index.js +4 -84
- package/dist/cli/ingest.d.ts +3 -0
- package/dist/cli/ingest.js +31 -0
- package/dist/cli/runtime.d.ts +12 -0
- package/dist/cli/runtime.js +125 -0
- package/dist/cli/snapshot-source.d.ts +25 -0
- package/dist/cli/snapshot-source.js +223 -0
- package/dist/cli/status.d.ts +3 -0
- package/dist/cli/status.js +84 -0
- package/dist/config.d.ts +20 -0
- package/dist/config.js +126 -1
- package/dist/daemon.d.ts +4 -29
- package/dist/daemon.js +12 -67
- package/dist/format/markdown.d.ts +3 -0
- package/dist/format/markdown.js +24 -0
- package/dist/format/rows.d.ts +27 -0
- package/dist/format/rows.js +241 -0
- package/dist/format/terminal.d.ts +12 -0
- package/dist/format/terminal.js +144 -0
- package/dist/http/server.d.ts +24 -3
- package/dist/http/server.js +236 -106
- package/dist/mcp/server.d.ts +0 -6
- package/dist/mcp/server.js +78 -25
- package/dist/runtime/client.d.ts +24 -0
- package/dist/runtime/client.js +72 -0
- package/dist/runtime/owner.d.ts +35 -0
- package/dist/runtime/owner.js +393 -0
- package/dist/runtime/poll.d.ts +48 -0
- package/dist/runtime/poll.js +222 -0
- package/dist/runtime/service.d.ts +24 -0
- package/dist/runtime/service.js +251 -0
- package/dist/runtime/spawn.d.ts +17 -0
- package/dist/runtime/spawn.js +97 -0
- package/dist/runtime/token.d.ts +3 -0
- package/dist/runtime/token.js +64 -0
- package/dist/service/index.d.ts +4 -0
- package/dist/service/index.js +47 -0
- package/dist/service/macos.d.ts +96 -0
- package/dist/service/macos.js +551 -0
- package/dist/src/adapters/agy.d.ts +3 -0
- package/dist/src/adapters/agy.js +81 -0
- package/dist/src/adapters/claude.d.ts +7 -5
- package/dist/src/adapters/claude.js +6 -6
- package/dist/src/adapters/codex.d.ts +3 -3
- package/dist/src/adapters/codex.js +128 -49
- package/dist/src/adapters/grok.d.ts +3 -3
- package/dist/src/adapters/grok.js +114 -70
- package/dist/src/adapters/index.d.ts +5 -2
- package/dist/src/adapters/index.js +36 -9
- package/dist/src/adapters/kimi.d.ts +3 -3
- package/dist/src/adapters/kimi.js +64 -86
- package/dist/src/adapters/manual.d.ts +4 -4
- package/dist/src/adapters/manual.js +2 -2
- package/dist/src/adapters/pty.d.ts +23 -0
- package/dist/src/adapters/pty.js +487 -0
- package/dist/src/adapters/types.d.ts +7 -3
- package/dist/src/advisory/engine.d.ts +19 -2
- package/dist/src/advisory/engine.js +130 -10
- package/dist/src/advisory/snapshot.d.ts +16 -0
- package/dist/src/advisory/snapshot.js +213 -0
- package/dist/src/advisory/types.d.ts +46 -4
- package/dist/src/advisory/validate.d.ts +5 -0
- package/dist/src/advisory/validate.js +18 -0
- package/dist/src/cli/advise.d.ts +3 -0
- package/dist/src/cli/advise.js +62 -0
- package/dist/src/cli/clients.d.ts +18 -0
- package/dist/src/cli/clients.js +11 -0
- package/dist/src/cli/index.js +4 -84
- package/dist/src/cli/ingest.d.ts +3 -0
- package/dist/src/cli/ingest.js +31 -0
- package/dist/src/cli/runtime.d.ts +12 -0
- package/dist/src/cli/runtime.js +125 -0
- package/dist/src/cli/snapshot-source.d.ts +25 -0
- package/dist/src/cli/snapshot-source.js +223 -0
- package/dist/src/cli/status.d.ts +3 -0
- package/dist/src/cli/status.js +84 -0
- package/dist/src/config.d.ts +20 -0
- package/dist/src/config.js +126 -1
- package/dist/src/daemon.d.ts +4 -29
- package/dist/src/daemon.js +12 -67
- package/dist/src/format/markdown.d.ts +3 -0
- package/dist/src/format/markdown.js +24 -0
- package/dist/src/format/rows.d.ts +27 -0
- package/dist/src/format/rows.js +241 -0
- package/dist/src/format/terminal.d.ts +12 -0
- package/dist/src/format/terminal.js +144 -0
- package/dist/src/http/server.d.ts +24 -3
- package/dist/src/http/server.js +236 -106
- package/dist/src/mcp/server.d.ts +0 -6
- package/dist/src/mcp/server.js +78 -25
- package/dist/src/runtime/client.d.ts +24 -0
- package/dist/src/runtime/client.js +72 -0
- package/dist/src/runtime/owner.d.ts +35 -0
- package/dist/src/runtime/owner.js +393 -0
- package/dist/src/runtime/poll.d.ts +48 -0
- package/dist/src/runtime/poll.js +222 -0
- package/dist/src/runtime/service.d.ts +24 -0
- package/dist/src/runtime/service.js +251 -0
- package/dist/src/runtime/spawn.d.ts +17 -0
- package/dist/src/runtime/spawn.js +97 -0
- package/dist/src/runtime/token.d.ts +3 -0
- package/dist/src/runtime/token.js +64 -0
- package/dist/src/service/index.d.ts +4 -0
- package/dist/src/service/index.js +47 -0
- package/dist/src/service/macos.d.ts +96 -0
- package/dist/src/service/macos.js +551 -0
- package/dist/src/store/attempts.d.ts +12 -0
- package/dist/src/store/attempts.js +29 -0
- package/dist/src/store/db.d.ts +1 -1
- package/dist/src/store/db.js +58 -2
- package/dist/src/store/quotas.js +52 -10
- package/dist/src/version.js +1 -1
- package/dist/src/webAssets.js +1 -1
- package/dist/src/webHtml.js +1 -1
- package/dist/store/attempts.d.ts +12 -0
- package/dist/store/attempts.js +29 -0
- package/dist/store/db.d.ts +1 -1
- package/dist/store/db.js +58 -2
- package/dist/store/quotas.js +52 -10
- package/dist/tests/adapters/agy.test.js +337 -0
- package/dist/tests/adapters/claude.test.js +41 -1
- package/dist/tests/adapters/codex.test.js +231 -91
- package/dist/tests/adapters/credential-free.test.js +211 -0
- package/dist/tests/adapters/grok.test.js +239 -135
- package/dist/tests/adapters/kimi.test.js +219 -104
- package/dist/tests/adapters/pty.test.d.ts +1 -0
- package/dist/tests/adapters/pty.test.js +240 -0
- package/dist/tests/adapters/registry.test.js +5 -5
- package/dist/tests/advisory/engine.test.js +194 -6
- package/dist/tests/advisory/pace.test.d.ts +1 -0
- package/dist/tests/advisory/pace.test.js +60 -0
- package/dist/tests/advisory/snapshot.test.d.ts +1 -0
- package/dist/tests/advisory/snapshot.test.js +65 -0
- package/dist/tests/advisory/validate.test.d.ts +1 -0
- package/dist/tests/advisory/validate.test.js +19 -0
- package/dist/tests/cli/advise.test.d.ts +1 -0
- package/dist/tests/cli/advise.test.js +294 -0
- package/dist/tests/cli/cli.test.js +39 -19
- package/dist/tests/cli/helpers.d.ts +9 -0
- package/dist/tests/cli/helpers.js +93 -0
- package/dist/tests/cli/ingest.test.d.ts +1 -0
- package/dist/tests/cli/ingest.test.js +188 -0
- package/dist/tests/cli/parity.test.d.ts +1 -0
- package/dist/tests/cli/parity.test.js +225 -0
- package/dist/tests/cli/snapshot-source.test.d.ts +1 -0
- package/dist/tests/cli/snapshot-source.test.js +305 -0
- package/dist/tests/cli/status.test.d.ts +1 -0
- package/dist/tests/cli/status.test.js +423 -0
- package/dist/tests/daemon.test.js +140 -38
- package/dist/tests/fixtures/stable-state.d.ts +22 -0
- package/dist/tests/fixtures/stable-state.js +152 -0
- package/dist/tests/fixtures/stable-state.test.d.ts +1 -0
- package/dist/tests/fixtures/stable-state.test.js +17 -0
- package/dist/tests/format/markdown.test.d.ts +1 -0
- package/dist/tests/format/markdown.test.js +49 -0
- package/dist/tests/format/terminal.test.d.ts +1 -0
- package/dist/tests/format/terminal.test.js +476 -0
- package/dist/tests/http/api.test.js +224 -2
- package/dist/tests/integration.test.js +20 -7
- package/dist/tests/mcp/protocol.test.d.ts +1 -0
- package/dist/tests/mcp/protocol.test.js +134 -0
- package/dist/tests/mcp/quotas.test.js +38 -6
- package/dist/tests/mcp/tools.test.d.ts +1 -0
- package/dist/tests/mcp/tools.test.js +291 -0
- package/dist/tests/runtime/cancel.test.d.ts +1 -0
- package/dist/tests/runtime/cancel.test.js +264 -0
- package/dist/tests/runtime/cli.test.d.ts +1 -0
- package/dist/tests/runtime/cli.test.js +226 -0
- package/dist/tests/runtime/config.test.d.ts +1 -0
- package/dist/tests/runtime/config.test.js +144 -0
- package/dist/tests/runtime/http.test.d.ts +1 -0
- package/dist/tests/runtime/http.test.js +267 -0
- package/dist/tests/runtime/lifecycle.test.d.ts +1 -0
- package/dist/tests/runtime/lifecycle.test.js +303 -0
- package/dist/tests/runtime/ownership.test.d.ts +1 -0
- package/dist/tests/runtime/ownership.test.js +355 -0
- package/dist/tests/runtime/poll.test.d.ts +1 -0
- package/dist/tests/runtime/poll.test.js +410 -0
- package/dist/tests/runtime/status.test.d.ts +1 -0
- package/dist/tests/runtime/status.test.js +232 -0
- package/dist/tests/service/macos.test.d.ts +1 -0
- package/dist/tests/service/macos.test.js +349 -0
- package/dist/tests/store/attempts.test.d.ts +1 -0
- package/dist/tests/store/attempts.test.js +29 -0
- package/dist/tests/store/db.test.js +61 -6
- package/dist/tests/store/migration.test.d.ts +1 -0
- package/dist/tests/store/migration.test.js +41 -0
- package/dist/tests/web/advice-drawer.test.d.ts +1 -0
- package/dist/tests/web/advice-drawer.test.js +45 -0
- package/dist/tests/web/dashboard.e2e.d.ts +1 -0
- package/dist/tests/web/dashboard.e2e.js +670 -0
- package/dist/tests/web/header.test.d.ts +1 -0
- package/dist/tests/web/header.test.js +56 -0
- package/dist/tests/web/names.test.d.ts +1 -0
- package/dist/tests/web/names.test.js +19 -0
- package/dist/tests/web/onboarding.test.d.ts +1 -0
- package/dist/tests/web/onboarding.test.js +21 -0
- package/dist/tests/web/pacing.test.d.ts +1 -0
- package/dist/tests/web/pacing.test.js +62 -0
- package/dist/tests/web/playwright.config.d.ts +2 -0
- package/dist/tests/web/playwright.config.js +10 -0
- package/dist/tests/web/provider-drawer.test.d.ts +1 -0
- package/dist/tests/web/provider-drawer.test.js +68 -0
- package/dist/tests/web/recommendation.test.d.ts +1 -0
- package/dist/tests/web/recommendation.test.js +38 -0
- package/dist/tests/web/reset-rail.test.d.ts +1 -0
- package/dist/tests/web/reset-rail.test.js +63 -0
- package/dist/tests/web/responsive.test.d.ts +1 -0
- package/dist/tests/web/responsive.test.js +21 -0
- package/dist/tests/web/settings.test.d.ts +1 -0
- package/dist/tests/web/settings.test.js +32 -0
- package/dist/tests/web/state-catalog.test.d.ts +1 -0
- package/dist/tests/web/state-catalog.test.js +206 -0
- package/dist/tests/web/state.test.d.ts +1 -0
- package/dist/tests/web/state.test.js +27 -0
- package/dist/tests/web/stub-server.d.ts +37 -0
- package/dist/tests/web/stub-server.js +132 -0
- package/dist/tests/web/theme.test.d.ts +1 -0
- package/dist/tests/web/theme.test.js +12 -0
- package/dist/version.js +1 -1
- package/dist/web/src/App.d.ts +1 -0
- package/dist/web/src/App.js +106 -78
- package/dist/web/src/Theme.d.ts +11 -0
- package/dist/web/src/Theme.js +53 -0
- package/dist/web/src/api.d.ts +27 -2
- package/dist/web/src/api.js +102 -6
- package/dist/web/src/components/AdviceDrawer.d.ts +9 -0
- package/dist/web/src/components/AdviceDrawer.js +77 -0
- package/dist/web/src/components/FaultBanner.d.ts +17 -0
- package/dist/web/src/components/FaultBanner.js +60 -0
- package/dist/web/src/components/Header.d.ts +22 -0
- package/dist/web/src/components/Header.js +46 -0
- package/dist/web/src/components/PaceBar.d.ts +51 -0
- package/dist/web/src/components/PaceBar.js +251 -0
- package/dist/web/src/components/PacingLegend.d.ts +3 -0
- package/dist/web/src/components/PacingLegend.js +43 -0
- package/dist/web/src/components/ProviderCard.d.ts +8 -0
- package/dist/web/src/components/ProviderCard.js +27 -0
- package/dist/web/src/components/ProviderDrawer.d.ts +52 -0
- package/dist/web/src/components/ProviderDrawer.js +270 -0
- package/dist/web/src/components/ProviderIcon.d.ts +7 -0
- package/dist/web/src/components/ProviderIcon.js +27 -0
- package/dist/web/src/components/ProviderRow.d.ts +8 -0
- package/dist/web/src/components/ProviderRow.js +14 -0
- package/dist/web/src/components/Recommendation.d.ts +27 -0
- package/dist/web/src/components/Recommendation.js +77 -0
- package/dist/web/src/components/ResetRail.d.ts +38 -0
- package/dist/web/src/components/ResetRail.js +188 -0
- package/dist/web/src/components/SubscriptionList.d.ts +19 -0
- package/dist/web/src/components/SubscriptionList.js +107 -0
- package/dist/web/src/components/settings/CliIntegrationPanel.d.ts +2 -0
- package/dist/web/src/components/settings/CliIntegrationPanel.js +49 -0
- package/dist/web/src/components/settings/DaemonPanel.d.ts +6 -0
- package/dist/web/src/components/settings/DaemonPanel.js +45 -0
- package/dist/web/src/components/settings/ProvidersPanel.d.ts +14 -0
- package/dist/web/src/components/settings/ProvidersPanel.js +69 -0
- package/dist/web/src/components/settings/SettingsDrawer.d.ts +9 -0
- package/dist/web/src/components/settings/SettingsDrawer.js +55 -0
- package/dist/web/src/names.d.ts +3 -0
- package/dist/web/src/names.js +19 -0
- package/dist/web/src/pages/Onboarding.d.ts +17 -0
- package/dist/web/src/pages/Onboarding.js +96 -0
- package/dist/web/src/router.d.ts +11 -0
- package/dist/web/src/router.js +31 -0
- package/dist/web/src/state.d.ts +97 -0
- package/dist/web/src/state.js +80 -0
- package/dist/webAssets.js +1 -1
- package/dist/webHtml.js +1 -1
- package/package.json +33 -8
- package/scripts/fix-pty-perms.mjs +21 -0
- package/web/dist/assets/index-CyUkWdwJ.css +1 -0
- package/web/dist/assets/index-PN4RpMKG.js +12 -0
- package/web/dist/index.html +19 -2
- package/dist/adapters/core.d.ts +0 -9
- package/dist/adapters/core.js +0 -90
- package/dist/format/table.d.ts +0 -2
- package/dist/format/table.js +0 -56
- package/dist/src/adapters/core.d.ts +0 -9
- package/dist/src/adapters/core.js +0 -90
- package/dist/src/format/table.d.ts +0 -2
- package/dist/src/format/table.js +0 -56
- package/dist/tests/adapters/core.test.js +0 -110
- package/dist/tests/format/table.test.js +0 -98
- package/web/dist/assets/index-Dpiqzcc8.js +0 -40
- /package/dist/tests/adapters/{core.test.d.ts → agy.test.d.ts} +0 -0
- /package/dist/tests/{format/table.test.d.ts → adapters/credential-free.test.d.ts} +0 -0
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { test, expect } from "@playwright/test";
|
|
6
|
+
import { startStub } from "./stub-server.js";
|
|
7
|
+
import { hatchGradient } from "../../web/src/components/PaceBar.js";
|
|
8
|
+
import { exampleStateSnapshotJson, staleStateSnapshotJson, resetPassedStateSnapshotJson, unknownPaceStateSnapshotJson, } from "../fixtures/stable-state.js";
|
|
9
|
+
/**
|
|
10
|
+
* Fixture-driven dashboard suite. The stub serves Track A fixture JSON
|
|
11
|
+
* verbatim plus the real Vite build; no server source is imported.
|
|
12
|
+
*/
|
|
13
|
+
const liveStubs = [];
|
|
14
|
+
test.afterEach(async () => {
|
|
15
|
+
while (liveStubs.length > 0) {
|
|
16
|
+
const stub = liveStubs.pop();
|
|
17
|
+
if (stub)
|
|
18
|
+
await stub.stop();
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
async function stubFor(state, extra) {
|
|
22
|
+
const stub = await startStub({ state, ...extra });
|
|
23
|
+
liveStubs.push(stub);
|
|
24
|
+
return stub;
|
|
25
|
+
}
|
|
26
|
+
/** Private screenshot dir: the main checkout's workbench, never the branch. */
|
|
27
|
+
function assetsDir() {
|
|
28
|
+
const override = process.env.QUOTACAP_ASSETS_DIR;
|
|
29
|
+
if (override && override.length > 0) {
|
|
30
|
+
fs.mkdirSync(override, { recursive: true });
|
|
31
|
+
return override;
|
|
32
|
+
}
|
|
33
|
+
const specDir = path.dirname(fileURLToPath(import.meta.url));
|
|
34
|
+
const common = execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], {
|
|
35
|
+
cwd: specDir,
|
|
36
|
+
encoding: "utf8",
|
|
37
|
+
}).trim();
|
|
38
|
+
const dir = path.join(path.dirname(common), ".workbench", "3-plans", "assets", "2026-09-07-stable-c");
|
|
39
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
40
|
+
return dir;
|
|
41
|
+
}
|
|
42
|
+
const ASSETS = assetsDir();
|
|
43
|
+
function shot(page, name) {
|
|
44
|
+
return page.screenshot({ path: path.join(ASSETS, name) });
|
|
45
|
+
}
|
|
46
|
+
async function themedPage(browser, theme, width, height = 900) {
|
|
47
|
+
const context = await browser.newContext({
|
|
48
|
+
viewport: { width, height },
|
|
49
|
+
colorScheme: theme,
|
|
50
|
+
});
|
|
51
|
+
const page = await context.newPage();
|
|
52
|
+
return { context, page };
|
|
53
|
+
}
|
|
54
|
+
function focusInside(page, testId) {
|
|
55
|
+
return page.evaluate((id) => {
|
|
56
|
+
const dialog = document.querySelector(`[data-testid="${id}"]`);
|
|
57
|
+
return !!dialog && dialog.contains(document.activeElement);
|
|
58
|
+
}, testId);
|
|
59
|
+
}
|
|
60
|
+
async function expectNoPageOverflow(page) {
|
|
61
|
+
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
|
62
|
+
expect(overflow).toBeLessThanOrEqual(1); // sub-pixel tolerance
|
|
63
|
+
}
|
|
64
|
+
const exampleState = () => JSON.parse(exampleStateSnapshotJson);
|
|
65
|
+
const reasonOf = (s) => s.recommendation.reason;
|
|
66
|
+
const recWaste = (s) => s.recommendation.wastePct !== null && s.recommendation.wastePct !== undefined
|
|
67
|
+
? `${Math.round(s.recommendation.wastePct)}%`
|
|
68
|
+
: reasonOf(s);
|
|
69
|
+
/** First-run server state: every provider present, no quota rows stored. */
|
|
70
|
+
function firstRunState() {
|
|
71
|
+
const s = exampleState();
|
|
72
|
+
return {
|
|
73
|
+
...s,
|
|
74
|
+
providers: s.providers.map((p) => ({
|
|
75
|
+
...p,
|
|
76
|
+
quota: null,
|
|
77
|
+
lastAttempt: null,
|
|
78
|
+
lastSuccessAt: null,
|
|
79
|
+
reporting: false,
|
|
80
|
+
stale: false,
|
|
81
|
+
resetPassed: false,
|
|
82
|
+
ageMs: null,
|
|
83
|
+
evidence: [],
|
|
84
|
+
exclusionReason: "not-reporting",
|
|
85
|
+
advisory: null,
|
|
86
|
+
})),
|
|
87
|
+
recommendation: {
|
|
88
|
+
use: "none",
|
|
89
|
+
reason: "no quotas yet",
|
|
90
|
+
wastePct: null,
|
|
91
|
+
idealRate: 0,
|
|
92
|
+
recommendationBasis: "none",
|
|
93
|
+
alternatives: [],
|
|
94
|
+
advisories: [],
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/** Balanced server state: nothing needs priority, lanes stay empty. */
|
|
99
|
+
function balancedState() {
|
|
100
|
+
const s = exampleState();
|
|
101
|
+
const calm = (a) => a ? { ...a, status: "on track", wastePct: 0, urgency: "on track" } : a;
|
|
102
|
+
return {
|
|
103
|
+
...s,
|
|
104
|
+
providers: s.providers.map((p) => p.exclusionReason === null ? { ...p, advisory: calm(p.advisory) } : p),
|
|
105
|
+
recommendation: {
|
|
106
|
+
...s.recommendation,
|
|
107
|
+
use: "none",
|
|
108
|
+
reason: "No subscription needs priority",
|
|
109
|
+
wastePct: 0,
|
|
110
|
+
recommendationBasis: "none",
|
|
111
|
+
advisories: s.recommendation.advisories.map((a) => ({ ...a, urgency: "on track" })),
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** One reset pushed past the 7-day rail for the overflow chip. */
|
|
116
|
+
function farResetState() {
|
|
117
|
+
const s = exampleState();
|
|
118
|
+
const far = new Date(Date.parse(s.asOf) + 30 * 24 * 60 * 60 * 1000).toISOString();
|
|
119
|
+
return {
|
|
120
|
+
...s,
|
|
121
|
+
providers: s.providers.map((p) => p.id === "kimi" && p.quota ? { ...p, quota: { ...p.quota, resetsAt: far } } : p),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/** Resets spread a day apart so every pin places without clustering. */
|
|
125
|
+
function spreadResetState() {
|
|
126
|
+
const s = exampleState();
|
|
127
|
+
const asOfMs = Date.parse(s.asOf);
|
|
128
|
+
const dayOf = {
|
|
129
|
+
kimi: 1,
|
|
130
|
+
claude: 2,
|
|
131
|
+
codex: 3,
|
|
132
|
+
"my-plan": 4,
|
|
133
|
+
agy: 5,
|
|
134
|
+
"agy:3p": 6,
|
|
135
|
+
};
|
|
136
|
+
return {
|
|
137
|
+
...s,
|
|
138
|
+
providers: s.providers.map((p) => {
|
|
139
|
+
const day = dayOf[p.id];
|
|
140
|
+
if (day === undefined || !p.quota)
|
|
141
|
+
return p;
|
|
142
|
+
return {
|
|
143
|
+
...p,
|
|
144
|
+
quota: { ...p.quota, resetsAt: new Date(asOfMs + day * 24 * 60 * 60 * 1000).toISOString() },
|
|
145
|
+
};
|
|
146
|
+
}),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
test("renders recommendation advice with forecast waste percentage", async ({ page }) => {
|
|
150
|
+
const stub = await stubFor(exampleState());
|
|
151
|
+
await page.goto(stub.url);
|
|
152
|
+
await expect(page.getByTestId("rec-prose")).toContainText(recWaste(exampleState()));
|
|
153
|
+
});
|
|
154
|
+
const VIEWPORTS = [320, 390, 768, 1440];
|
|
155
|
+
const THEMES = ["light", "dark"];
|
|
156
|
+
test("dashboard renders in every viewport and theme without page overflow", async ({ browser, }) => {
|
|
157
|
+
const stub = await stubFor(exampleState());
|
|
158
|
+
for (const width of VIEWPORTS) {
|
|
159
|
+
for (const theme of THEMES) {
|
|
160
|
+
const { context, page } = await themedPage(browser, theme, width);
|
|
161
|
+
try {
|
|
162
|
+
await page.goto(stub.url);
|
|
163
|
+
await expect(page.getByTestId("rec-prose")).toContainText(recWaste(exampleState()));
|
|
164
|
+
await expectNoPageOverflow(page);
|
|
165
|
+
await shot(page, `dashboard-${width}-${theme}.png`);
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
await context.close();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
async function expectLabeledChrome(page) {
|
|
174
|
+
await expect(page.getByTestId("rec-prose")).toContainText(recWaste(exampleState()));
|
|
175
|
+
await expect(page.getByRole("heading", { name: /quota advice & pacing/i })).toBeVisible();
|
|
176
|
+
await expect(page.getByRole("heading", { name: /upcoming resets/i })).toBeVisible();
|
|
177
|
+
await expect(page.getByRole("heading", { name: /all subscriptions/i })).toBeVisible();
|
|
178
|
+
await expect(page.getByTestId("view-advice")).toBeVisible();
|
|
179
|
+
await expect(page.getByLabel(/Sort by/)).toBeVisible();
|
|
180
|
+
await expect(page.getByRole("button", { name: "Cards" })).toBeVisible();
|
|
181
|
+
await expect(page.getByRole("button", { name: "Table" })).toBeVisible();
|
|
182
|
+
const refreshLabel = page.locator('[data-testid="refresh-button"] .btn-label');
|
|
183
|
+
const settingsLabel = page.locator('[data-testid="settings-button"] .btn-label');
|
|
184
|
+
await expect(refreshLabel).toBeVisible();
|
|
185
|
+
await expect(refreshLabel).toHaveText("Refresh");
|
|
186
|
+
await expect(refreshLabel).not.toHaveCSS("display", "none");
|
|
187
|
+
await expect(settingsLabel).toBeVisible();
|
|
188
|
+
await expect(settingsLabel).toHaveText("Settings");
|
|
189
|
+
await expect(settingsLabel).not.toHaveCSS("display", "none");
|
|
190
|
+
}
|
|
191
|
+
test("dashboard chrome matches the signed-off section structure", async ({ browser }) => {
|
|
192
|
+
const stub = await stubFor(exampleState());
|
|
193
|
+
for (const width of [1440, 390]) {
|
|
194
|
+
const { context, page } = await themedPage(browser, "light", width);
|
|
195
|
+
try {
|
|
196
|
+
await page.goto(stub.url);
|
|
197
|
+
await expectLabeledChrome(page);
|
|
198
|
+
if (width === 1440) {
|
|
199
|
+
await expect(page.getByTestId("pill")).toContainText("daemon live");
|
|
200
|
+
const rec = page.getByTestId("recommendation");
|
|
201
|
+
const recRadius = await rec.evaluate((el) => getComputedStyle(el).borderRadius);
|
|
202
|
+
expect(recRadius).toBe("18px");
|
|
203
|
+
const head = page.getByRole("heading", { name: /quota advice & pacing/i });
|
|
204
|
+
const headSize = await head.evaluate((el) => getComputedStyle(el).fontSize);
|
|
205
|
+
expect(headSize).toBe("12.5px");
|
|
206
|
+
await expect(page.getByTestId("provider-card-kimi")).toContainText("% used");
|
|
207
|
+
await expect(page.getByTestId("rail")).toHaveCSS("overflow-x", "visible");
|
|
208
|
+
await page.getByRole("button", { name: "Table" }).click();
|
|
209
|
+
await expect(page.getByTestId("ledger-table")).toBeVisible();
|
|
210
|
+
await shot(page, "dashboard-table-1440-light.png");
|
|
211
|
+
await page.getByRole("button", { name: "Cards" }).click();
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
await shot(page, "dashboard-chrome-390-light.png");
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
finally {
|
|
218
|
+
await context.close();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
test("settings tabs and advice drawer capture every operational surface", async ({ browser }) => {
|
|
223
|
+
const stub = await stubFor(exampleState());
|
|
224
|
+
for (const theme of THEMES) {
|
|
225
|
+
const { context, page } = await themedPage(browser, theme, 1440);
|
|
226
|
+
try {
|
|
227
|
+
await page.goto(stub.url);
|
|
228
|
+
await expect(page.getByTestId("rec-prose")).toBeVisible();
|
|
229
|
+
await page.getByTestId("settings-button").click();
|
|
230
|
+
const settings = page.getByTestId("settings-drawer");
|
|
231
|
+
await expect(settings).toBeVisible();
|
|
232
|
+
await shot(page, `settings-providers-1440-${theme}.png`);
|
|
233
|
+
await page.getByRole("tab", { name: "CLI & Shell" }).click();
|
|
234
|
+
await shot(page, `settings-cli-1440-${theme}.png`);
|
|
235
|
+
await page.getByRole("tab", { name: "Daemon" }).click();
|
|
236
|
+
await shot(page, `settings-daemon-1440-${theme}.png`);
|
|
237
|
+
await page.keyboard.press("Escape");
|
|
238
|
+
await expect(settings).toBeHidden();
|
|
239
|
+
await page.getByTestId("view-advice").click();
|
|
240
|
+
const advice = page.getByTestId("advice-drawer");
|
|
241
|
+
await expect(advice).toBeVisible();
|
|
242
|
+
await expect(advice).toContainText("Quota Advice");
|
|
243
|
+
await expect(advice).toContainText("Recommended for next session");
|
|
244
|
+
await expect(advice).toContainText("Fleet Pacing Breakdown");
|
|
245
|
+
await expect(advice).toContainText("quotacap advise");
|
|
246
|
+
await shot(page, `advice-drawer-1440-${theme}.png`);
|
|
247
|
+
await page.keyboard.press("Escape");
|
|
248
|
+
await expect(advice).toBeHidden();
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
await context.close();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
test("unknown pace shows no numeric rate or predicted waste", async ({ browser }) => {
|
|
256
|
+
const stub = await stubFor(JSON.parse(unknownPaceStateSnapshotJson));
|
|
257
|
+
const { context, page } = await themedPage(browser, "dark", 1440);
|
|
258
|
+
try {
|
|
259
|
+
await page.goto(stub.url);
|
|
260
|
+
const kimi = page.getByTestId("provider-card-kimi");
|
|
261
|
+
await expect(kimi).toContainText("Measuring pace");
|
|
262
|
+
await expect(kimi).toContainText("—");
|
|
263
|
+
await expect(page.getByTestId("dashboard-root")).not.toContainText("predicted waste");
|
|
264
|
+
await shot(page, "dashboard-unknown-pace-1440-dark.png");
|
|
265
|
+
}
|
|
266
|
+
finally {
|
|
267
|
+
await context.close();
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
test("lanes follow server urgency", async ({ page }) => {
|
|
271
|
+
const stub = await stubFor(exampleState());
|
|
272
|
+
await page.goto(stub.url);
|
|
273
|
+
const useMore = page.getByTestId("lane-use-more");
|
|
274
|
+
await expect(useMore).toContainText("Kimi");
|
|
275
|
+
await expect(useMore).toContainText("Claude");
|
|
276
|
+
await expect(useMore).toContainText("Codex");
|
|
277
|
+
await expect(useMore).toContainText("my-plan");
|
|
278
|
+
await expect(page.getByTestId("lane-ease-off")).toHaveCount(0);
|
|
279
|
+
});
|
|
280
|
+
test("crowded resets group into a cluster popover", async ({ browser }) => {
|
|
281
|
+
const stub = await stubFor(exampleState());
|
|
282
|
+
for (const theme of THEMES) {
|
|
283
|
+
const { context, page } = await themedPage(browser, theme, 1440);
|
|
284
|
+
try {
|
|
285
|
+
await page.goto(stub.url);
|
|
286
|
+
const cluster = page.getByTestId("cluster-pin");
|
|
287
|
+
await expect(cluster).toContainText("6 resets");
|
|
288
|
+
await cluster.click();
|
|
289
|
+
const popover = page.getByTestId("cluster-popover");
|
|
290
|
+
await expect(popover).toBeVisible();
|
|
291
|
+
await expect(popover.locator("li")).toHaveCount(6);
|
|
292
|
+
await shot(page, `rail-crowded-1440-${theme}.png`);
|
|
293
|
+
await popover.locator("li button").first().click();
|
|
294
|
+
await expect(page.getByTestId("provider-drawer")).toBeVisible();
|
|
295
|
+
}
|
|
296
|
+
finally {
|
|
297
|
+
await context.close();
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
test("distant resets render as overflow chips", async ({ page }) => {
|
|
302
|
+
const stub = await stubFor(farResetState());
|
|
303
|
+
await page.goto(stub.url);
|
|
304
|
+
await expect(page.getByTestId("rail-overflow")).toContainText("+1 beyond rail");
|
|
305
|
+
});
|
|
306
|
+
test("provider drawer traps focus, closes on esc and scrim, and returns focus", async ({ browser, }) => {
|
|
307
|
+
const stub = await stubFor(exampleState());
|
|
308
|
+
for (const theme of THEMES) {
|
|
309
|
+
const { context, page } = await themedPage(browser, theme, 1440);
|
|
310
|
+
try {
|
|
311
|
+
await page.goto(stub.url);
|
|
312
|
+
const card = page.getByTestId("provider-card-kimi");
|
|
313
|
+
await card.click();
|
|
314
|
+
const drawer = page.getByTestId("provider-drawer");
|
|
315
|
+
await expect(drawer).toBeVisible();
|
|
316
|
+
await expect(drawer).toContainText(/averaged over|Window avg|Measured/);
|
|
317
|
+
await shot(page, `provider-drawer-1440-${theme}.png`);
|
|
318
|
+
// A reverse tab from the freshly focused dialog container stays inside.
|
|
319
|
+
await page.keyboard.press("Shift+Tab");
|
|
320
|
+
expect(await focusInside(page, "provider-drawer")).toBe(true);
|
|
321
|
+
// Focus stays inside while tabbing.
|
|
322
|
+
for (let i = 0; i < 30; i++) {
|
|
323
|
+
await page.keyboard.press("Tab");
|
|
324
|
+
const inside = await page.evaluate(() => {
|
|
325
|
+
const d = document.querySelector('[data-testid="provider-drawer"]');
|
|
326
|
+
return !!d && d.contains(document.activeElement);
|
|
327
|
+
});
|
|
328
|
+
expect(inside).toBe(true);
|
|
329
|
+
}
|
|
330
|
+
// Esc closes and returns focus to the invoking card.
|
|
331
|
+
await page.keyboard.press("Escape");
|
|
332
|
+
await expect(drawer).toBeHidden();
|
|
333
|
+
const returned = await page.evaluate(() => document.activeElement?.dataset?.testid ?? null);
|
|
334
|
+
expect(returned).toBe("provider-card-kimi");
|
|
335
|
+
// Scrim click closes too.
|
|
336
|
+
await card.click();
|
|
337
|
+
await expect(drawer).toBeVisible();
|
|
338
|
+
await page.getByTestId("drawer-scrim").click({ position: { x: 10, y: 10 } });
|
|
339
|
+
await expect(drawer).toBeHidden();
|
|
340
|
+
}
|
|
341
|
+
finally {
|
|
342
|
+
await context.close();
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
test("settings drawer holds focus on an immediate reverse tab", async ({ page }) => {
|
|
347
|
+
const stub = await stubFor(exampleState());
|
|
348
|
+
await page.goto(stub.url);
|
|
349
|
+
await page.getByTestId("settings-button").click();
|
|
350
|
+
await expect(page.getByTestId("settings-drawer")).toBeVisible();
|
|
351
|
+
await page.keyboard.press("Shift+Tab");
|
|
352
|
+
expect(await focusInside(page, "settings-drawer")).toBe(true);
|
|
353
|
+
});
|
|
354
|
+
test("every dashboard control matches the prototype click targets", async ({ browser }) => {
|
|
355
|
+
const stub = await stubFor(exampleState());
|
|
356
|
+
const { context, page } = await themedPage(browser, "dark", 1440);
|
|
357
|
+
try {
|
|
358
|
+
await page.goto(stub.url);
|
|
359
|
+
await expect(page.getByTestId("rec-prose")).toBeVisible();
|
|
360
|
+
await page.getByTestId("theme-toggle").click();
|
|
361
|
+
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
|
|
362
|
+
await page.getByTestId("theme-toggle").click();
|
|
363
|
+
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
|
364
|
+
await page.getByTestId("view-advice").click();
|
|
365
|
+
const advice = page.getByTestId("advice-drawer");
|
|
366
|
+
await expect(advice).toBeVisible();
|
|
367
|
+
await expect(advice.getByRole("heading", { name: /quota advice & pacing/i })).toBeVisible();
|
|
368
|
+
await expect(advice).toContainText("Recommended for next session");
|
|
369
|
+
await expect(advice).toContainText("Use more");
|
|
370
|
+
await expect(advice).toContainText("Ease off");
|
|
371
|
+
await expect(advice).toContainText("$ quotacap advise");
|
|
372
|
+
await shot(page, "interaction-advice-drawer-1440-dark.png");
|
|
373
|
+
await page.getByRole("button", { name: "Close advice" }).click();
|
|
374
|
+
await expect(advice).toBeHidden();
|
|
375
|
+
await page.getByTestId("provider-card-kimi").getByRole("button", { name: "Inspect" }).click();
|
|
376
|
+
await expect(page.getByTestId("provider-drawer")).toBeVisible();
|
|
377
|
+
await expect(page.getByTestId("advice-drawer")).toHaveCount(0);
|
|
378
|
+
await page.keyboard.press("Escape");
|
|
379
|
+
await page.getByTestId("settings-button").click();
|
|
380
|
+
const settings = page.getByTestId("settings-drawer");
|
|
381
|
+
await expect(settings).toBeVisible();
|
|
382
|
+
await page.getByRole("tab", { name: "Providers" }).click();
|
|
383
|
+
await shot(page, "interaction-settings-providers-1440-dark.png");
|
|
384
|
+
await page.getByRole("tab", { name: "CLI & Shell" }).click();
|
|
385
|
+
await expect(settings).toContainText("Shell");
|
|
386
|
+
await page.getByRole("tab", { name: "Daemon" }).click();
|
|
387
|
+
await page.keyboard.press("Escape");
|
|
388
|
+
await expect(settings).toBeHidden();
|
|
389
|
+
await page.getByLabel(/Sort by/).click();
|
|
390
|
+
await expect(page.getByRole("option", { name: "Recommended" })).toBeVisible();
|
|
391
|
+
await page.getByRole("option", { name: "Highest Usage" }).click();
|
|
392
|
+
await page.getByRole("button", { name: "Table" }).click();
|
|
393
|
+
await expect(page.getByTestId("ledger-table")).toBeVisible();
|
|
394
|
+
await page.getByRole("button", { name: "Cards" }).click();
|
|
395
|
+
await expect(page.getByTestId("cards-grid")).toBeVisible();
|
|
396
|
+
await page.getByRole("button", { name: /legend/i }).click();
|
|
397
|
+
await expect(page.getByRole("dialog", { name: "Pacing legend" })).toBeVisible();
|
|
398
|
+
}
|
|
399
|
+
finally {
|
|
400
|
+
await context.close();
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
test("the projected-unused hatch paints a gradient the browser accepts", async ({ page }) => {
|
|
404
|
+
const stub = await stubFor(exampleState());
|
|
405
|
+
await page.goto(stub.url);
|
|
406
|
+
// A CSS variable with an alpha suffix parses to nothing, so the browser
|
|
407
|
+
// would report background-image: none and the hatch would never show.
|
|
408
|
+
const painted = await page.evaluate((gradient) => {
|
|
409
|
+
const probe = document.createElement("div");
|
|
410
|
+
probe.style.background = gradient;
|
|
411
|
+
document.body.append(probe);
|
|
412
|
+
const image = getComputedStyle(probe).backgroundImage;
|
|
413
|
+
probe.remove();
|
|
414
|
+
return image;
|
|
415
|
+
}, hatchGradient("Behind pace"));
|
|
416
|
+
expect(painted).not.toBe("none");
|
|
417
|
+
expect(painted).toContain("gradient");
|
|
418
|
+
for (const hatch of await page.getByTestId("pace-hatch").all()) {
|
|
419
|
+
await expect(hatch).toHaveCSS("background-image", /gradient/);
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
test("settings lists adapters only and has no ingest form", async ({ browser, }) => {
|
|
423
|
+
const stub = await stubFor(exampleState());
|
|
424
|
+
const { context, page } = await themedPage(browser, "light", 1440);
|
|
425
|
+
try {
|
|
426
|
+
await page.goto(stub.url);
|
|
427
|
+
await page.getByTestId("settings-button").click();
|
|
428
|
+
const drawer = page.getByTestId("settings-drawer");
|
|
429
|
+
await expect(drawer).toBeVisible();
|
|
430
|
+
await expect(drawer.getByText("Kimi Code")).toBeVisible();
|
|
431
|
+
await expect(drawer.getByText("manual", { exact: true })).toHaveCount(0);
|
|
432
|
+
await expect(drawer.getByText("Manual Quota Ingest")).toHaveCount(0);
|
|
433
|
+
await expect(page.getByLabel("Provider id for manual ingest")).toHaveCount(0);
|
|
434
|
+
await shot(page, "settings-drawer-1440-light.png");
|
|
435
|
+
}
|
|
436
|
+
finally {
|
|
437
|
+
await context.close();
|
|
438
|
+
}
|
|
439
|
+
const dark = await themedPage(browser, "dark", 1440);
|
|
440
|
+
try {
|
|
441
|
+
await dark.page.goto(stub.url);
|
|
442
|
+
await dark.page.getByTestId("settings-button").click();
|
|
443
|
+
await expect(dark.page.getByTestId("settings-drawer")).toBeVisible();
|
|
444
|
+
await shot(dark.page, "settings-drawer-1440-dark.png");
|
|
445
|
+
}
|
|
446
|
+
finally {
|
|
447
|
+
await dark.context.close();
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
test("keyboard-only traversal reaches rail pins, sort, density, and drawers", async ({ page, }) => {
|
|
451
|
+
await page.setViewportSize({ width: 1440, height: 900 });
|
|
452
|
+
const stub = await stubFor(spreadResetState());
|
|
453
|
+
await page.goto(stub.url);
|
|
454
|
+
// Rail pin opens the drawer from the keyboard.
|
|
455
|
+
const pin = page.getByTestId("pin").first();
|
|
456
|
+
await pin.focus();
|
|
457
|
+
await page.keyboard.press("Enter");
|
|
458
|
+
await expect(page.getByTestId("provider-drawer")).toBeVisible();
|
|
459
|
+
await page.keyboard.press("Escape");
|
|
460
|
+
await expect(page.getByTestId("provider-drawer")).toBeHidden();
|
|
461
|
+
// Card opens the drawer from the keyboard.
|
|
462
|
+
await page.getByTestId("provider-card-kimi").focus();
|
|
463
|
+
await page.keyboard.press("Enter");
|
|
464
|
+
await expect(page.getByTestId("provider-drawer")).toBeVisible();
|
|
465
|
+
await page.keyboard.press("Escape");
|
|
466
|
+
// Sort changes order from the keyboard via the custom SortDropdown.
|
|
467
|
+
await expect(page.getByTestId("cards-grid").locator("article").first()).toHaveAttribute("data-testid", "provider-card-my-plan");
|
|
468
|
+
await page.getByLabel(/Sort/).focus();
|
|
469
|
+
await page.keyboard.press("Enter");
|
|
470
|
+
await page.getByRole("option", { name: "Highest Usage" }).focus();
|
|
471
|
+
await page.keyboard.press("Enter");
|
|
472
|
+
await expect(page.getByTestId("cards-grid").locator("article").first()).toHaveAttribute("data-testid", "provider-card-agy:3p");
|
|
473
|
+
// Density and legend work from the keyboard.
|
|
474
|
+
await page.getByRole("button", { name: "Table" }).focus();
|
|
475
|
+
await page.keyboard.press("Enter");
|
|
476
|
+
await expect(page.getByTestId("ledger-table")).toBeVisible();
|
|
477
|
+
await page.getByRole("button", { name: /legend/i }).focus();
|
|
478
|
+
await page.keyboard.press("Enter");
|
|
479
|
+
await expect(page.getByRole("dialog", { name: "Pacing legend" })).toBeVisible();
|
|
480
|
+
});
|
|
481
|
+
test("reduced motion disables transitions", async ({ browser }) => {
|
|
482
|
+
const stub = await stubFor(exampleState());
|
|
483
|
+
const context = await browser.newContext({ reducedMotion: "reduce" });
|
|
484
|
+
try {
|
|
485
|
+
const page = await context.newPage();
|
|
486
|
+
await page.goto(stub.url);
|
|
487
|
+
await expect(page.getByTestId("rec-prose")).toBeVisible();
|
|
488
|
+
const reduced = await page.evaluate(() => matchMedia("(prefers-reduced-motion: reduce)").matches);
|
|
489
|
+
expect(reduced).toBe(true);
|
|
490
|
+
const duration = await page.evaluate(() => getComputedStyle(document.body).transitionDuration);
|
|
491
|
+
expect(duration).toBe("0s");
|
|
492
|
+
}
|
|
493
|
+
finally {
|
|
494
|
+
await context.close();
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
test("every graphic carries its label", async ({ page }) => {
|
|
498
|
+
const stub = await stubFor(spreadResetState());
|
|
499
|
+
await page.goto(stub.url);
|
|
500
|
+
await expect(page.getByTestId("rail")).toHaveAttribute("aria-label", /next 7 days/);
|
|
501
|
+
const bars = page.getByTestId("pace-bar");
|
|
502
|
+
expect(await bars.count()).toBeGreaterThan(0);
|
|
503
|
+
for (let i = 0; i < (await bars.count()); i++) {
|
|
504
|
+
await expect(bars.nth(i)).toHaveAttribute("aria-label", /used/);
|
|
505
|
+
}
|
|
506
|
+
const pins = page.getByTestId("pin");
|
|
507
|
+
expect(await pins.count()).toBeGreaterThan(0);
|
|
508
|
+
for (let i = 0; i < (await pins.count()); i++) {
|
|
509
|
+
await expect(pins.nth(i)).toHaveAttribute("aria-label", /resets/);
|
|
510
|
+
}
|
|
511
|
+
await expect(page.locator(".pin-label.pace-behind").first()).toBeVisible();
|
|
512
|
+
await expect(page.getByTestId("rail")).toHaveCSS("overflow-x", "visible");
|
|
513
|
+
});
|
|
514
|
+
test("fault banner per excluded provider carries last-read age", async ({ page }) => {
|
|
515
|
+
const stub = await stubFor(exampleState());
|
|
516
|
+
await page.goto(stub.url);
|
|
517
|
+
const banners = page.getByTestId("fault-banner");
|
|
518
|
+
await expect(banners).toHaveCount(2);
|
|
519
|
+
await expect(banners.filter({ hasText: "Agy 3P" })).toContainText("Last read 3h ago");
|
|
520
|
+
await expect(banners.filter({ hasText: "Grok" })).toContainText("invalid reading");
|
|
521
|
+
await expect(banners.filter({ hasText: "manual" })).toHaveCount(0);
|
|
522
|
+
});
|
|
523
|
+
test("failed refresh preserves prior readings with age", async ({ page }) => {
|
|
524
|
+
const stub = await stubFor(exampleState());
|
|
525
|
+
await page.goto(stub.url);
|
|
526
|
+
await expect(page.getByTestId("rec-prose")).toContainText(recWaste(exampleState()));
|
|
527
|
+
stub.setRefresh({ status: 500, body: { error: "poll exploded" } });
|
|
528
|
+
await page.getByTestId("refresh-button").click();
|
|
529
|
+
const stale = page.getByTestId("state-stale-error");
|
|
530
|
+
await expect(stale).toContainText("poll exploded");
|
|
531
|
+
await expect(stale).toContainText(/read .* ago/);
|
|
532
|
+
await expect(page.getByTestId("rec-prose")).toContainText(recWaste(exampleState()));
|
|
533
|
+
});
|
|
534
|
+
test("refresh surfaces server cooldown info verbatim", async ({ page }) => {
|
|
535
|
+
const stub = await stubFor(exampleState());
|
|
536
|
+
await page.goto(stub.url);
|
|
537
|
+
stub.setRefresh({ body: { message: "cooling down: retry in 40s" } });
|
|
538
|
+
await page.getByTestId("refresh-button").click();
|
|
539
|
+
await expect(page.getByTestId("refresh-notice")).toContainText("cooling down: retry in 40s");
|
|
540
|
+
});
|
|
541
|
+
test("first run routes to setup and completes to the dashboard", async ({ browser }) => {
|
|
542
|
+
const stub = await stubFor(firstRunState());
|
|
543
|
+
const { context, page } = await themedPage(browser, "light", 1440);
|
|
544
|
+
try {
|
|
545
|
+
await page.goto(`${stub.url}/`);
|
|
546
|
+
await expect(page).toHaveURL(/\/setup$/);
|
|
547
|
+
await expect(page.getByTestId("setup-step-1")).toBeVisible();
|
|
548
|
+
await expect(page.getByTestId("setup-step-1")).toContainText("Sign in first");
|
|
549
|
+
await shot(page, "setup-1-1440-light.png");
|
|
550
|
+
await page.getByRole("button", { name: "Continue to review" }).click();
|
|
551
|
+
await expect(page.getByTestId("setup-step-2")).toBeVisible();
|
|
552
|
+
await shot(page, "setup-2-1440-light.png");
|
|
553
|
+
await page.getByRole("button", { name: "Continue to first poll" }).click();
|
|
554
|
+
await expect(page.getByTestId("setup-step-3")).toBeVisible();
|
|
555
|
+
await expect(page.getByTestId("setup-step-3")).toContainText("Sample data");
|
|
556
|
+
stub.setRefresh({ nextState: exampleState() });
|
|
557
|
+
await page.getByTestId("setup-poll-button").click();
|
|
558
|
+
await expect(page).toHaveURL(`${stub.url}/`);
|
|
559
|
+
await expect(page.getByTestId("rec-prose")).toContainText(recWaste(exampleState()));
|
|
560
|
+
}
|
|
561
|
+
finally {
|
|
562
|
+
await context.close();
|
|
563
|
+
}
|
|
564
|
+
// Fresh first-run stub: the main flow above mutated the shared state.
|
|
565
|
+
const narrowStub = await stubFor(firstRunState());
|
|
566
|
+
const narrow = await themedPage(browser, "dark", 390);
|
|
567
|
+
try {
|
|
568
|
+
await narrow.page.goto(`${narrowStub.url}/setup`);
|
|
569
|
+
await expect(narrow.page.getByTestId("setup-step-1")).toBeVisible();
|
|
570
|
+
await expectNoPageOverflow(narrow.page);
|
|
571
|
+
await shot(narrow.page, "setup-1-390-dark.png");
|
|
572
|
+
}
|
|
573
|
+
finally {
|
|
574
|
+
await narrow.context.close();
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
test("setup step 3 renders the payoff preview", async ({ page }) => {
|
|
578
|
+
await page.setViewportSize({ width: 1440, height: 900 });
|
|
579
|
+
const stub = await stubFor(firstRunState());
|
|
580
|
+
await page.goto(`${stub.url}/setup`);
|
|
581
|
+
await page.getByRole("button", { name: "Continue to review" }).click();
|
|
582
|
+
await page.getByRole("button", { name: "Continue to first poll" }).click();
|
|
583
|
+
await expect(page.getByTestId("setup-step-3")).toBeVisible();
|
|
584
|
+
await shot(page, "setup-3-1440-light.png");
|
|
585
|
+
});
|
|
586
|
+
test("error states render from server fields", async ({ browser }) => {
|
|
587
|
+
// Service unavailable: the state endpoint never answers.
|
|
588
|
+
{
|
|
589
|
+
const stub = await stubFor(exampleState());
|
|
590
|
+
const { context, page } = await themedPage(browser, "dark", 1440);
|
|
591
|
+
try {
|
|
592
|
+
await page.route("**/api/state", (route) => route.abort());
|
|
593
|
+
await page.goto(stub.url);
|
|
594
|
+
const panel = page.getByTestId("state-unavailable");
|
|
595
|
+
await expect(panel).toBeVisible();
|
|
596
|
+
await expect(panel).toContainText("/api/state");
|
|
597
|
+
await shot(page, "states-unavailable-1440-dark.png");
|
|
598
|
+
await page.unroute("**/api/state");
|
|
599
|
+
await panel.getByRole("button", { name: "Retry" }).click();
|
|
600
|
+
await expect(page.getByTestId("rec-prose")).toContainText(recWaste(exampleState()));
|
|
601
|
+
}
|
|
602
|
+
finally {
|
|
603
|
+
await context.close();
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
// HTTP error: status code plus the server message.
|
|
607
|
+
{
|
|
608
|
+
const stub = await stubFor(exampleState());
|
|
609
|
+
const { context, page } = await themedPage(browser, "dark", 1440);
|
|
610
|
+
try {
|
|
611
|
+
await page.route("**/api/state", (route) => route.fulfill({
|
|
612
|
+
status: 500,
|
|
613
|
+
contentType: "application/json",
|
|
614
|
+
body: JSON.stringify({ error: "state exploded" }),
|
|
615
|
+
}));
|
|
616
|
+
await page.goto(stub.url);
|
|
617
|
+
const panel = page.getByTestId("state-http-error");
|
|
618
|
+
await expect(panel).toContainText("HTTP 500");
|
|
619
|
+
await expect(panel).toContainText("state exploded");
|
|
620
|
+
await shot(page, "states-http-error-1440-dark.png");
|
|
621
|
+
}
|
|
622
|
+
finally {
|
|
623
|
+
await context.close();
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
// Initial loading: skeletons, not blank.
|
|
627
|
+
{
|
|
628
|
+
const stub = await stubFor(exampleState(), { stateDelayMs: 3000 });
|
|
629
|
+
const { context, page } = await themedPage(browser, "dark", 1440);
|
|
630
|
+
try {
|
|
631
|
+
await page.goto(stub.url);
|
|
632
|
+
await expect(page.getByTestId("state-loading")).toBeVisible();
|
|
633
|
+
await shot(page, "states-loading-1440-dark.png");
|
|
634
|
+
await expect(page.getByTestId("rec-prose")).toContainText(recWaste(exampleState()));
|
|
635
|
+
}
|
|
636
|
+
finally {
|
|
637
|
+
await context.close();
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
// Fixture variants and the balanced state.
|
|
641
|
+
const variants = [
|
|
642
|
+
["stale", JSON.parse(staleStateSnapshotJson), /Last read 3h ago/],
|
|
643
|
+
["reset-passed", JSON.parse(resetPassedStateSnapshotJson), /awaiting fresh window/],
|
|
644
|
+
["balanced", balancedState(), /No subscription needs priority/],
|
|
645
|
+
];
|
|
646
|
+
for (const [name, state, marker] of variants) {
|
|
647
|
+
const stub = await stubFor(state);
|
|
648
|
+
const { context, page } = await themedPage(browser, "dark", 1440);
|
|
649
|
+
try {
|
|
650
|
+
await page.goto(stub.url);
|
|
651
|
+
await expect(page.getByTestId("dashboard-root")).toContainText(marker);
|
|
652
|
+
await shot(page, `dashboard-${name}-1440-dark.png`);
|
|
653
|
+
}
|
|
654
|
+
finally {
|
|
655
|
+
await context.close();
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
// Estimated resets carry (est.) on every reset rendering.
|
|
659
|
+
{
|
|
660
|
+
const stub = await stubFor(exampleState());
|
|
661
|
+
const { context, page } = await themedPage(browser, "dark", 1440);
|
|
662
|
+
try {
|
|
663
|
+
await page.goto(stub.url);
|
|
664
|
+
await expect(page.getByTestId("provider-card-codex")).toContainText("(est.)");
|
|
665
|
+
}
|
|
666
|
+
finally {
|
|
667
|
+
await context.close();
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|