mirror of
https://github.com/hanzoai/chat.git
synced 2026-09-30 20:51:17 +00:00
Three defects the CTO saw, each one an element saying something untrue.
COMPOSER FOCUS. `focus-within:border-text-secondary` flipped the answer
composer's 1px border to a near-white TEXT token, painting a hard bright
rectangle whose square corners fought the 24px radius — it read as a second
border stacked inside the rounded one. ChatForm already had the right language
(steady border, shadow-md -> shadow-lg on focus), so both composers now share
it: focus reads as a lift, not a second box.
EMPTY BORDERED BOX in the sidebar. It was a loading skeleton that could never
resolve. `useSearchEnabled` runs its query with `enabled: isAuthenticated`, so
for a signed-out visitor it never runs, no branch fires, and `search.enabled`
stays at its initial `null` — which is exactly the value Nav renders
`SearchBarSkeleton` for. Not-authenticated is a KNOWN answer (no search), not a
pending one, so say so and the skeleton stops shimmering forever.
ERROR FALLBACK. `Error.tsx` ended in "Here's the specific error message we
encountered: ${text}", which made every unmapped shape a leak. Two shipped that
way: Passport's bare `Unauthorized`, then a gateway `402 a billable tenant is
required (no anonymous usage)`. Enumerating shapes is fine where they genuinely
say something different to the reader; what had to change is where the
enumeration MISSES. The fallback is now a human sentence. The raw text is not
lost — useResumableSSE already logs it verbatim.
Tests: the Error spec previously asserted the OLD contract ("still reports an
unrecognized error verbatim") — that test encoded the leak as intent, so it is
replaced by two that pin the absence, including the exact no-`type` 402 body
that reached production. guest.spec.ts gains two geometry checks: the footer
must intersect nothing (numeric rect overlap — pointer-events cannot move a box,
only layout can), and the sidebar must hold no permanently-empty bordered box.
Co-authored-by: Hanzo Dev <[email protected]>
166 lines
7.1 KiB
TypeScript
166 lines
7.1 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
|
|
|
/**
|
|
* What a signed-out stranger actually SEES after sending a chat message.
|
|
*
|
|
* A 200 proves a server answered, not that a human got what they needed: this
|
|
* whole class of failure arrives as the assistant's reply inside a perfectly
|
|
* healthy 200 SSE stream. So every assertion is on rendered text, and the
|
|
* load-bearing one is an ABSENCE — the raw upstream body must never reach the
|
|
* reader. That leak has shipped twice in two shapes (`Unauthorized`, then a
|
|
* bare 402 "a billable tenant is required"), so this pins the CLASS of string
|
|
* rather than either instance.
|
|
*
|
|
* Runs against E2E_BASE_URL, defaulting to the local stack.
|
|
*/
|
|
const BASE = process.env.E2E_BASE_URL ?? 'http://localhost:3080';
|
|
|
|
/** Reads like plumbing, not like a sentence written for a person. */
|
|
const RAW_UPSTREAM = [
|
|
/\b402\b/,
|
|
/billable tenant/i,
|
|
/no anonymous usage/i,
|
|
/invalid API key/i,
|
|
/An error occurred while processing/i,
|
|
/Here's the specific error message/i,
|
|
/\bUnauthorized\b/,
|
|
/GUEST_LIMIT/,
|
|
];
|
|
|
|
/** A signed-out visitor, always — never a session this machine happens to hold. */
|
|
test.use({ storageState: { cookies: [], origins: [] } });
|
|
|
|
/** Land on the chat surface: the composer lives behind the Chat mode chip. */
|
|
async function openGuestChat(page: import('@playwright/test').Page) {
|
|
await page.goto(`${BASE}/c/new`, { waitUntil: 'domcontentloaded' });
|
|
const chatChip = page.getByRole('button', { name: 'Chat', exact: true }).first();
|
|
await chatChip.waitFor({ state: 'visible', timeout: 90000 });
|
|
await chatChip.click();
|
|
const composer = page.locator('textarea').first();
|
|
await composer.waitFor({ state: 'visible', timeout: 30000 });
|
|
return composer;
|
|
}
|
|
|
|
test.describe('Signed-out visitor', () => {
|
|
test('never sees a raw upstream error as the assistant reply', async ({ page }) => {
|
|
const composer = await openGuestChat(page);
|
|
await composer.fill('hello');
|
|
await composer.press('Enter');
|
|
await page.waitForTimeout(15000);
|
|
|
|
const body = await page.locator('body').innerText();
|
|
for (const pattern of RAW_UPSTREAM) {
|
|
expect(body, `raw upstream text leaked to the reader: ${pattern}`).not.toMatch(pattern);
|
|
}
|
|
|
|
// There must be a way forward, and it must be clickable AT ITS CENTER —
|
|
// an affordance an overlay eats is not an affordance.
|
|
const signIn = page
|
|
.getByRole('button', { name: /sign in|log in/i })
|
|
.or(page.getByRole('link', { name: /sign in|log in/i }))
|
|
.first();
|
|
await expect(signIn).toBeVisible({ timeout: 15000 });
|
|
const box = (await signIn.boundingBox())!;
|
|
const ownsCenter = await page.evaluate(
|
|
([x, y]) => !!document.elementFromPoint(x, y)?.closest('a,button'),
|
|
[box.x + box.width / 2, box.y + box.height / 2],
|
|
);
|
|
expect(ownsCenter, 'sign-in affordance is occluded at its center').toBe(true);
|
|
});
|
|
|
|
/**
|
|
* EXPECTED TO FAIL while guest chat is broken upstream.
|
|
*
|
|
* The K8s secret chat-guest-key/API_KEY currently holds a copy of
|
|
* chat-secrets/OPENAI_API_KEY (verified identical by sha256), not the
|
|
* `hk-<uuid>` IAM accessKey that operator/crs/chat-guest-key-kms-sync.yaml
|
|
* documents, so the gateway answers "invalid API key" and no tokens ever
|
|
* arrive. `test.fail()` makes that state loud instead of silent: when the KMS
|
|
* path is reseeded this test starts PASSING, Playwright reports "expected to
|
|
* fail but passed", and whoever fixed it deletes this annotation.
|
|
*
|
|
* The assertion is growth across samples, because one blob at t≈1100ms and a
|
|
* real stream are indistinguishable by status code — that difference is
|
|
* exactly what caught this.
|
|
*/
|
|
test('streams tokens incrementally rather than one instant blob', async ({ page }) => {
|
|
test.fail(); // scoped to THIS test — see the note above.
|
|
const composer = await openGuestChat(page);
|
|
await composer.fill('Count slowly from one to twenty.');
|
|
await composer.press('Enter');
|
|
|
|
const lengths: number[] = [];
|
|
for (let i = 0; i < 30; i++) {
|
|
await page.waitForTimeout(500);
|
|
lengths.push((await page.locator('body').innerText()).length);
|
|
}
|
|
|
|
// Distinct increases, not one jump: a stream grows repeatedly.
|
|
let steps = 0;
|
|
for (let i = 1; i < lengths.length; i++) {
|
|
if (lengths[i] > lengths[i - 1]) steps++;
|
|
}
|
|
expect(steps, `text grew in ${steps} step(s); a stream grows across many`).toBeGreaterThan(2);
|
|
});
|
|
|
|
/**
|
|
* The footer must OCCUPY space, not cover its neighbours.
|
|
*
|
|
* `pointer-events-none` stopped the strip eating clicks, but pointer-events
|
|
* cannot move a box: it still painted over "New search" (a ~9px vertical
|
|
* intersection) and rendered as collided text. Laying it out in flow fixes
|
|
* that, and only a numeric rect check can prove it stayed fixed.
|
|
*/
|
|
test('footer occupies its own space and intersects nothing', async ({ page }) => {
|
|
await openGuestChat(page);
|
|
|
|
const collisions = await page.evaluate(() => {
|
|
const footer = document.querySelector('[role="contentinfo"]');
|
|
if (!footer) return { found: false, hits: [] as string[] };
|
|
const fr = footer.getBoundingClientRect();
|
|
const hits: string[] = [];
|
|
for (const el of Array.from(document.querySelectorAll('button, a, textarea, h1, h2, p'))) {
|
|
if (footer.contains(el) || el.contains(footer)) continue;
|
|
const r = el.getBoundingClientRect();
|
|
if (r.width === 0 || r.height === 0) continue;
|
|
const y = Math.min(fr.bottom, r.bottom) - Math.max(fr.top, r.top);
|
|
const x = Math.min(fr.right, r.right) - Math.max(fr.left, r.left);
|
|
if (y > 0 && x > 0) {
|
|
hits.push(`${el.tagName.toLowerCase()}"${(el.textContent || '').trim().slice(0, 30)}" ${Math.round(y)}px`);
|
|
}
|
|
}
|
|
return { found: true, hits };
|
|
});
|
|
|
|
expect(collisions.found, 'no footer rendered').toBe(true);
|
|
expect(collisions.hits, `footer overlaps: ${collisions.hits.join(', ')}`).toEqual([]);
|
|
});
|
|
|
|
/** A loading skeleton that can never resolve is an empty box, not a promise. */
|
|
test('sidebar shows no permanently-empty bordered box', async ({ page }) => {
|
|
await openGuestChat(page);
|
|
await page.waitForTimeout(6000); // well past any real settle
|
|
|
|
const ghosts = await page.evaluate(() => {
|
|
const nav = document.querySelector('nav');
|
|
if (!nav) return [];
|
|
const out: string[] = [];
|
|
for (const el of Array.from(nav.querySelectorAll('div, input, form'))) {
|
|
const r = el.getBoundingClientRect();
|
|
if (r.width < 80 || r.height < 20 || r.height > 80) continue;
|
|
const cs = getComputedStyle(el);
|
|
const bordered =
|
|
parseFloat(cs.borderTopWidth) > 0 ||
|
|
(cs.backgroundColor && cs.backgroundColor !== 'rgba(0, 0, 0, 0)');
|
|
if (!bordered) continue;
|
|
if ((el.textContent || '').trim() !== '') continue;
|
|
if (el.querySelector('svg, img, input, textarea')) continue;
|
|
out.push(`${el.tagName.toLowerCase()}.${String(el.className).slice(0, 50)} ${Math.round(r.width)}x${Math.round(r.height)}`);
|
|
}
|
|
return out;
|
|
});
|
|
|
|
expect(ghosts, `empty bordered box(es) in the sidebar: ${ghosts.join(' | ')}`).toEqual([]);
|
|
});
|
|
});
|