Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions .github/workflows/word-addin.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# CI: Word add-in typecheck + hermetic Playwright e2e (chromium AND webkit).
#
# The add-in's e2e suite is fully hermetic (static-served production bundle,
# in-page Office shim, every backend call intercepted — see
# word-addin/playwright.config.ts), so no Supabase/API/secrets are needed here.
#
# WebKit is a first-class project, not extra coverage: the add-in's real host
# on Word for Mac is WKWebView, which ignores `overflow-anchor: none` and
# re-anchors scrollTop when a descendant resizes, while Chromium honours the
# opt-out. The scroll-pinning assertions in e2e/chat-layout.spec.ts only bite
# under webkit — a chromium-only run would silently pass with the scroll fix
# reverted.
#
# Unlike ci.yml/e2e.yml this workflow is path-filtered: the add-in is fully
# self-contained under word-addin/ (own lockfile, own tsconfigs, mocked
# backend), so nothing outside that directory can change what this job proves.
name: Word add-in

on:
workflow_dispatch:
push:
branches: [main]
paths:
- "word-addin/**"
- ".github/workflows/word-addin.yml"
pull_request:
# `main` is the destination upstream; `upstream-main` is the fork's mirror
# of it (same pairing as e2e.yml / stack-tests.yml).
branches: [main, upstream-main]
paths:
- "word-addin/**"
- ".github/workflows/word-addin.yml"

# Don't pile up runs on rapid pushes to the same PR; pushes to main each get
# their own group (keyed by sha) and are never cancelled.
concurrency:
group: word-addin-${{ github.event_name == 'push' && github.sha || github.ref }}
cancel-in-progress: ${{ github.event_name != 'push' }}

jobs:
playwright:
name: Typecheck and Playwright (chromium + webkit)
runs-on: ubuntu-latest
timeout-minutes: 30
defaults:
run:
working-directory: word-addin
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: word-addin/package-lock.json

# Same silent-merge-corruption guard as ci.yml: git's line-level merge
# can splice package(-lock).json into invalid JSON without a conflict,
# and npm's own error for that is misleading.
- name: Validate package.json and lockfile parse
run: node -e "for (const f of ['package.json','package-lock.json']) JSON.parse(require('fs').readFileSync(f, 'utf8'))"

- run: npm ci

# Covers both the app bundle (tsconfig.json) and the e2e suite +
# playwright.config.ts (tsconfig.e2e.json).
- run: npm run typecheck

# Both engines: webkit is the browser the assertions were written for
# (see header), chromium guards the honours-overflow-anchor path.
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium webkit

# Runs BOTH projects. playwright.config.ts owns the build/serve
# orchestration: its webServer command runs `build:e2e` (typecheck +
# production webpack bundle with fixed REACT_APP_* values) and then
# static-serves dist/ over plain HTTP, so this single step builds,
# serves, and tests.
- name: Run Playwright (chromium + webkit)
run: npx playwright test

# always() (not !cancelled()) so traces/screenshots still upload when
# the job is cancelled by the timeout — exactly when they're needed.
- uses: actions/upload-artifact@v4
if: always()
with:
name: word-addin-playwright-results
path: word-addin/test-results/
retention-days: 14
if-no-files-found: ignore
140 changes: 136 additions & 4 deletions backend/src/__tests__/integration/chat.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ const { runLLMStream, dbInserts, dbUpdates, dbControl } = vi.hoisted(() => ({
terminalUpdateAttempts: 0,
terminalUpdateGate: null as Promise<void> | null,
wordChatMissing: false,
// When set, selects on chat_messages resolve against these rows with
// the eq/not/order/limit chain genuinely applied (a mini query
// engine), so tests can prove which assistant row a query picks.
assistantMessageRows: null as Record<string, unknown>[] | null,
},
}));

