Skip to content

Commit 40786b8

Browse files
aksOpsclaude
andcommitted
chore(dev-harness): overflow seed, deepseek AI toggle, generic LLM CORS proxy
Dev-only (e2e/harness, not published): ?seed=N seeds varied issues for overflow/preview testing, ?ai=1 enables AI with the dev proxy injecting the local Ollama key, and a same-origin /llmx/<host> proxy makes any OpenAI-compatible provider reachable from the browser dev preview. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RVxYQbH2FUj7AHZ4RCSdTA
1 parent 4d76d08 commit 40786b8

3 files changed

Lines changed: 194 additions & 16 deletions

File tree

e2e/harness/dev-main.tsx

Lines changed: 64 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,74 @@ import { installFetchMock } from "./devmock";
33
import { Tracker } from "../../src/index";
44
import { createTokenStore } from "../../src/auth/tokenStore";
55

6-
// Interactive dev launch: mock GitLab in the browser and seed a token so the
7-
// app boots straight to a populated board. Sign-out still works (it returns to
8-
// the connect screen; re-entering any token re-enters via the mocked /user).
6+
// Interactive dev launch. Mode is chosen by URL query:
7+
//
8+
// (default) LIVE — talk to a real GitLab. instanceUrl defaults to
9+
// https://gitlab.com (override with ?instance=https://gitlab.example).
10+
// personalProjectId is fixed at mount, so pass your project's numeric id
11+
// with ?project=<id> (GitLab → project home → ⋯ menu → "Copy project ID").
12+
// Sign in with a Personal Access Token (scope: api) on the connect screen.
13+
//
14+
// ?mock=1 — in-browser GitLab mock + a seeded token: boots straight to a
15+
// populated fake board. Non-GitLab requests still hit the network, so a
16+
// real LLM key in Settings exercises the live streaming paths.
917
async function boot(): Promise<void> {
10-
installFetchMock();
11-
try {
12-
const ts = await createTokenStore();
13-
await ts.set({ kind: "pat", token: "dev-token" });
14-
} catch {
15-
/* IndexedDB unavailable — the connect screen will handle sign-in */
16-
}
18+
const q = new URLSearchParams(location.search);
1719
const el = document.getElementById("root");
1820
if (!el) return;
21+
22+
// ---- mock mode -------------------------------------------------------
23+
if (q.get("mock") === "1") {
24+
// ?ai=1 → turn on AI with deepseek-v4-flash via the key-injecting dev proxy
25+
// (no key needed in the browser; the proxy adds the box's Ollama key).
26+
if (q.get("ai") === "1") {
27+
try {
28+
localStorage.setItem(
29+
"lane.llm.config",
30+
JSON.stringify({
31+
enabled: true,
32+
baseUrl: "https://lane.randomcodespace.dev/llm/v1",
33+
model: "deepseek-v4-flash",
34+
temperature: 0.4,
35+
}),
36+
);
37+
} catch {
38+
/* localStorage unavailable — Settings can still enable AI manually */
39+
}
40+
}
41+
installFetchMock();
42+
try {
43+
const ts = await createTokenStore();
44+
await ts.set({ kind: "pat", token: "dev-token" });
45+
} catch {
46+
/* IndexedDB unavailable — the connect screen will handle sign-in */
47+
}
48+
createRoot(el).render(
49+
<Tracker instanceUrl="https://gitlab.test" personalProjectId={42} llm={{}} />,
50+
);
51+
return;
52+
}
53+
54+
// ---- live mode (default) ---------------------------------------------
55+
const instanceUrl = q.get("instance") ?? "https://gitlab.com";
56+
const projectId = Number(q.get("project"));
57+
if (!Number.isInteger(projectId) || projectId <= 0) {
58+
el.innerHTML = [
59+
'<div style="font:14px/1.6 system-ui,sans-serif;max-width:42rem;margin:12vh auto;padding:0 1.5rem;color:#d7dbe0">',
60+
'<h1 style="font-size:1.15rem;margin:0 0 .75rem">Lane dev — live GitLab</h1>',
61+
`<p style="margin:.4rem 0">Connecting to <code>${instanceUrl}</code>. Add your project id to the URL:</p>`,
62+
'<p style="margin:.4rem 0"><code>?project=&lt;numeric id&gt;</code> — e.g. <code>?project=12345678</code></p>',
63+
'<p style="margin:.9rem 0 .4rem;opacity:.85">Find the id on your GitLab project’s home page (under the title, or the ⋯ menu → <em>Copy project ID</em>).</p>',
64+
'<p style="margin:.4rem 0;opacity:.85">Then sign in with a Personal Access Token (scope <code>api</code>) on the connect screen.</p>',
65+
'<p style="margin:1.1rem 0 0;opacity:.6">Tip: append <code>&amp;mock=1</code> (or use <code>?mock=1</code>) to boot the offline mock board instead.</p>',
66+
"</div>",
67+
].join("");
68+
return;
69+
}
70+
71+
// llm={{}} exposes the AI section in Settings (default-off).
1972
createRoot(el).render(
20-
<Tracker instanceUrl="https://gitlab.test" personalProjectId={42} />,
73+
<Tracker instanceUrl={instanceUrl} personalProjectId={projectId} llm={{}} />,
2174
);
2275
}
2376

