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
43 changes: 36 additions & 7 deletions e2e/pages/WorkItemsPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,23 +152,41 @@ export class WorkItemsPage {
}

/**
* Type a search query and wait for the debounced API response.
* Type a search query and wait for both the debounced API response and the
* DOM to re-render with the filtered results.
*
* The response listener must be registered BEFORE the fill action to avoid a
* race condition where the debounced request resolves before the listener is
* attached (especially common on WebKit/tablet where the 300ms debounce can
* fire and complete before the next line executes).
*
* After the network response is received we additionally call waitForLoaded()
* to ensure React has flushed the new data into the DOM before callers
* attempt to read titles or interact with list items.
*/
async search(query: string): Promise<void> {
await this.searchInput.fill(query);
await this.page.waitForResponse(
const responsePromise = this.page.waitForResponse(
(resp) => resp.url().includes('/api/work-items') && resp.status() === 200,
);
await this.searchInput.fill(query);
await responsePromise;
await this.waitForLoaded();
}

/**
* Clear the search input and wait for the list to update.
* Clear the search input and wait for both the API response and the DOM to
* update.
*
* The response listener must be registered BEFORE the clear action for the
* same race-condition reason as search().
*/
async clearSearch(): Promise<void> {
await this.searchInput.clear();
await this.page.waitForResponse(
const responsePromise = this.page.waitForResponse(
(resp) => resp.url().includes('/api/work-items') && resp.status() === 200,
);
await this.searchInput.clear();
await responsePromise;
await this.waitForLoaded();
}

/**
Expand Down Expand Up @@ -216,10 +234,21 @@ export class WorkItemsPage {

/**
* Confirm the deletion in the delete modal.
* No explicit timeout — uses project-level actionTimeout (15s for WebKit).
*
* Waits for both the DELETE API response and the modal to hide. Registering
* the response listener before the click prevents a race where the DELETE
* completes and the modal closes before the listener is attached (common on
* fast Chromium or heavily-loaded CI runners).
*/
async confirmDelete(): Promise<void> {
const deleteResponsePromise = this.page.waitForResponse(
(resp) =>
resp.url().includes('/api/work-items') &&
resp.request().method() === 'DELETE' &&
resp.status() === 204,
);
await this.deleteConfirmButton.click();
await deleteResponsePromise;
await this.deleteModal.waitFor({ state: 'hidden' });
}

Expand Down
67 changes: 47 additions & 20 deletions e2e/tests/proxy/proxy-setup.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import { test, expect } from '@playwright/test';
import type { Page } from '@playwright/test';
import { readFile } from 'fs/promises';
import { TEST_ADMIN, API } from '../../fixtures/testData.js';

Expand All @@ -25,6 +26,44 @@ test.beforeAll(async () => {
}
});

/**
* Log in through the proxy and wait until the session is established.
*
* Uses Promise.all to start the API response listener before clicking Submit,
* preventing a race where the login response arrives before the listener is
* attached (especially relevant through the extra nginx hop). After the API
* response confirms success, we wait for the URL to change away from /login
* using waitForURL — this is a condition-based wait that is more reliable than
* the previous `expect(page).not.toHaveURL` pattern which could time out if
* React's router update lagged slightly behind the session establishment.
*
* @param page - Playwright Page within an unauthenticated context
* @param baseUrl - The proxy base URL to use
*/
async function loginThroughProxy(page: Page, baseUrl: string) {
await page.goto(`${baseUrl}/login`);
await page.getByLabel(/email/i).fill(TEST_ADMIN.email);
await page.getByLabel(/password/i).fill(TEST_ADMIN.password);

// Start the response listener BEFORE clicking so we don't miss the response.
const [loginResponse] = await Promise.all([
page.waitForResponse(
(resp) => resp.url().includes('/api/auth/login') && resp.request().method() === 'POST',
),
page.getByRole('button', { name: /sign in/i }).click(),
]);

if (!loginResponse.ok()) {
throw new Error(
`Login through proxy failed: ${loginResponse.status()} ${loginResponse.statusText()}`,
);
}

// Wait for the React router to navigate away from /login after session is set.
// The proxy adds an extra nginx hop so allow enough time for the redirect chain.
await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 15000 });
}

