Skip to content

Commit a8b1b3c

Browse files
committed
test: stabilize theme and deletion e2e specs
1 parent b6acc39 commit a8b1b3c

3 files changed

Lines changed: 61 additions & 87 deletions

File tree

tests/e2e/playwright.config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import { defineConfig, devices } from '@playwright/test';
22

33
const workers = Math.max(1, Number.parseInt(process.env.PICLAW_E2E_WORKERS || '1', 10) || 1);
44
const timeout = Math.max(10_000, Number.parseInt(process.env.PICLAW_E2E_TEST_TIMEOUT_MS || '30000', 10) || 30_000);
5+
// Optional: only inject an internal-secret header into browser-originated
6+
// requests when explicitly requested. Do not reuse PICLAW_INTERNAL_SECRET here:
7+
// the auth bootstrap request consumes that value separately, and globally adding
8+
// it can perturb Playwright's APIRequestContext cookie/bootstrap flow.
9+
const internalSecret = (process.env.PICLAW_E2E_INTERNAL_SECRET || '').trim();
510

611
export default defineConfig({
712
testDir: './steps',
@@ -17,6 +22,7 @@ export default defineConfig({
1722
screenshot: 'only-on-failure',
1823
video: 'retain-on-failure',
1924
trace: 'retain-on-failure',
25+
...(internalSecret ? { extraHTTPHeaders: { 'x-piclaw-internal-secret': internalSecret } } : {}),
2026
// Use domcontentloaded — SSE keeps networkidle from resolving
2127
navigationTimeout: 30_000,
2228
},

tests/e2e/steps/theme-tint-commands.spec.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { sel } from '../support/selectors';
44
// /theme and /tint E2E tests
55
//
66
// Architecture:
7-
// compose → POST /agent/chat → server handleUiThemeCommand()
7+
// compose → POST /agent/default/message → server handleUiThemeCommand()
88
// → SSE ui_theme event → client applyThemeFromEvent()
99
// → sets data-theme, data-color-theme, data-tint on <html>
1010
// → applies CSS variables on document.documentElement.style
@@ -20,7 +20,7 @@ import { sel } from '../support/selectors';
2020
/** Send a slash command and wait for the authenticated POST to complete. */
2121
async function sendCommand(page: import('@playwright/test').Page, cmd: string) {
2222
const responsePromise = page.waitForResponse(
23-
(response) => response.url().includes('/agent/chat') && response.request().method() === 'POST',
23+
(response) => response.url().includes('/agent/default/message') && response.request().method() === 'POST',
2424
{ timeout: 10_000 },
2525
).catch(() => null);
2626
const compose = page.locator(sel.composeInput);
@@ -55,7 +55,7 @@ async function getThemeState(page: import('@playwright/test').Page) {
5555
return {
5656
dataTheme: root.dataset.theme || null,
5757
dataColorTheme: root.dataset.colorTheme || null,
58-
dataTint: root.dataset.tint || null,
58+
dataTint: root.dataset.tint || '',
5959
bgPrimary: style.getPropertyValue('--bg-primary').trim() || null,
6060
bgSecondary: style.getPropertyValue('--bg-secondary').trim() || null,
6161
accentColor: style.getPropertyValue('--accent-color').trim() || null,
@@ -180,7 +180,7 @@ test.describe('/theme command', () => {
180180
await waitForThemeState(page, { dataColorTheme: 'ristretto' });
181181
expect((await getThemeState(page)).dataColorTheme).toBe('ristretto');
182182

183-
await page.reload({ waitUntil: 'networkidle' });
183+
await page.reload({ waitUntil: 'domcontentloaded' });
184184
await page.waitForTimeout(1000);
185185

186186
const after = await getThemeState(page);
@@ -332,7 +332,7 @@ test.describe('/tint command on default theme', () => {
332332
await sendCommand(page, '/tint #e11d48');
333333
expect((await getThemeState(page)).dataTint).toBe('#e11d48');
334334

335-
await page.reload({ waitUntil: 'networkidle' });
335+
await page.reload({ waitUntil: 'domcontentloaded' });
336336
await page.waitForTimeout(1000);
337337

338338
const after = await getThemeState(page);

tests/e2e/steps/us16-message-deletion.spec.ts

Lines changed: 50 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { test, expect } from '../support/world';
22
import { sel } from '../support/selectors';
33

44
const CHAT_POST_TIMEOUT_MS = 8_000;
5-
const AGENT_REPLY_TIMEOUT_MS = 5_000;
65

76
// US-16: Message Deletion from Timeline
87
//
@@ -30,55 +29,49 @@ function deleteButtonFor(page: import('@playwright/test').Page, postSelector: st
3029
return page.locator(`${postSelector} .post-delete-btn, ${postSelector} [aria-label="Delete message"]`);
3130
}
3231

33-
/** Send a message and wait for the authenticated chat POST plus timeline echo. */
34-
async function sendMessage(page: import('@playwright/test').Page, text: string) {
35-
const userPostCount = await page.locator('.post:not(.agent-post)').count();
36-
const responsePromise = page.waitForResponse(
37-
(response) => response.url().includes('/agent/chat') && response.request().method() === 'POST',
38-
{ timeout: CHAT_POST_TIMEOUT_MS },
39-
).catch(() => null);
40-
41-
const compose = page.locator(sel.composeInput);
42-
await compose.click();
43-
await compose.fill(text);
44-
await page.keyboard.press('Enter');
45-
46-
const response = await responsePromise;
47-
if (!response) {
48-
throw new Error(`No /agent/chat response observed for ${JSON.stringify(text)}; E2E auth/session bootstrap may be missing.`);
49-
}
50-
if (!response.ok()) {
51-
throw new Error(`/agent/chat failed with HTTP ${response.status()} for ${JSON.stringify(text)}: ${await response.text().catch(() => '')}`);
52-
}
53-
54-
await page.waitForFunction(
55-
({ previousCount, expectedText }) => {
56-
const userPosts = Array.from(document.querySelectorAll('.post:not(.agent-post)'));
57-
return userPosts.length > previousCount && userPosts.some((post) => post.textContent?.includes(expectedText));
58-
},
59-
{ previousCount: userPostCount, expectedText: text },
60-
{ timeout: CHAT_POST_TIMEOUT_MS },
61-
);
32+
/** Create a timeline post directly so deletion tests do not leave the agent busy. */
33+
async function sendMessage(page: import('@playwright/test').Page, text: string): Promise<number> {
34+
const response = await page.evaluate(async (content) => {
35+
const res = await fetch('/post', {
36+
method: 'POST',
37+
headers: { 'Content-Type': 'application/json' },
38+
body: JSON.stringify({ content }),
39+
});
40+
const body = await res.json().catch(() => ({}));
41+
if (!res.ok) throw new Error(`/post failed with HTTP ${res.status}: ${JSON.stringify(body)}`);
42+
return body;
43+
}, text);
44+
45+
const id = Number((response as { id?: unknown }).id);
46+
await expect(page.locator(`#post-${id}`)).toBeVisible({ timeout: CHAT_POST_TIMEOUT_MS });
47+
return id;
6248
}
6349

64-
/** Wait briefly for an agent response; deletion tests can continue/skip cascade checks without one. */
65-
async function waitForAgentReply(page: import('@playwright/test').Page, timeoutMs = AGENT_REPLY_TIMEOUT_MS) {
66-
const postCount = await page.locator(sel.post).count();
67-
await page.waitForFunction(
68-
(count) => document.querySelectorAll('.post').length > count,
69-
postCount,
70-
{ timeout: timeoutMs }
71-
).catch(() => {});
72-
await page.waitForTimeout(500);
50+
/** Create a deterministic thread reply without invoking the agent. */
51+
async function sendReply(page: import('@playwright/test').Page, threadId: number, text: string): Promise<number> {
52+
const response = await page.evaluate(async ({ threadId, content }) => {
53+
const res = await fetch('/post/reply', {
54+
method: 'POST',
55+
headers: { 'Content-Type': 'application/json' },
56+
body: JSON.stringify({ thread_id: threadId, content }),
57+
});
58+
const body = await res.json().catch(() => ({}));
59+
if (!res.ok) throw new Error(`/post/reply failed with HTTP ${res.status}: ${JSON.stringify(body)}`);
60+
return body;
61+
}, { threadId, content: text });
62+
63+
const id = Number((response as { id?: unknown }).id);
64+
await expect(page.locator(`#post-${id}`)).toBeVisible({ timeout: CHAT_POST_TIMEOUT_MS });
65+
return id;
7366
}
7467

7568
// ── Single message deletion ──────────────────────────────────────
7669

7770
test.describe('US-16: Single Message Deletion', () => {
7871
test('delete button visible on hover', async ({ authedPage: page }) => {
79-
await page.waitForSelector(sel.post);
72+
const postId = await sendMessage(page, `hover-delete-test-${Date.now()}`);
8073

81-
const post = page.locator(sel.post).last();
74+
const post = page.locator(`#post-${postId}`);
8275
await post.hover();
8376
await page.waitForTimeout(300);
8477

@@ -89,7 +82,6 @@ test.describe('US-16: Single Message Deletion', () => {
8982
test('delete a single message removes it from timeline', async ({ authedPage: page }) => {
9083
// Send a test message
9184
await sendMessage(page, `e2e-delete-test-${Date.now()}`);
92-
await waitForAgentReply(page);
9385

9486
// Get the user message we just sent
9587
const userPosts = page.locator('.post:not(.agent-post)');
@@ -140,7 +132,7 @@ test.describe('US-16: Single Message Deletion', () => {
140132
}
141133

142134
// Refresh
143-
await page.reload({ waitUntil: 'networkidle' });
135+
await page.reload({ waitUntil: 'domcontentloaded' });
144136
await page.waitForSelector(sel.post, { timeout: 10000 }).catch(() => {});
145137
await page.waitForTimeout(1000);
146138

@@ -154,9 +146,9 @@ test.describe('US-16: Single Message Deletion', () => {
154146

155147
test.describe('US-16: Cascading Thread Deletion', () => {
156148
test('deleting a parent with replies shows confirmation with count', async ({ authedPage: page }) => {
157-
// Send a message that will get a reply (agent response creates a thread)
158-
await sendMessage(page, 'Reply to this for thread deletion test');
159-
await waitForAgentReply(page);
149+
// Create a parent plus deterministic replies without invoking the agent.
150+
const parentId = await sendMessage(page, 'Reply to this for thread deletion test');
151+
await sendReply(page, parentId, 'Thread deletion reply 1');
160152

161153
// The user message should now have at least 1 reply (the agent response)
162154
// Find the user message (parent)
@@ -167,29 +159,9 @@ test.describe('US-16: Cascading Thread Deletion', () => {
167159
return;
168160
}
169161

170-
const parentPost = userPosts.last();
171-
172-
// Check if this post has thread replies visible
173-
const parentId = await parentPost.evaluate((el) => {
174-
const idAttr = el.id?.replace('post-', '');
175-
return idAttr || null;
176-
});
177-
178-
if (!parentId) {
179-
test.skip(undefined, 'Cannot determine parent post ID');
180-
return;
181-
}
182-
183-
// Count visible replies
184-
const replyCount = await page.evaluate((pid) => {
185-
return document.querySelectorAll(`.post.thread-reply`).length;
186-
}, parentId);
162+
const parentPost = page.locator(`#post-${parentId}`);
187163

188-
if (replyCount === 0) {
189-
// Agent may not have threaded its reply — skip cascade-specific test
190-
test.skip(undefined, 'No thread replies to test cascade');
191-
return;
192-
}
164+
await expect(page.locator(sel.postContent, { hasText: 'Thread deletion reply 1' })).toBeVisible();
193165

194166
// Set up dialog listener to check the message
195167
let dialogMessage = '';
@@ -209,15 +181,13 @@ test.describe('US-16: Cascading Thread Deletion', () => {
209181
});
210182

211183
test('confirming cascade removes parent and all replies', async ({ authedPage: page }) => {
212-
await sendMessage(page, 'Cascade delete test - please reply');
213-
await waitForAgentReply(page);
184+
const parentId = await sendMessage(page, 'Cascade delete test - please reply');
185+
await sendReply(page, parentId, 'Cascade delete reply 1');
214186

215187
const postsBefore = await getVisiblePostIds(page);
216188
const countBefore = postsBefore.length;
217189

218-
// Find the last user post
219-
const userPosts = page.locator('.post:not(.agent-post)');
220-
const parentPost = userPosts.last();
190+
const parentPost = page.locator(`#post-${parentId}`);
221191

222192
// Accept cascade confirmation
223193
page.on('dialog', async (dialog) => {
@@ -238,13 +208,12 @@ test.describe('US-16: Cascading Thread Deletion', () => {
238208
});
239209

240210
test('cancelling cascade preserves all messages', async ({ authedPage: page }) => {
241-
await sendMessage(page, 'Cancel cascade test');
242-
await waitForAgentReply(page);
211+
const parentId = await sendMessage(page, 'Cancel cascade test');
212+
await sendReply(page, parentId, 'Cancel cascade reply 1');
243213

244214
const postsBefore = await getVisiblePostIds(page);
245215

246-
const userPosts = page.locator('.post:not(.agent-post)');
247-
const parentPost = userPosts.last();
216+
const parentPost = page.locator(`#post-${parentId}`);
248217

249218
// Dismiss (cancel) confirmation
250219
page.on('dialog', async (dialog) => {
@@ -265,12 +234,11 @@ test.describe('US-16: Cascading Thread Deletion', () => {
265234
});
266235

267236
test('no orphaned replies remain after cascade delete', async ({ authedPage: page }) => {
268-
await sendMessage(page, 'Orphan check test');
269-
await waitForAgentReply(page);
237+
const createdParentId = await sendMessage(page, 'Orphan check test');
238+
await sendReply(page, createdParentId, 'Orphan check reply 1');
270239

271-
const userPosts = page.locator('.post:not(.agent-post)');
272-
const parentPost = userPosts.last();
273-
const parentId = await parentPost.evaluate(el => el.id?.replace('post-', '') || '');
240+
const parentPost = page.locator(`#post-${createdParentId}`);
241+
const parentId = String(createdParentId);
274242

275243
// Accept cascade
276244
page.on('dialog', async (dialog) => await dialog.accept());

0 commit comments

Comments
 (0)