Over Platform's 66dbea9 key-presence seam, the five owner-locked tasks: 1. Zero-keys pill — components/setup-cta.tsx: pure hasProviderKey (classifyKeys reuse — tool keys like Deepgram never count), useZeroKeys (GET /api/keys, re-read on the new lh:settings-saved 'keys' signal), ZeroKeysPill (testid setup-cta-keys) taking PRECEDENCE over the models CTA on chat/home/new-session. SettingsSection gains 'keys'; keys-section dispatches after save AND delete. 2. Setup stage 1 is self-contained: provider picker (MenuShell + ProviderMenuList) + key paste + Save via PUT /api/keys/:id — the dialog STAYS MOUNTED; its own save dispatches the keys signal, the fetch effect re-runs on it, and the stage flips to the pickers on its own. onOpenKeys + setup-open-keys die. SetupKeysBody exported for SSR pins. 3. parseProviderKeyRefusal (chat.tsx, envelope-verbatim twin of parseModelsRefusal) joins the revert-draft-and-open-setup interception; activity-dock reads 'needs a provider key'; dispatch-card shows 'Add a key' — both deep-link setup, which lands on stage 1 by itself. 4. Dictation trio: the token route's only 503 (missing Deepgram key) becomes the client-authored DICTATION_KEYLESS short line; the errored idle click clears the error and deep-links Settings -> Keys -> DEEPGRAM_API_KEY via the new open(section, row) seam (dialog landOnRow -> SettingsDialogBody initialRow -> KeysSection openRow follow-effect). Live-verified on :3001 under LH_IGNORE_ENV_KEYS=1 (the fresh-user switch): pill on all three surfaces + precedence over the CTA, stage-1 save -> auto-advance with dialog mounted, real 503 send refusal reverting the draft and opening setup, dictation 503 -> short line -> Settings lands on the unfolded Deepgram row with the error cleared, keys-section save and delete both flipping the pill live, and a fabricated refused research job rendering the 'Add a key' dispatch card. 926/926, tsc/lint/build green.
118 lines
4.7 KiB
TypeScript
118 lines
4.7 KiB
TypeScript
/**
|
|
* The zero-keys wave's pure seams (owner-locked over Platform's 66dbea9
|
|
* key-presence gate, 2026-09-25): the provider-key truth (hasProviderKey
|
|
* — tool keys don't count, they unlock no models), the pill's SSR shape,
|
|
* and the SetupDialog's self-contained first-key screen (the dialog STAYS
|
|
* MOUNTED after a save; these pins hold the screen's structure).
|
|
*/
|
|
|
|
import assert from "node:assert/strict";
|
|
import { describe, it } from "node:test";
|
|
import { createElement } from "react";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
import { hasProviderKey, ZeroKeysPill } from "../components/setup-cta.tsx";
|
|
import { SetupKeysBody } from "../components/setup-dialog.tsx";
|
|
import { DICTATION_KEYLESS, DEEPGRAM_KEY_ID } from "../components/dictation.tsx";
|
|
import type { AuthKeyStatus } from "../src/core/auth-keys.ts";
|
|
import type { ProviderKeyInfo } from "../components/settings/keys-section.tsx";
|
|
|
|
const status = (id: string, present: boolean, source: AuthKeyStatus["source"] = present ? "file" : "none"): AuthKeyStatus => ({
|
|
id,
|
|
info: null,
|
|
present,
|
|
masked: present ? "…ab12 (64 chars)" : null,
|
|
source,
|
|
shadowedByEnv: false,
|
|
});
|
|
|
|
const PROVIDERS: ProviderKeyInfo[] = [
|
|
{ id: "openrouter", envVar: "OPENROUTER_API_KEY", label: "OpenRouter" },
|
|
{ id: "anthropic", envVar: "ANTHROPIC_API_KEY", label: "Anthropic" },
|
|
];
|
|
|
|
describe("hasProviderKey — the zero-keys pill's truth", () => {
|
|
it("a present provider key (env or file) is a key", () => {
|
|
assert.equal(hasProviderKey([status("OPENROUTER_API_KEY", true, "env")], PROVIDERS), true);
|
|
assert.equal(hasProviderKey([status("ANTHROPIC_API_KEY", true, "file")], PROVIDERS), true);
|
|
});
|
|
|
|
it("a TOOL key alone does not count — Deepgram unlocks no models", () => {
|
|
// Deepgram is not in the providers roster: it must never suppress the pill.
|
|
assert.equal(hasProviderKey([status("DEEPGRAM_API_KEY", true)], PROVIDERS), false);
|
|
});
|
|
|
|
it("absent provider keys = zero keys (the pill shows)", () => {
|
|
assert.equal(hasProviderKey([status("OPENROUTER_API_KEY", false)], PROVIDERS), false);
|
|
assert.equal(hasProviderKey([], PROVIDERS), false);
|
|
});
|
|
});
|
|
|
|
describe("ZeroKeysPill — the composer-hoisted notice", () => {
|
|
it("the pill takes the CTA's chrome with its own testid and copy", () => {
|
|
const html = renderToStaticMarkup(createElement(ZeroKeysPill, { onOpen: () => {} }));
|
|
assert.ok(html.includes('data-testid="setup-cta-keys"'));
|
|
assert.ok(html.includes("Add a provider key"));
|
|
// the models CTA's testid must not appear on the keys pill
|
|
assert.ok(!html.includes('data-testid="setup-cta"'));
|
|
});
|
|
});
|
|
|
|
describe("SetupKeysBody — stage 1's self-contained first-key screen", () => {
|
|
const base = {
|
|
value: "",
|
|
saving: false,
|
|
error: null,
|
|
menuOpen: false,
|
|
onToggleMenu: () => {},
|
|
onPick: () => {},
|
|
onValue: () => {},
|
|
onSave: () => {},
|
|
menuAnchor: { current: null },
|
|
};
|
|
|
|
it("before a pick: the provider trigger, no key input yet", () => {
|
|
const html = renderToStaticMarkup(
|
|
createElement(SetupKeysBody, { ...base, providers: PROVIDERS, provider: null }),
|
|
);
|
|
assert.match(html, /First, add a provider key\./);
|
|
assert.ok(html.includes("The model pickers list only what your keys unlock"));
|
|
assert.ok(html.includes('data-testid="setup-key-provider"'));
|
|
assert.ok(html.includes("Pick a provider…"));
|
|
assert.ok(!html.includes('data-testid="setup-key-input"'), "no paste field before a provider is picked");
|
|
});
|
|
|
|
it("after a pick: the paste input + Save join", () => {
|
|
const html = renderToStaticMarkup(
|
|
createElement(SetupKeysBody, { ...base, providers: PROVIDERS, provider: PROVIDERS[0]! }),
|
|
);
|
|
assert.ok(html.includes('data-testid="setup-key-input"'));
|
|
assert.ok(html.includes('data-testid="setup-key-save"'));
|
|
assert.ok(html.includes("Save key"));
|
|
assert.ok(html.includes("OpenRouter"), "the chosen provider's label rides the trigger");
|
|
});
|
|
|
|
it("a shape-gate 400 keeps its line inline — no Settings round-trip chrome", () => {
|
|
const html = renderToStaticMarkup(
|
|
createElement(SetupKeysBody, {
|
|
...base,
|
|
providers: PROVIDERS,
|
|
provider: PROVIDERS[0]!!,
|
|
error: "Expected 20+ characters",
|
|
}),
|
|
);
|
|
assert.ok(html.includes('data-testid="setup-key-error"'));
|
|
assert.ok(html.includes("Expected 20+ characters"));
|
|
// the old "Open Keys settings" round-trip died with the self-contained screen
|
|
assert.ok(!html.includes("setup-open-keys"));
|
|
});
|
|
});
|
|
|
|
describe("the dictation keyless seam (owner dictation trio)", () => {
|
|
it("the client-authored short line — not the route's dev-speak", () => {
|
|
assert.equal(DICTATION_KEYLESS, "Dictation needs a Deepgram key");
|
|
});
|
|
|
|
it("the Deepgram row id matches the keys store (KeysSection openRow)", () => {
|
|
assert.equal(DEEPGRAM_KEY_ID, "DEEPGRAM_API_KEY");
|
|
});
|
|
}); |