test.describe('Reverse Proxy Setup', { tag: '@responsive' }, () => {
test('should return healthy status through proxy', async ({ request }) => {
// Given: A reverse proxy forwarding to the Cornerstone app
Expand Down Expand Up @@ -73,16 +112,12 @@ test.describe('Reverse Proxy Setup', { tag: '@responsive' }, () => {
storageState: { cookies: [], origins: [] },
});
const page = await context.newPage();
await page.goto(`${proxyBaseUrl}/login`);

// When: Logging in with valid credentials
await page.getByLabel(/email/i).fill(TEST_ADMIN.email);
await page.getByLabel(/password/i).fill(TEST_ADMIN.password);
await page.getByRole('button', { name: /sign in/i }).click();
// When: Logging in with valid credentials through the proxy
await loginThroughProxy(page, proxyBaseUrl);

// Then: Should redirect away from login page (to dashboard or home)
// Proxy login goes through an extra nginx hop — give it more time than the default 5s
await expect(page).not.toHaveURL(/\/login/, { timeout: 15000 });
// Then: Should have redirected away from login page (to dashboard or home)
expect(page.url()).not.toMatch(/\/login/);

await context.close();
});
Expand All @@ -96,12 +131,8 @@ test.describe('Reverse Proxy Setup', { tag: '@responsive' }, () => {
});
const page = await context.newPage();

// When: Logging in through the proxy
await page.goto(`${proxyBaseUrl}/login`);
await page.getByLabel(/email/i).fill(TEST_ADMIN.email);
await page.getByLabel(/password/i).fill(TEST_ADMIN.password);
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page).not.toHaveURL(/\/login/, { timeout: 15000 });
// When: Logging in through the proxy (helper waits for session to be established)
await loginThroughProxy(page, proxyBaseUrl);

// And: Navigating to a protected route
await page.goto(`${proxyBaseUrl}/profile`);
Expand All @@ -121,12 +152,8 @@ test.describe('Reverse Proxy Setup', { tag: '@responsive' }, () => {
});
const page = await context.newPage();

// Login first
await page.goto(`${proxyBaseUrl}/login`);
await page.getByLabel(/email/i).fill(TEST_ADMIN.email);
await page.getByLabel(/password/i).fill(TEST_ADMIN.password);
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page).not.toHaveURL(/\/login/, { timeout: 15000 });
// Login first — helper waits for the API response and URL to change
await loginThroughProxy(page, proxyBaseUrl);

// When: Logging out through the proxy
await page.goto(`${proxyBaseUrl}/profile`);
Expand Down
8 changes: 5 additions & 3 deletions e2e/tests/work-items/work-item-create.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,12 +98,14 @@ test.describe('Create with title only — happy path (Scenario 3)', { tag: '@res
createdId = body.workItem?.id ?? body.id ?? null;

// Should redirect to /work-items/:id
await page.waitForURL('**/work-items/**', { timeout: 7000 });
// Use project-level navigationTimeout (15s for WebKit) — do not hardcode
// a timeout that is too short for mobile/tablet WebKit.
await page.waitForURL('**/work-items/**');
expect(page.url()).toMatch(/\/work-items\/[a-z0-9-]+$/);
expect(page.url()).not.toContain('/work-items/new');

// Detail page shows the correct title
await expect(page.getByRole('heading', { level: 1 })).toHaveText(title, { timeout: 7000 });
// Detail page shows the correct title — use project-level expect timeout
await expect(page.getByRole('heading', { level: 1 })).toHaveText(title);
} finally {
if (createdId) await deleteWorkItemViaApi(page, createdId);
}
Expand Down
15 changes: 12 additions & 3 deletions e2e/tests/work-items/work-items-list.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,11 +292,20 @@ test.describe('Delete modal — confirm (Scenario 6)', { tag: '@responsive' }, (
const modalText = await workItemsPage.deleteModal.textContent();
expect(modalText).toContain(title);

// Confirm deletion
// Register the list-refresh response listener before confirming deletion so
// we don't miss the GET that fires immediately after the DELETE completes.
const listRefreshPromise = page.waitForResponse(
(resp) => resp.url().includes('/api/work-items') && resp.status() === 200,
);

// Confirm deletion — confirmDelete() waits for the DELETE API response and
// the modal to close.
await workItemsPage.confirmDelete();

// Work item no longer in list
await workItemsPage.waitForLoaded();
// Wait for the list to refresh with the post-delete GET response.
await listRefreshPromise;

// Work item no longer in list (assert on DOM, no need for another waitForLoaded)
const titlesAfter = await workItemsPage.getWorkItemTitles();
expect(titlesAfter).not.toContain(title);

Expand Down