Expand Down Expand Up @@ -44,7 +48,6 @@ function makeQuery(table: string) {
}
| undefined;
const chain = [
"select",
"delete",
"upsert",
"neq",
Expand All @@ -56,12 +59,34 @@ function makeQuery(table: string) {
"gte",
"lte",
"filter",
"order",
"limit",
"range",
"contains",
];
for (const m of chain) q[m] = vi.fn(() => q);
// Select-chain state, applied against dbControl.assistantMessageRows when
// the query resolves (see q.then below).
let didSelect = false;
const selectState = {
filters: [] as { column: string; op: string; value: unknown }[],
order: null as { column: string; ascending: boolean } | null,
limit: null as number | null,
};
q.select = vi.fn(() => {
didSelect = true;
return q;
});
q.not = vi.fn((column: string, operator: string, value: unknown) => {
selectState.filters.push({ column, op: `not-${operator}`, value });
return q;
});
q.order = vi.fn((column: string, opts?: { ascending?: boolean }) => {
selectState.order = { column, ascending: opts?.ascending !== false };
return q;
});
q.limit = vi.fn((count: number) => {
selectState.limit = count;
return q;
});
q.insert = vi.fn((value: unknown) => {
dbInserts.push({ table, value });
if (
Expand All @@ -82,7 +107,8 @@ function makeQuery(table: string) {
return q;
});
q.eq = vi.fn((column: string, value: unknown) => {
activeUpdate?.filters.push({ column, value });
if (activeUpdate) activeUpdate.filters.push({ column, value });
else selectState.filters.push({ column, op: "eq", value });
return q;
});
q.single = vi.fn(() => Promise.resolve(result));
Expand Down Expand Up @@ -115,6 +141,33 @@ function makeQuery(table: string) {
};
}
}
if (
!activeUpdate &&
didSelect &&
table === "chat_messages" &&
dbControl.assistantMessageRows
) {
let rows = [...dbControl.assistantMessageRows];
for (const f of selectState.filters) {
if (f.op === "eq") {
rows = rows.filter((row) => row[f.column] === f.value);
} else if (f.op === "not-is" && f.value === null) {
rows = rows.filter((row) => row[f.column] !== null);
}
}
if (selectState.order) {
const { column, ascending } = selectState.order;
rows = [...rows].sort(
(a, b) =>
String(a[column]).localeCompare(String(b[column])) *
(ascending ? 1 : -1),
);
}
if (selectState.limit != null) {
rows = rows.slice(0, selectState.limit);
}
return { data: rows, error: null };
}
return result;
};
return resolveQuery().then(resolve, reject);
Expand Down Expand Up @@ -208,6 +261,7 @@ describe("POST /chat — streaming endpoint", () => {
dbControl.terminalUpdateAttempts = 0;
dbControl.terminalUpdateGate = null;
dbControl.wordChatMissing = false;
dbControl.assistantMessageRows = null;
runLLMStream.mockResolvedValue({
fullText: "hi there",
events: [],
Expand Down Expand Up @@ -649,6 +703,84 @@ describe("POST /chat — streaming endpoint", () => {
).toEqual([]);
});

it("appends ask-input responses to the real last assistant message, skipping a null-content reservation", async () => {
// A stream that died before its save path (or a concurrently
// streaming POST) leaves the newest assistant row as an empty
// reservation. The continuation must attach the user's answers to
// the older, real message that actually asked the question.
dbControl.assistantMessageRows = [
{
id: "assistant-real",
chat_id: "chat-1",
role: "assistant",
content: [{ type: "ask_inputs", items: [] }],
citations: null,
created_at: "2026-01-01T00:00:00Z",
},
{
id: "assistant-reservation",
chat_id: "chat-1",
role: "assistant",
content: null,
citations: null,
created_at: "2026-01-01T00:05:00Z",
},
];

const res = await request(app)
.post("/chat")
.set("Authorization", "Bearer test")
.send({
...VALID_BODY,
chat_id: "chat-1",
ask_inputs_response: {
responses: [
{
id: "choice-1",
kind: "choice",
question: "Continue?",
answer: "Yes",
},
],
},
});

expect(res.status).toBe(200);
const askInputsUpdate = dbUpdates.find(
({ table, filters }) =>
table === "chat_messages" &&
filters.some(
(f) => f.column === "id" && f.value === "assistant-real",
),
);
expect(askInputsUpdate?.value).toMatchObject({
content: [
{ type: "ask_inputs", items: [] },
{
type: "ask_inputs_response",
responses: [
{
id: "choice-1",
kind: "choice",
question: "Continue?",
answer: "Yes",
},
],
},
],
});
// The orphaned reservation is never selected or written to.
expect(
dbUpdates.some(({ filters }) =>
filters.some(
(f) =>
f.column === "id" &&
f.value === "assistant-reservation",
),
),
).toBe(false);
});

it("returns 400 on an empty messages array (never starts a stream)", async () => {
const res = await request(app)
.post("/chat")
Expand Down
Loading