e2e/harness/dev.vite.config.ts

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,95 @@
1-
import { defineConfig, mergeConfig, type UserConfig } from "vite";
1+
import { defineConfig, mergeConfig, type Plugin, type UserConfig } from "vite";
2+
import { request as httpsRequest } from "node:https";
3+
import type { IncomingMessage, ServerResponse } from "node:http";
24
import base from "./vite.config";
35

46
// Dev-only: same harness as vite.config.ts, but allows the reverse-proxied
5-
// public host and routes HMR over the HTTPS tunnel. Kept separate so the
6-
// Playwright config stays clean.
7+
// public host, routes HMR over the HTTPS tunnel, and adds a same-origin LLM
8+
// CORS shim. Kept separate so the Playwright config stays clean.
9+
10+
// Most LLM APIs (ollama, OpenAI, Groq, Together, …) send no CORS headers, so a
11+
// browser can't POST to them directly. This middleware lets the browser call a
12+
// same-origin path; the dev server forwards it to the real API, streaming both
13+
// ways (SSE-safe) and preserving the Authorization: Bearer <key> header.
14+
// Base URL = https://lane.randomcodespace.dev/llmx/<host>/<suffix> (https assumed)
15+
// e.g. …/llmx/api.openai.com/v1 · …/llmx/api.groq.com/openai/v1 · …/llmx/ollama.com/v1
16+
// Hop-by-hop response headers are dropped so Node re-frames the stream itself.
17+
const HOP_BY_HOP = new Set([
18+
"connection", "keep-alive", "transfer-encoding", "upgrade",
19+
"te", "trailer", "proxy-authenticate", "proxy-authorization",
20+
]);
21+
22+
function llmCorsProxy(): Plugin {
23+
return {
24+
name: "lane-dev-llm-cors-proxy",
25+
configureServer(server) {
26+
server.middlewares.use(
27+
(req: IncomingMessage, res: ServerResponse, next: (err?: unknown) => void) => {
28+
const m = (req.url ?? "").match(/^\/llmx\/([^/]+)(\/.*)?$/);
29+
if (!m) return next();
30+
const host = m[1];
31+
if (!host) return next();
32+
const path = m[2] ?? "/";
33+
const [hostname, port] = host.split(":");
34+
const headers = { ...req.headers, host };
35+
// Dev convenience: inject the box's Ollama key when targeting ollama and
36+
// the browser sent none, so AI works without pasting a key in the browser.
37+
if (hostname === "ollama.com" && process.env.OLLAMA_API_KEY && !headers.authorization) {
38+
headers.authorization = `Bearer ${process.env.OLLAMA_API_KEY}`;
39+
}
40+
const upstream = httpsRequest(
41+
{
42+
hostname,
43+
port: port ? Number(port) : 443,
44+
path,
45+
method: req.method,
46+
headers,
47+
servername: hostname, // SNI for the real host, not lane.…
48+
},
49+
(up) => {
50+
res.statusCode = up.statusCode ?? 502;
51+
for (const [k, v] of Object.entries(up.headers)) {
52+
if (v !== undefined && !HOP_BY_HOP.has(k)) res.setHeader(k, v);
53+
}
54+
up.pipe(res);
55+
},
56+
);
57+
upstream.on("error", (e) => {
58+
res.statusCode = 502;
59+
res.end(`llmx proxy error: ${(e as Error).message}`);
60+
});
61+
req.pipe(upstream);
62+
},
63+
);
64+
},
65+
};
66+
}
67+
768
export default mergeConfig(
869
base as UserConfig,
970
defineConfig({
71+
plugins: [llmCorsProxy()],
1072
server: {
1173
allowedHosts: ["oteliq.randomcodespace.dev", ".randomcodespace.dev"],
74+
// Shortcut for Ollama Cloud → Base URL https://lane.randomcodespace.dev/llm/v1
75+
// (equivalent to the generic …/llmx/ollama.com/v1).
76+
proxy: {
77+
"/llm": {
78+
target: "https://ollama.com",
79+
changeOrigin: true,
80+
rewrite: (p) => p.replace(/^\/llm/, ""),
81+
configure: (proxy) => {
82+
proxy.on("proxyReq", (proxyReq) => {
83+
if (process.env.OLLAMA_API_KEY && !proxyReq.getHeader("authorization")) {
84+
proxyReq.setHeader("authorization", `Bearer ${process.env.OLLAMA_API_KEY}`);
85+
}
86+
});
87+
},
88+
},
89+
},
1290
hmr: {
1391
protocol: "wss",
14-
host: "oteliq.randomcodespace.dev",
92+
host: "lane.randomcodespace.dev",
1593
clientPort: 443,
1694
},
1795
},

e2e/harness/devmock.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// Reuses the exported fixtures from the e2e GitLab mock and reimplements the
44
// REST handler against window.fetch (Playwright's page.route isn't available in
55
// a normal browser).
6-
import { USER, PROJECT, PROJECT_ID, SYSTEM_LABELS, USER_LABELS } from "../fixtures/gitlab";
6+
import { USER, PROJECT, PROJECT_ID, PROJECT_PATH, SYSTEM_LABELS, USER_LABELS } from "../fixtures/gitlab";
77

88
type Obj = Record<string, unknown>;
99

@@ -56,6 +56,41 @@ function freshState(): State {
5656
};
5757
}
5858

59+
// Overflow/preview stress seed: append N varied issues (long titles, many
60+
// labels, heading/code/checklist/long-prose descriptions). Enabled via ?seed=N.
61+
function seedExtra(state: State, n: number): void {
62+
const states = ["state::todo", "state::doing", "state::done"];
63+
const pool = ["frontend", "backend", "urgent", "design", "infra", "docs", "perf", "a11y", "security", "tech-debt"];
64+
const descs = [
65+
"## Background\n\nThe webhook retries are not idempotent, so a duplicate delivery double-charges. We need an idempotency key keyed on the event id, persisted for 24h.\n\n- [ ] add key column\n- [ ] dedupe on ingest\n- [x] write the migration",
66+
"```ts\nexport const retry = (n: number) => backoff(n);\n```\nNeeds a jittered backoff; the current fixed delay thunders the herd on outage recovery.",
67+
"Short note.",
68+
"",
69+
"Refactor the pagination cursor to be opaque and signed so clients cannot enumerate ids. Touches the list endpoint, the typed client, and the e2e fixtures; preserve backwards compatibility for one release.",
70+
"- [x] spec\n- [x] api\n- [ ] docs\n- [ ] changelog",
71+
];
72+
for (let i = 0; i < n; i++) {
73+
const st = states[i % 3]!;
74+
const labels = [st, "src::side", ...pool.slice(i % 6, (i % 6) + ((i % 4) + 1))];
75+
if (i % 7 === 0) labels.push("flag::blocked");
76+
if (i % 9 === 0) labels.push("flag::reviewing");
77+
state.issues.push(
78+
mkIssue({
79+
iid: 200 + i,
80+
title:
81+
i % 5 === 0
82+
? `Investigate the very long and unwieldy issue title number ${200 + i} that should wrap gracefully across lines on a card`
83+
: `Seeded issue ${200 + i}: ${["tune", "audit", "ship", "spike", "patch"][i % 5]} the ${pool[i % pool.length]} path`,
84+
labels,
85+
description: descs[i % descs.length] ?? "",
86+
weight: i % 3 === 0 ? (i % 13) + 1 : null,
87+
due_date: i % 4 === 0 ? "2026-06-05" : i % 4 === 1 ? "2026-07-01" : null,
88+
notes: i % 6,
89+
}),
90+
);
91+
}
92+
}
93+
5994
function csv(v: unknown): string[] {
6095
return typeof v === "string" && v ? v.split(",").map((s) => s.trim()).filter(Boolean) : [];
6196
}
@@ -68,6 +103,14 @@ function handle(state: State, method: string, path: string, body: Obj): { status
68103

69104
if (/^\/projects\/\d+$/.test(path) && method === "GET") return { status: 200, data: PROJECT };
70105

106+
// Connect-screen path resolver: GET /projects/<url-encoded path> (non-numeric).
107+
const pm = path.match(/^\/projects\/([^/]+)$/);
108+
if (pm && method === "GET" && !/^\d+$/.test(pm[1] ?? "")) {
109+
const decoded = decodeURIComponent(pm[1] ?? "");
110+
if (decoded === PROJECT_PATH) return { status: 200, data: PROJECT };
111+
return { status: 404, data: { message: "404 Project Not Found" } };
112+
}
113+
71114
if (path === `/projects/${PROJECT_ID}/labels`) {
72115
if (method === "GET") return { status: 200, data: state.labels };
73116
if (method === "POST") {
@@ -137,11 +180,15 @@ function handle(state: State, method: string, path: string, body: Obj): { status
137180
}
138181
}
139182

183+
// eslint-disable-next-line no-console
184+
console.warn(`[lane dev] unmocked GitLab call: ${method} ${path} — returning {}`);
140185
return { status: 200, data: {} };
141186
}
142187

143188
export function installFetchMock(): void {
144189
const state = freshState();
190+
const seed = Number(new URLSearchParams(location.search).get("seed"));
191+
if (Number.isInteger(seed) && seed > 0) seedExtra(state, Math.min(seed, 200));
145192
const orig = window.fetch.bind(window);
146193
window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
147194
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;

0 commit comments

Comments
 (0)