mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* feat(billing): support Coding Plan usage and wallet fallback * fix(web): keep AMR send off billing preflight Workspace send no longer waits on advisory vela billing preflight; recovery still requests it. Realign zero-wallet P0 oracles and the credits-card wallet label. Generated-By: looper 0.15.0 (runner=fixer, agent=omp) * fix(billing): serialize recovery preflight and bound hung vela Skip overlapping AmrBalanceDialog recovery ticks and stop the watch after ten minutes even if a preflight is still pending. Give vela billing preflight a 60s runVelaCommand timeout so a hung CLI cannot pin recovery. Generated-By: looper 0.15.0 (runner=fixer, agent=omp) * fix(web): restore coding plan text contrast in billing card * feat(web): render Coding Plan windows and Go tier in the credits panel - CodingPlanUsage renders one row per preflight window (name, used %, remaining USD, reset countdown + absolute time) with loading/unavailable states; refreshes once on mount and once at resetsAt (no 30s polling, setTimeout delay clamped to the 32-bit limit). - Pure model in coding-plan-usage-model.ts; creditsPerUsd comes from `vela billing summary` through WorkspaceBillingSummary and falls back to 10,000 only when the backend omits it. - PlanWordmark/EntryNavRail accept the `go` tier (placeholder wordmark, design asset still pending). - i18n: 7 new billing keys + entry.billingTierGo across all 19 locales; real translations for the #8339 keys; drop unused codingPlanNone / codingPlanRemaining. - CLI wording: unstarted window reads "not started yet". * build(pack): pin vela-cli 0.1.4-test.0 and gate on billing preflight The packaged client needs `vela billing preflight` for the Coding Plan window rows. Bump the bundled CLI to the npm `test` prerelease that ships it and add a third capability gate in tools-pack: a CLI without the subcommand prints the parent `billing` help and exits 0, so the gate checks the help markers (preflight, --workspace-id, --model, --format) instead of the exit code. TEMPORARY: replace 0.1.4-test.0 with the stable 0.1.4 once it is released; do not merge with a `test` prerelease pinned. * fix(web): make Coding Plan rows legible on the inverted credits card The credits card is a hard-coded #202020/#fafafa inverted surface, but CodingPlanUsage.module.css read page-level --text-* / --bg-fill-* tokens, so on the card the window names rendered #202020 on #202020 (1.00:1) and the stat/reset lines sat below WCAG AA. Derive every colour from currentColor instead (name 100%, stat 72%, reset/note 60%, separator ~50%, track 36%, exhausted = --red mixed toward currentColor); measured in Chrome: name 15.6:1, stat 8.7:1, reset 6.4:1, track 3.2:1, exhausted 6.5:1. Keep the stat on one line (nowrap) and let the name ellipsize instead of wrapping "60% / used" onto two lines; drop the block's 12px side padding so it aligns with the card's own rows. Also add a minutes tier to the reset countdown: the last hour before a window resets used to show no countdown at all. New key billing.codingPlanResetsInMinutes in all 19 locales. * feat(web): rebuild the plan panel 1:1 from design PR #8364 Replace the improvised Coding Plan block with the design's panel, measured against docs/ui-previews/plan-panels/index.html in Chrome (computed styles and a 4x pixel diff of the Plus specimen): - Header: tier name + 16px wordmark, 升级 pill (管理 on the top tier). - One row 「7天额度免费用 · 已用 N% ›」 (7-day window only; rounded percent; 100% when spent, no red state) over a 5px #b1f38b track, then the separator and the 「钱包余额 · US$x.xx ›」 row. No headings, window names, remaining USD, reset time, note or unavailable line. - Team workspaces keep the pre-existing card unchanged (all team_* tiers); the quota row renders only for personal workspaces. - Free personal workspace: no quota row, but the header sells 升级 like the design (a personal workspace needs no billing permission; an account B reports without a plan upgrades as free). - Top-right pill shows the wordmark only, no balance. - Wallet always reads US$ regardless of locale (product ruling 2026-09-23). - zh-CN tier names follow the design: 免费版 / 标准版 / 专业版 / 旗舰版. - Drop the dead billing.codingPlan* keys and model helpers; add billing.codingPlanWeeklyAllowance and entry.creditsManage in 19 locales. * feat(web): show 管理 on personal Max per design PR #8364 The design's Max card sells 管理 (console auto-recharge settings) where every other tier sells 升级; product ruled on 2026-09-23 to follow the design for personal Max too, superseding the earlier "personal tiers are always upgradeable" ruling. The gate now asks isMaxPlanTier and 管理 wins over 升级 when both would show. * feat(web): route the plan card's three entries per product ruling 2026-09-23 ruling on the card's landings: 升级 opens the console's pricing dialog (billing=plan, unchanged), the allowance row opens the console dashboard (unchanged), the wallet row asks for the manual top-up dialog (new billing=recharge intent; B does not honor it yet and degrades to the dashboard), and 管理 on Max opens the plain dashboard instead of the auto-recharge dialog. * fix(web): align personal plan cards with v2 design and scoped loading * fix(web): center plan wordmarks inside the top-right pill * fix(web): reuse shared billing cache for plan hover cards * test(web): match personal billing preflight in avatar fixture * fix(billing): require usable quota evidence for funding recovery * test(billing): cover scoped preflight and quota event refresh end to end * feat(daemon): relay scoped coding plan usage events and health * fix(billing): refresh quota caches from scoped realtime events * test(daemon): accept capability query on mock event stream --------- Co-authored-by: lefarcen <935902669@qq.com>
232 lines
13 KiB
TypeScript
232 lines
13 KiB
TypeScript
import { createServer, type ServerResponse } from 'node:http';
|
|
|
|
import { expect, test } from '@/playwright/suite';
|
|
import { applyStandardMocks } from '@/playwright/mock-factory';
|
|
import { ensureRailOpen } from '@/playwright/rail';
|
|
import { T } from '@/timeouts';
|
|
|
|
// A browser witness for the presenter/shared-cache/SSE boundary. Billing JSON
|
|
// is injected at the HTTP edge; EventSource still consumes a real HTTP stream.
|
|
// This does not claim to exercise Vela billing or provider settlement.
|
|
test('[P1] personal credits reuse cached snapshots across hover, update while hidden over SSE, and survive a failed refresh', async ({ page }, testInfo) => {
|
|
test.setTimeout(T.xlong);
|
|
const streams = new Set<ServerResponse>();
|
|
const server = createServer((_req, res) => {
|
|
res.writeHead(200, {
|
|
'content-type': 'text/event-stream',
|
|
'cache-control': 'no-cache',
|
|
'access-control-allow-origin': '*',
|
|
});
|
|
res.write(': connected\n\n');
|
|
streams.add(res);
|
|
res.on('close', () => streams.delete(res));
|
|
});
|
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
const address = server.address();
|
|
if (!address || typeof address === 'string') throw new Error('SSE fixture port missing');
|
|
const eventUrl = `http://127.0.0.1:${address.port}/events`;
|
|
const scope = {
|
|
workspaceId: 'ws-cache-browser', workspaceMemberId: 'member-cache-browser',
|
|
workspaceName: 'Cache acceptance', workspaceType: 'personal',
|
|
role: 'owner', memberStatus: 'active', lifecycleState: 'active',
|
|
};
|
|
const context = {
|
|
...scope, billingState: 'active', planId: 'go', providerMode: 'platform_credits',
|
|
permissions: { canManageBilling: true, canManageMembers: true, canInviteMembers: true,
|
|
canManageAutoRecharge: true, canShareProjects: true, canWriteSyncedFiles: true,
|
|
canViewWorkspaceSettings: true, canManageSharedResources: true },
|
|
};
|
|
const team = { ...scope, workspaceId: 'ws-cache-team', workspaceMemberId: 'member-cache-team', workspaceType: 'team', workspaceName: 'Cache team' };
|
|
const teamContext = { ...context, ...team, planId: 'team_plus' };
|
|
let activeWorkspaceId = scope.workspaceId;
|
|
let remaining = 80;
|
|
let quotaHealthy = true;
|
|
let requests = 0;
|
|
let failNext = false;
|
|
let releaseCold!: () => void;
|
|
const coldResponse = new Promise<void>((resolve) => { releaseCold = resolve; });
|
|
let delayPersonal = false;
|
|
let releasePersonal: (() => void) | undefined;
|
|
let personalHeld: (() => void) | undefined;
|
|
const requestWitness: Array<{ count: number; remaining: number; failed: boolean }> = [];
|
|
function emit(eventId: string, options: { legacy?: boolean; memberId?: string } = {}) {
|
|
const type = options.legacy ? 'billing-changed' : 'coding-plan-usage-changed';
|
|
const payload = { type, workspaceId: scope.workspaceId,
|
|
...(options.legacy ? {} : { workspaceMemberId: options.memberId ?? scope.workspaceMemberId }),
|
|
eventId, at: new Date().toISOString() };
|
|
for (const stream of streams) stream.write(`event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`);
|
|
}
|
|
try {
|
|
await applyStandardMocks(page);
|
|
await page.route('**/api/integrations/vela/status*', (route) => route.fulfill({ json: {
|
|
loggedIn: true, profile: 'test', user: { id: 'cache-user', email: 'cache@example.invalid', plan: 'go' },
|
|
} }));
|
|
await page.route('**/api/workspace/directory', (route) => route.fulfill({ json: { items: [scope, team], activeWorkspaceId } }));
|
|
await page.route('**/api/workspace/context', (route) => route.fulfill({ json: { context: route.request().headers()['x-od-workspace-id'] === team.workspaceId ? teamContext : context } }));
|
|
await page.route('**/api/workspace/active', (route) => {
|
|
activeWorkspaceId = route.request().postDataJSON().workspaceId;
|
|
return route.fulfill({ json: { activeWorkspaceId, context: activeWorkspaceId === team.workspaceId ? teamContext : context } });
|
|
});
|
|
await page.route('**/api/workspace/billing/interests/*', (route) => route.fulfill({ json: route.request().method() === 'DELETE' ? { ok: true, released: true } : {
|
|
clientId: decodeURIComponent(new URL(route.request().url()).pathname.split('/').at(-1)!),
|
|
acceptedGeneration: route.request().postDataJSON().generation,
|
|
leaseExpiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
} }));
|
|
await page.route('**/api/projects*', (route) => route.fulfill({ json: { projects: [] } }));
|
|
await page.route('**/api/workspace/events*', (route) => route.fulfill({ status: 307, headers: { location: eventUrl } }));
|
|
await page.route('**/api/workspace/billing?*', async (route) => {
|
|
const selected = new URL(route.request().url()).searchParams.get('workspaceId') === team.workspaceId ? team : scope;
|
|
const balanceUsd = selected === team ? '42.00' : '0.00';
|
|
const failed = failNext;
|
|
failNext = false;
|
|
requests += 1;
|
|
requestWitness.push({ count: requests, remaining, failed });
|
|
await coldResponse;
|
|
if (failed) { await route.fulfill({ status: 503, json: { error: 'fixture_billing_unavailable' } }); return; }
|
|
if (delayPersonal && selected === scope) {
|
|
await new Promise<void>((resolve) => { releasePersonal = resolve; personalHeld?.(); });
|
|
}
|
|
const generatedAt = await page.evaluate(() => new Date().toISOString());
|
|
await route.fulfill({ json: {
|
|
summary: { workspaceId: null, membershipTier: 'go', subscriptionStatus: 'active', balanceUsd: '999.00', totalAvailableCredits: 999, subscriptionCredits: 0, rechargeCredits: 0, availableActions: [], workspaceBalance: null },
|
|
workspaceBalance: { ...selected, balanceUsd, billingScopeVersion: 2, updatedAt: generatedAt, expiresAt: null },
|
|
quotaRealtime: { healthy: quotaHealthy },
|
|
workspaceRuntime: { workspaceId: selected.workspaceId, workspaceMemberId: selected.workspaceMemberId,
|
|
status: 'fresh', revision: String(requests), observedAt: generatedAt,
|
|
softExpiresAt: new Date(Date.parse(generatedAt) + 30_000).toISOString(),
|
|
hardExpiresAt: new Date(Date.parse(generatedAt) + 600_000).toISOString(),
|
|
retryAt: null, errorCode: null, reason: 'browser-fixture', sourceGapDetected: false },
|
|
preflight: {
|
|
schemaVersion: 1, workspaceId: selected.workspaceId, workspaceMemberId: selected.workspaceMemberId,
|
|
modelId: null, generatedAt, balanceUsd, funding: 'coding_plan', modelCovered: null,
|
|
codingPlan: { workspaceId: selected.workspaceId, generatedAt, eligible: true, tier: 'go', windows: [18000, 604800].map((durationSeconds) => ({
|
|
policyId: `quota-${durationSeconds}`, durationSeconds, resetMode: 'activity_triggered',
|
|
usedCredits: String(100 - remaining), remainingCredits: String(remaining), limitCredits: '100',
|
|
windowStart: null, resetsAt: null,
|
|
})) },
|
|
},
|
|
} });
|
|
});
|
|
await page.clock.install();
|
|
const initialBilling = page.waitForRequest((request) => request.url().includes('/api/workspace/billing?'));
|
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
|
const pill = page.getByTestId('entry-top-right-credits');
|
|
const panel = page.getByTestId('entry-top-right-credits-panel');
|
|
await initialBilling;
|
|
// The entry currently withholds the pill itself until its first billing
|
|
// snapshot resolves. A component's cold skeleton is not a browser witness.
|
|
await expect(pill).toHaveCount(0);
|
|
releaseCold();
|
|
await expect(pill).toBeVisible({ timeout: T.long });
|
|
await pill.hover();
|
|
await expect(panel).toBeVisible();
|
|
await expect(panel.getByRole('progressbar')).toHaveCount(2);
|
|
await expect(panel.getByRole('progressbar').first()).toHaveAttribute('aria-valuenow', '80');
|
|
await expect(panel).toContainText('US$0.00');
|
|
await expect.poll(() => streams.size).toBeGreaterThan(0);
|
|
|
|
// Use the real pointer path: these events mount/unmount the credits card.
|
|
const beforeHover = requests;
|
|
for (let i = 0; i < 5; i += 1) {
|
|
await page.mouse.move(10, 400);
|
|
await expect(panel).toHaveCount(0);
|
|
await pill.hover();
|
|
await expect(panel.getByRole('progressbar').first()).toHaveAttribute('aria-valuenow', '80');
|
|
await expect(page.getByTestId('coding-plan-quota-skeleton')).toHaveCount(0);
|
|
}
|
|
expect(requests).toBe(beforeHover);
|
|
const afterHover = requests;
|
|
|
|
await page.clock.pauseAt(new Date(Date.now() + 1_000));
|
|
const beforeHealthyFloor = requests;
|
|
await page.clock.runFor(30_001);
|
|
expect(requests, 'healthy upstream quota events + local SSE suppress 30s polling').toBe(beforeHealthyFloor);
|
|
|
|
await page.mouse.move(10, 400);
|
|
await page.clock.runFor(200);
|
|
await expect(panel).toHaveCount(0);
|
|
remaining = 70;
|
|
const refreshed = page.waitForResponse((response) => response.url().includes('/api/workspace/billing?') && response.status() === 200);
|
|
const beforeQuota = requests;
|
|
emit('wrong-member', { memberId: 'other-member' });
|
|
emit('usage-70');
|
|
emit('usage-70');
|
|
await refreshed;
|
|
await pill.hover();
|
|
await expect(panel.getByRole('progressbar').first()).toHaveAttribute('aria-valuenow', '70');
|
|
await expect(page.getByTestId('coding-plan-quota-skeleton')).toHaveCount(0);
|
|
expect(requests, 'foreign member and duplicate quota events must not create extra billing reads').toBe(beforeQuota + 1);
|
|
await expect(panel).toContainText('US$0.00');
|
|
|
|
// Older clients receive this backend compatibility projection without a
|
|
// wallet change, revision or clock. It must still re-read preflight.
|
|
remaining = 65;
|
|
emit('legacy-65', { legacy: true });
|
|
await expect(panel.getByRole('progressbar').first()).toHaveAttribute('aria-valuenow', '65');
|
|
|
|
quotaHealthy = false;
|
|
emit('capability-unavailable', { legacy: true });
|
|
await expect.poll(() => requests).toBeGreaterThan(beforeQuota + 2);
|
|
await page.mouse.move(10, 400);
|
|
await page.clock.runFor(200);
|
|
await expect(panel).toHaveCount(0);
|
|
remaining = 63;
|
|
const polled = page.waitForResponse((response) => response.url().includes('/api/workspace/billing?') && response.status() === 200);
|
|
await page.clock.runFor(30_001);
|
|
await polled;
|
|
await pill.hover();
|
|
await expect(panel.getByRole('progressbar').first()).toHaveAttribute('aria-valuenow', '63');
|
|
await expect(page.getByTestId('coding-plan-quota-skeleton')).toHaveCount(0);
|
|
quotaHealthy = true;
|
|
|
|
failNext = true;
|
|
const failedResponse = page.waitForResponse((response) => response.url().includes('/api/workspace/billing?') && response.status() === 503);
|
|
emit('temporary-outage');
|
|
await failedResponse;
|
|
await expect(panel.getByRole('progressbar').first()).toHaveAttribute('aria-valuenow', '63');
|
|
await expect(panel).toContainText('US$0.00');
|
|
await expect(page.getByTestId('coding-plan-quota-skeleton')).toHaveCount(0);
|
|
remaining = 60;
|
|
emit('recovered-60');
|
|
await expect(panel.getByRole('progressbar').first()).toHaveAttribute('aria-valuenow', '60');
|
|
await testInfo.attach('recovered-go', { body: await page.screenshot(), contentType: 'image/png' });
|
|
await page.clock.resume();
|
|
|
|
delayPersonal = true;
|
|
const held = new Promise<void>((resolve) => { personalHeld = resolve; });
|
|
emit('hold-personal-before-team');
|
|
await held;
|
|
await page.mouse.move(10, 400);
|
|
await page.clock.runFor(200);
|
|
await ensureRailOpen(page);
|
|
await page.getByTestId('workspace-switcher').click();
|
|
await page.getByTestId('workspace-switcher-list').getByText('Cache team', { exact: true }).click();
|
|
await expect.poll(() => activeWorkspaceId).toBe(team.workspaceId);
|
|
await pill.hover();
|
|
await expect(panel).toContainText('$42.00');
|
|
const latePersonal = page.waitForResponse((response) => response.url().includes(`/api/workspace/billing?scope=workspace&workspaceId=${scope.workspaceId}`));
|
|
delayPersonal = false;
|
|
releasePersonal!();
|
|
await latePersonal;
|
|
await expect(panel).toContainText('$42.00');
|
|
await expect(panel).not.toContainText('US$0.00');
|
|
await expect(panel.getByRole('progressbar')).toHaveCount(0);
|
|
await testInfo.attach('scope-switch-late-personal', { body: await page.screenshot(), contentType: 'image/png' });
|
|
await testInfo.attach('billing-request-witness', { body: JSON.stringify({ beforeHover, afterHover, requestWitness }, null, 2), contentType: 'application/json' });
|
|
} catch (error) {
|
|
await testInfo.attach('billing-failure-state', {
|
|
body: JSON.stringify({ requests, requestWitness, html: await page.locator('body').innerText().catch(() => 'page unavailable') }, null, 2),
|
|
contentType: 'application/json',
|
|
});
|
|
if (!page.isClosed()) await testInfo.attach('billing-failure-screen', { body: await page.screenshot(), contentType: 'image/png' });
|
|
throw error;
|
|
} finally {
|
|
releaseCold();
|
|
releasePersonal?.();
|
|
await page.unrouteAll({ behavior: 'ignoreErrors' });
|
|
await page.close();
|
|
for (const stream of streams) stream.end();
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
}
|
|
});
|