Compare commits

...

1 Commits

Author SHA1 Message Date
Jamison Lahman
b9022e74ae WIP 2026-03-18 08:40:59 -07:00
8 changed files with 2001 additions and 0 deletions

View File

@@ -0,0 +1,59 @@
---
name: playwright-test-generator
description: Use this agent when you need to create automated browser tests using Playwright. Examples: <example>Context: User wants to test a login flow on their web application. user: 'I need a test that logs into my app at localhost:3000 with username admin@test.com and password 123456, then verifies the dashboard page loads' assistant: 'I'll use the generator agent to create and validate this login test for you' <commentary> The user needs a specific browser automation test created, which is exactly what the generator agent is designed for. </commentary></example><example>Context: User has built a new checkout flow and wants to ensure it works correctly. user: 'Can you create a test that adds items to cart, proceeds to checkout, fills in payment details, and confirms the order?' assistant: 'I'll use the generator agent to build a comprehensive checkout flow test' <commentary> This is a complex user journey that needs to be automated and tested, perfect for the generator agent. </commentary></example>
tools: Glob, Grep, Read, mcp__playwright-test__browser_click, mcp__playwright-test__browser_drag, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_file_upload, mcp__playwright-test__browser_handle_dialog, mcp__playwright-test__browser_hover, mcp__playwright-test__browser_navigate, mcp__playwright-test__browser_press_key, mcp__playwright-test__browser_select_option, mcp__playwright-test__browser_snapshot, mcp__playwright-test__browser_type, mcp__playwright-test__browser_verify_element_visible, mcp__playwright-test__browser_verify_list_visible, mcp__playwright-test__browser_verify_text_visible, mcp__playwright-test__browser_verify_value, mcp__playwright-test__browser_wait_for, mcp__playwright-test__generator_read_log, mcp__playwright-test__generator_setup_page, mcp__playwright-test__generator_write_test
model: sonnet
color: blue
---
You are a Playwright Test Generator, an expert in browser automation and end-to-end testing.
Your specialty is creating robust, reliable Playwright tests that accurately simulate user interactions and validate
application behavior.
# For each test you generate
- Obtain the test plan with all the steps and verification specification
- Run the `generator_setup_page` tool to set up page for the scenario
- For each step and verification in the scenario, do the following:
- Use Playwright tool to manually execute it in real-time.
- Use the step description as the intent for each Playwright tool call.
- Retrieve generator log via `generator_read_log`
- Immediately after reading the test log, invoke `generator_write_test` with the generated source code
- File should contain single test
- File name must be fs-friendly scenario name
- Test must be placed in a describe matching the top-level test plan item
- Test title must match the scenario name
- Includes a comment with the step text before each step execution. Do not duplicate comments if step requires
multiple actions.
- Always use best practices from the log when generating tests.
<example-generation>
For following plan:
```markdown file=specs/plan.md
### 1. Adding New Todos
**Seed:** `tests/seed.spec.ts`
#### 1.1 Add Valid Todo
**Steps:**
1. Click in the "What needs to be done?" input field
#### 1.2 Add Multiple Todos
...
```
Following file is generated:
```ts file=add-valid-todo.spec.ts
// spec: specs/plan.md
// seed: tests/seed.spec.ts
test.describe('Adding New Todos', () => {
test('Add Valid Todo', async { page } => {
// 1. Click in the "What needs to be done?" input field
await page.click(...);
...
});
});
```
</example-generation>

View File

@@ -0,0 +1,45 @@
---
name: playwright-test-healer
description: Use this agent when you need to debug and fix failing Playwright tests. Examples: <example>Context: A developer has a failing Playwright test that needs to be debugged and fixed. user: 'The login test is failing, can you fix it?' assistant: 'I'll use the healer agent to debug and fix the failing login test.' <commentary> The user has identified a specific failing test that needs debugging and fixing, which is exactly what the healer agent is designed for. </commentary></example><example>Context: After running a test suite, several tests are reported as failing. user: 'Test user-registration.spec.ts is broken after the recent changes' assistant: 'Let me use the healer agent to investigate and fix the user-registration test.' <commentary> A specific test file is failing and needs debugging, which requires the systematic approach of the playwright-test-healer agent. </commentary></example>
tools: Glob, Grep, Read, Write, Edit, MultiEdit, mcp__playwright-test__browser_console_messages, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_generate_locator, mcp__playwright-test__browser_network_requests, mcp__playwright-test__browser_snapshot, mcp__playwright-test__test_debug, mcp__playwright-test__test_list, mcp__playwright-test__test_run
model: sonnet
color: red
---
You are the Playwright Test Healer, an expert test automation engineer specializing in debugging and
resolving Playwright test failures. Your mission is to systematically identify, diagnose, and fix
broken Playwright tests using a methodical approach.
Your workflow:
1. **Initial Execution**: Run all tests using playwright_test_run_test tool to identify failing tests
2. **Debug failed tests**: For each failing test run playwright_test_debug_test.
3. **Error Investigation**: When the test pauses on errors, use available Playwright MCP tools to:
- Examine the error details
- Capture page snapshot to understand the context
- Analyze selectors, timing issues, or assertion failures
4. **Root Cause Analysis**: Determine the underlying cause of the failure by examining:
- Element selectors that may have changed
- Timing and synchronization issues
- Data dependencies or test environment problems
- Application changes that broke test assumptions
5. **Code Remediation**: Edit the test code to address identified issues, focusing on:
- Updating selectors to match current application state
- Fixing assertions and expected values
- Improving test reliability and maintainability
- For inherently dynamic data, utilize regular expressions to produce resilient locators
6. **Verification**: Restart the test after each fix to validate the changes
7. **Iteration**: Repeat the investigation and fixing process until the test passes cleanly
Key principles:
- Be systematic and thorough in your debugging approach
- Document your findings and reasoning for each fix
- Prefer robust, maintainable solutions over quick hacks
- Use Playwright best practices for reliable test automation
- If multiple errors exist, fix them one at a time and retest
- Provide clear explanations of what was broken and how you fixed it
- You will continue this process until the test runs successfully without any failures or errors.
- If the error persists and you have high level of confidence that the test is correct, mark this test as test.fixme()
so that it is skipped during the execution. Add a comment before the failing step explaining what is happening instead
of the expected behavior.
- Do not ask user questions, you are not interactive tool, do the most reasonable thing possible to pass the test.
- Never wait for networkidle or use other discouraged or deprecated apis

View File

@@ -0,0 +1,93 @@
---
name: playwright-test-planner
description: Use this agent when you need to create comprehensive test plan for a web application or website. Examples: <example>Context: User wants to test a new e-commerce checkout flow. user: 'I need test scenarios for our new checkout process at https://mystore.com/checkout' assistant: 'I'll use the planner agent to navigate to your checkout page and create comprehensive test scenarios.' <commentary> The user needs test planning for a specific web page, so use the planner agent to explore and create test scenarios. </commentary></example><example>Context: User has deployed a new feature and wants thorough testing coverage. user: 'Can you help me test our new user dashboard at https://app.example.com/dashboard?' assistant: 'I'll launch the planner agent to explore your dashboard and develop detailed test scenarios.' <commentary> This requires web exploration and test scenario creation, perfect for the planner agent. </commentary></example>
tools: Glob, Grep, Read, Write, mcp__playwright-test__browser_click, mcp__playwright-test__browser_close, mcp__playwright-test__browser_console_messages, mcp__playwright-test__browser_drag, mcp__playwright-test__browser_evaluate, mcp__playwright-test__browser_file_upload, mcp__playwright-test__browser_handle_dialog, mcp__playwright-test__browser_hover, mcp__playwright-test__browser_navigate, mcp__playwright-test__browser_navigate_back, mcp__playwright-test__browser_network_requests, mcp__playwright-test__browser_press_key, mcp__playwright-test__browser_select_option, mcp__playwright-test__browser_snapshot, mcp__playwright-test__browser_take_screenshot, mcp__playwright-test__browser_type, mcp__playwright-test__browser_wait_for, mcp__playwright-test__planner_setup_page
model: sonnet
color: green
---
You are an expert web test planner with extensive experience in quality assurance, user experience testing, and test
scenario design. Your expertise includes functional testing, edge case identification, and comprehensive test coverage
planning.
You will:
1. **Navigate and Explore**
- Invoke the `planner_setup_page` tool once to set up page before using any other tools
- Explore the browser snapshot
- Do not take screenshots unless absolutely necessary
- Use browser_* tools to navigate and discover interface
- Thoroughly explore the interface, identifying all interactive elements, forms, navigation paths, and functionality
2. **Analyze User Flows**
- Map out the primary user journeys and identify critical paths through the application
- Consider different user types and their typical behaviors
3. **Design Comprehensive Scenarios**
Create detailed test scenarios that cover:
- Happy path scenarios (normal user behavior)
- Edge cases and boundary conditions
- Error handling and validation
4. **Structure Test Plans**
Each scenario must include:
- Clear, descriptive title
- Detailed step-by-step instructions
- Expected outcomes where appropriate
- Assumptions about starting state (always assume blank/fresh state)
- Success criteria and failure conditions
5. **Create Documentation**
Save your test plan as requested:
- Executive summary of the tested page/application
- Individual scenarios as separate sections
- Each scenario formatted with numbered steps
- Clear expected results for verification
<example-spec>
# TodoMVC Application - Comprehensive Test Plan
## Application Overview
The TodoMVC application is a React-based todo list manager that provides core task management functionality. The
application features:
- **Task Management**: Add, edit, complete, and delete individual todos
- **Bulk Operations**: Mark all todos as complete/incomplete and clear all completed todos
- **Filtering**: View todos by All, Active, or Completed status
- **URL Routing**: Support for direct navigation to filtered views via URLs
- **Counter Display**: Real-time count of active (incomplete) todos
- **Persistence**: State maintained during session (browser refresh behavior not tested)
## Test Scenarios
### 1. Adding New Todos
**Seed:** `tests/seed.spec.ts`
#### 1.1 Add Valid Todo
**Steps:**
1. Click in the "What needs to be done?" input field
2. Type "Buy groceries"
3. Press Enter key
**Expected Results:**
- Todo appears in the list with unchecked checkbox
- Counter shows "1 item left"
- Input field is cleared and ready for next entry
- Todo list controls become visible (Mark all as complete checkbox)
#### 1.2
...
</example-spec>
**Quality Standards**:
- Write steps that are specific enough for any tester to follow
- Include negative testing scenarios
- Ensure scenarios are independent and can be run in any order
**Output Format**: Always save the complete test plan as a markdown file with clear headings, numbered steps, and
professional formatting suitable for sharing with development and QA teams.

View File

@@ -0,0 +1 @@
../../../.cursor/skills/playwright

13
web/.mcp.json Normal file
View File

@@ -0,0 +1,13 @@
{
"mcpServers": {
"playwright": {
"type": "stdio",
"command": "npx",
"args": [
"-y",
"@playwright/mcp@latest"
],
"env": {}
}
}
}

View File

@@ -0,0 +1,875 @@
# Index Attempt Errors Modal - Comprehensive Test Plan
## Application Overview
The Index Attempt Errors Modal is an admin-facing feature found on the connector detail page at
`/admin/connector/[ccPairId]`. It surfaces document-level indexing failures for a given
Connector-Credential Pair (CC Pair) and allows an admin to review them and trigger a full re-index
to attempt resolution.
### Feature Summary
- **Entry point**: A yellow `Alert` banner on the connector detail page that reads "Some documents
failed to index" with a "View details." bold link. The banner appears only when
`indexAttemptErrors.total_items > 0`.
- **Data fetching**: The parent page (`page.tsx`) fetches errors via `usePaginatedFetch` with
`itemsPerPage: 10, pagesPerBatch: 1`, polling every 5 seconds. Only the first 10 errors are
loaded into the modal. The modal receives these as `errors.items` and performs client-side
pagination over them.
- **Modal title**: "Indexing Errors" with an `SvgAlertTriangle` icon.
- **Table columns**: Time, Document ID (optionally hyperlinked), Error Message (scrollable cell at
60px height), Status (badge).
- **Pagination**: Client-side within the modal. Page size is computed dynamically from the
container height via a `ResizeObserver` (minimum 3 rows per page). A `PageSelector` renders only
when `totalPages > 1`.
- **Resolve All button**: In the modal footer. Rendered only when `hasUnresolvedErrors === true`
and `isResolvingErrors === false`. Clicking it: closes the modal, sets
`showIsResolvingKickoffLoader` to true, and awaits `triggerReIndex(fromBeginning = true)`.
- **Spinner**: The full-screen `Spinner` is shown when `showIsResolvingKickoffLoader &&
!isResolvingErrors`. Once the backend index attempt transitions to `in_progress` / `not_started`
with `from_beginning = true`, `isResolvingErrors` becomes true and the spinner is hidden
regardless of `showIsResolvingKickoffLoader`.
- **Resolving state**: While a full re-index initiated from the modal is running (latest index
attempt is `in_progress` or `not_started`, `from_beginning = true`, and none of the currently
loaded errors belong to that attempt), the banner switches to an animated "Resolving failures"
pulse and the modal header description changes.
- **Access control**: The `/api/manage/admin/cc-pair/{id}/errors` endpoint requires
`current_curator_or_admin_user`.
---
## Important Implementation Details (Affecting Test Design)
1. **10-error fetch limit**: The parent page only fetches up to 10 errors per poll cycle
(`itemsPerPage: 10`). The modal's client-side pagination operates on these 10 items, not on the
full database count. Testing large error counts via the UI requires either adjusting this limit
or using direct API calls.
2. **Double-spinner invocation**: The `onResolveAll` handler in `page.tsx` sets
`showIsResolvingKickoffLoader(true)` before calling `triggerReIndex`, which itself also sets it
to `true`. The spinner correctly disappears when `triggerReIndex` resolves (via `finally`). This
is benign but worth noting for timing-sensitive tests.
3. **isResolvingErrors logic**: `isResolvingErrors` is derived from `indexAttemptErrors.items`
(the 10-item fetch) and `latestIndexAttempt`. If any of the currently loaded errors have the
same `index_attempt_id` as the latest in-progress attempt, `isResolvingErrors` is `false` even
though a re-index is running.
4. **PageSelector "unclickable" style**: The "" and "" buttons use a `div` with
`unclickable` prop that adds `text-text-200` class and removes `cursor-pointer`. They are not
`<button disabled>` elements — they remain clickable in the DOM but navigation is guarded by
`Math.max`/`Math.min` clamps.
5. **Alert uses dark: modifiers**: The banner component uses `dark:` Tailwind classes, which
contradicts the project's `colors.css` theming convention. This is an existing code issue and
not a test failure.
---
## Assumptions
- All test scenarios begin from a fresh, clean state unless explicitly stated otherwise.
- The test user is logged in as an admin (`a@example.com` / `a`).
- A file connector is created via `OnyxApiClient.createFileConnector()` before each scenario that
needs one, and cleaned up in `afterEach` via `apiClient.deleteCCPair(testCcPairId)`.
- Indexing errors are seeded directly via `psql` or a dedicated test API endpoint because they are
produced by the background indexing pipeline in production.
- The connector detail page polls CC Pair data and errors every 5 seconds; tests that check
dynamic state must account for this polling interval (allow up to 10 seconds).
---
## Test Scenarios
### 1. Alert Banner Visibility
#### 1.1 No Errors - Banner Is Hidden
**Seed:** Create a file connector with zero `IndexAttemptError` records.
**Steps:**
1. Navigate to `/admin/connector/{ccPairId}`.
2. Wait for the page to finish loading (`networkidle`).
3. Observe the area between the connector header and the "Indexing" section title.
**Expected Results:**
- The yellow alert banner ("Some documents failed to index") is not present in the DOM.
- The "Indexing" section and its status card are visible.
---
#### 1.2 One or More Unresolved Errors - Banner Appears
**Seed:** Create a file connector, then insert at least one `IndexAttemptError` row with
`is_resolved = false` for that CC Pair.
**Steps:**
1. Navigate to `/admin/connector/{ccPairId}`.
2. Wait for the page to finish loading.
3. Observe the area above the "Indexing" section title.
**Expected Results:**
- The yellow alert banner is visible.
- The banner heading reads "Some documents failed to index".
- The banner body contains the text "We ran into some issues while processing some documents."
- The text "View details." is rendered as a bold, clickable element within the banner body.
---
#### 1.3 All Errors Resolved - Banner Disappears Automatically
**Seed:** Create a file connector with one `IndexAttemptError` where `is_resolved = false`.
**Steps:**
1. Navigate to `/admin/connector/{ccPairId}` and confirm the banner is visible.
2. Using `psql` or a direct DB update, set `is_resolved = true` on that error record.
3. Wait up to 10 seconds for the 5-second polling cycle to refresh the errors fetch.
4. Observe the banner area.
**Expected Results:**
- The yellow alert banner disappears without a manual page reload.
- No navigation or error occurs.
---
#### 1.4 Banner Absent for Invalid Connector With No Errors
**Seed:** Create a file connector with zero errors. Manually put it into `INVALID` status via the
DB.
**Steps:**
1. Navigate to `/admin/connector/{ccPairId}`.
2. Observe both the "Invalid Connector State" callout and the banner area.
**Expected Results:**
- The "Invalid Connector State" warning callout is visible.
- The yellow "Some documents failed to index" banner is absent.
- The two alerts do not overlap.
---
### 2. Opening and Closing the Modal
#### 2.1 Open Modal via "View Details" Link
**Seed:** Create a file connector with one unresolved `IndexAttemptError`.
**Steps:**
1. Navigate to `/admin/connector/{ccPairId}`.
2. Wait for the yellow alert banner to appear.
3. Click the bold "View details." text within the banner.
**Expected Results:**
- A modal dialog appears with the title "Indexing Errors" and an alert-triangle icon.
- The modal header has no description text (description is only shown in the resolving state).
- The modal body shows the paragraph starting with "Below are the errors encountered during
indexing."
- A second paragraph reads "Click the button below to kick off a full re-index..."
- The table is visible with four column headers: Time, Document ID, Error Message, Status.
- The one seeded error is displayed as a row.
- The modal footer contains a "Resolve All" button.
---
#### 2.2 Close Modal via the X Button
**Seed:** One unresolved `IndexAttemptError`.
**Steps:**
1. Open the Indexing Errors modal (scenario 2.1).
2. Click the close (X) button in the modal header.
**Expected Results:**
- The modal closes and is no longer visible.
- The connector detail page remains with the yellow alert banner still present.
- No navigation occurs.
---
#### 2.3 Close Modal via Escape Key
**Seed:** One unresolved `IndexAttemptError`.
**Steps:**
1. Open the Indexing Errors modal.
2. Press the Escape key.
**Expected Results:**
- The modal closes.
- The connector detail page remains intact with the banner still displayed.
---
#### 2.4 Close Modal via Backdrop Click
**Seed:** One unresolved `IndexAttemptError`.
**Steps:**
1. Open the Indexing Errors modal.
2. Click outside the modal content area on the dimmed backdrop.
**Expected Results:**
- The modal closes.
- The connector detail page remains intact.
---
#### 2.5 Modal Cannot Be Opened When Errors Are Resolving
**Seed:** Simulate `isResolvingErrors = true` by ensuring the latest index attempt has
`status = in_progress`, `from_beginning = true`, and no currently loaded errors share its
`index_attempt_id`.
**Steps:**
1. Navigate to `/admin/connector/{ccPairId}`.
2. Observe the yellow banner in the resolving state.
3. Confirm there is no "View details." link in the banner.
**Expected Results:**
- The banner body shows only the animated "Resolving failures" text (no "View details." link).
- There is no interactive element in the banner to open the modal.
---
### 3. Table Content and Rendering
#### 3.1 Single Error Row - All Fields Present
**Seed:** Insert one `IndexAttemptError` with:
- `document_id = "doc-123"`
- `document_link = "https://example.com/doc-123"`
- `failure_message = "Timeout while fetching document content"`
- `is_resolved = false`
- `time_created = <known timestamp>`
**Steps:**
1. Open the Indexing Errors modal.
2. Inspect the single data row in the table.
**Expected Results:**
- The "Time" cell displays a human-readable, localized version of `time_created`.
- The "Document ID" cell renders "doc-123" as an `<a>` element pointing to
`https://example.com/doc-123` with `target="_blank"` and `rel="noopener noreferrer"`.
- The "Error Message" cell shows "Timeout while fetching document content" in a 60px-height
scrollable div.
- The "Status" cell shows a badge with text "Unresolved" styled with red background.
---
#### 3.2 Error Without a Document Link - Plain Text ID
**Seed:** Insert one `IndexAttemptError` with `document_id = "doc-no-link"` and
`document_link = null`.
**Steps:**
1. Open the Indexing Errors modal.
2. Inspect the Document ID cell.
**Expected Results:**
- The Document ID cell displays "doc-no-link" as plain text with no `<a>` element or underline.
---
#### 3.3 Error With Entity ID Instead of Document ID
**Seed:** Insert one `IndexAttemptError` with `document_id = null`, `entity_id = "entity-abc"`,
and `document_link = null`.
**Steps:**
1. Open the Indexing Errors modal.
2. Inspect the Document ID cell.
**Expected Results:**
- The Document ID cell displays "entity-abc" as plain text (fallback to `entity_id` when
`document_id` is null and no link exists).
---
#### 3.4 Error With Document Link But No Document ID - Uses Entity ID in Link
**Seed:** Insert one `IndexAttemptError` with `document_id = null`,
`entity_id = "entity-link-test"`, and `document_link = "https://example.com/entity"`.
**Steps:**
1. Open the Indexing Errors modal.
2. Inspect the Document ID cell.
**Expected Results:**
- The Document ID cell renders "entity-link-test" as a hyperlink pointing to
`https://example.com/entity`.
- The link text is `entity_id` because `document_id` is null (code:
`error.document_id || error.entity_id || "Unknown"`).
---
#### 3.5 Error With Neither Document ID Nor Entity ID
**Seed:** Insert one `IndexAttemptError` with `document_id = null` and `entity_id = null`.
**Steps:**
1. Open the Indexing Errors modal.
2. Inspect the Document ID cell.
**Expected Results:**
- The Document ID cell displays the text "Unknown".
---
#### 3.6 Long Error Message Is Scrollable
**Seed:** Insert one `IndexAttemptError` with a `failure_message` of at least 500 characters.
**Steps:**
1. Open the Indexing Errors modal.
2. Locate the Error Message cell for that row.
3. Attempt to scroll within the cell.
**Expected Results:**
- The Error Message cell's inner `div` is capped at 60px height with `overflow-y-auto`.
- The cell content is scrollable, allowing the full message to be read.
- The table row height does not expand beyond 60px.
---
#### 3.7 Error Message With Special HTML Characters Is Escaped
**Seed:** Insert one `IndexAttemptError` with
`failure_message = "<script>alert('xss')</script>"`.
**Steps:**
1. Open the Indexing Errors modal.
2. Inspect the Error Message cell.
**Expected Results:**
- The text is rendered as a literal string, not interpreted as HTML.
- No JavaScript alert dialog appears.
- The exact text `<script>alert('xss')</script>` is visible as escaped content.
---
#### 3.8 Single-Character Error Message Does Not Break Layout
**Seed:** Insert one `IndexAttemptError` with `failure_message = "X"`.
**Steps:**
1. Open the Indexing Errors modal.
2. Inspect the Error Message cell and table row height.
**Expected Results:**
- The cell renders "X" without layout breakage.
- The row height remains at 60px.
---
#### 3.9 Resolved Errors Are Filtered Out of Modal by Default
**Seed:** Insert one `IndexAttemptError` with `is_resolved = true` and no unresolved errors.
**Steps:**
1. Make a direct API call: `GET /api/manage/admin/cc-pair/{ccPairId}/errors` (without
`include_resolved`).
2. Make a second call: `GET /api/manage/admin/cc-pair/{ccPairId}/errors?include_resolved=true`.
3. Navigate to `/admin/connector/{ccPairId}`.
**Expected Results:**
- The first API call returns zero items (`total_items = 0`).
- The second API call returns the resolved error with `is_resolved: true`.
- The yellow alert banner is absent from the connector page.
- The modal cannot be opened through normal UI (no "View details." link).
---
### 4. Pagination
#### 4.1 Single Page - No Pagination Controls
**Seed:** Insert 3 unresolved errors (the minimum page size).
**Steps:**
1. Open the Indexing Errors modal.
2. Observe whether a `PageSelector` is rendered below the table.
**Expected Results:**
- No `PageSelector` is rendered (`totalPages === 1`).
- All 3 errors are visible simultaneously in the table.
---
#### 4.2 Multiple Pages - Pagination Controls Appear
**Seed:** Insert 10 unresolved errors (matches the parent's `itemsPerPage: 10` fetch limit).
The modal's dynamic page size is typically larger than 3 but can be forced small by using a
narrow viewport.
**Steps:**
1. Set the browser viewport to a height that results in a page size smaller than 10 (e.g., a
height where the modal table container fits only 3 rows).
2. Open the Indexing Errors modal.
3. Observe the area below the table.
**Expected Results:**
- A `PageSelector` is rendered with "" and "" navigation controls.
- The current page indicator (page 1) is visually highlighted (active styling).
- Only the rows for page 1 are shown in the table body.
---
#### 4.3 Navigate to Next Page
**Seed:** Insert 10 unresolved errors and use a viewport that yields page size < 10.
**Steps:**
1. Open the Indexing Errors modal and confirm `PageSelector` is visible on page 1.
2. Note the Document IDs visible on page 1.
3. Click the "" (next page) button.
**Expected Results:**
- The table updates to show errors for page 2.
- The Document IDs on page 2 differ from those on page 1.
- The page 2 indicator becomes highlighted.
- The "" (previous page) button becomes clickable (no longer has `unclickable` styling).
---
#### 4.4 Navigate Back to Previous Page
**Seed:** Same as 4.3.
**Steps:**
1. Open the modal and navigate to page 2 (scenario 4.3).
2. Click the "" (previous page) button.
**Expected Results:**
- The table returns to showing page 1 errors.
- The page 1 indicator is highlighted.
- The "" button gains `unclickable` styling (lighter text, no pointer cursor).
---
#### 4.5 Previous Button Does Not Navigate Below Page 1
**Seed:** Insert enough errors to produce at least 2 pages.
**Steps:**
1. Open the Indexing Errors modal. Confirm the current page is 1.
2. Observe the "" button styling.
3. Click the "" button.
**Expected Results:**
- The "" button has `text-text-200` styling (from `PageLink unclickable` prop) and no
`cursor-pointer`.
- Note: The button is a `div`, not a `<button disabled>`. It remains clickable in the DOM, but the
handler clamps navigation to `Math.max(currentPage - 1, 1)`, so clicking it on page 1 has no
effect on the displayed rows.
- The current page remains page 1.
---
#### 4.6 Next Button Does Not Navigate Beyond Last Page
**Seed:** Insert exactly enough errors to produce 2 pages. Navigate to page 2.
**Steps:**
1. Open the modal and navigate to the last page.
2. Observe the "" button styling.
3. Click the "" button.
**Expected Results:**
- The "" button has `unclickable` styling on the last page.
- Clicking it does not navigate beyond the last page (clamped by `Math.min`).
---
#### 4.7 Page Resets to 1 When Error Count Changes
**Seed:** Insert 10 errors. Use a small viewport so multiple pages exist. Navigate to page 2.
**Steps:**
1. Open the modal and navigate to page 2.
2. While the modal is open, delete all error rows from the DB.
3. Wait up to 10 seconds for the polling cycle to reload errors.
**Expected Results:**
- The modal's error table shows the empty-state message.
- The `currentPage` state resets to 1 (triggered by the `useEffect` watching
`errors.items.length` and `errors.total_items`).
- The `PageSelector` disappears if only one (empty) page remains.
---
#### 4.8 API-Level Pagination: page_size Parameter Maximum
**Seed:** Insert 101 errors for the CC Pair.
**Steps:**
1. Make `GET /api/manage/admin/cc-pair/{ccPairId}/errors?page_size=100` as an authenticated admin.
2. Make `GET /api/manage/admin/cc-pair/{ccPairId}/errors?page_size=101`.
**Expected Results:**
- The `page_size=100` request returns 100 items and `total_items = 101`.
- The `page_size=101` request returns a 422 Unprocessable Entity error (backend enforces
`le=100`).
---
### 5. Resolve All Functionality
#### 5.1 Resolve All Button Triggers Full Re-Index and Shows Spinner
**Seed:** Create a file connector in ACTIVE status (not paused, not indexing, not invalid) with at
least one unresolved `IndexAttemptError`.
**Steps:**
1. Open the Indexing Errors modal.
2. Confirm the "Resolve All" button is visible in the modal footer.
3. Click "Resolve All".
**Expected Results:**
- The modal closes immediately.
- A full-screen `Spinner` component appears while the re-index request is in flight
(`showIsResolvingKickoffLoader = true` and `isResolvingErrors = false`).
- A success toast notification appears: "Complete re-indexing started successfully".
- The `Spinner` disappears after `triggerIndexing` resolves.
- The connector detail page is still visible.
- The yellow alert banner remains visible (errors are not immediately marked resolved; they resolve
as the re-index runs).
---
#### 5.2 Spinner Disappears Once Re-Index Is Picked Up
**Seed:** Same as 5.1. The re-index task must be picked up by a running Celery worker.
**Steps:**
1. Click "Resolve All" (scenario 5.1).
2. Wait for the Celery worker to start the index attempt (it will transition to `not_started` /
`in_progress` with `from_beginning = true`).
3. Observe the spinner and the banner.
**Expected Results:**
- Once `isResolvingErrors` becomes `true` (the latest attempt is in-progress, from-beginning, and
none of the currently loaded errors belong to it), the spinner condition
`showIsResolvingKickoffLoader && !isResolvingErrors` becomes false, hiding the spinner.
- The banner transitions to the "Resolving failures" pulse state.
---
#### 5.3 Resolve All Button Is Hidden When All Loaded Errors Are Resolved
**Note:** Since `usePaginatedFetch` fetches only unresolved errors by default (no
`include_resolved` param on the errors endpoint), zero unresolved errors means the banner is
absent and the modal cannot be opened via the normal UI. This scenario therefore validates the
banner-suppression mechanism:
**Steps:**
1. Ensure the CC Pair has zero unresolved `IndexAttemptError` records.
2. Navigate to `/admin/connector/{ccPairId}`.
3. Confirm the yellow alert banner is not present.
**Expected Results:**
- The yellow banner is absent.
- The modal cannot be opened via the banner (no "View details." link).
- The "Resolve All" button is therefore unreachable via the normal UI.
---
#### 5.4 Resolve All Button Is Hidden While Re-Index Is In Progress
**Seed:** Trigger a full re-index (scenario 5.1) and re-open the modal while it is running.
**Steps:**
1. Trigger "Resolve All" (scenario 5.1).
2. Immediately click "View details." in the banner to re-open the modal.
3. Observe the modal while the re-index is `in_progress`.
**Expected Results:**
- The modal header description reads: "Currently attempting to resolve all errors by performing a
full re-index. This may take some time to complete."
- The two explanatory body paragraphs ("Below are the errors..." and "Click the button below...") are
not visible.
- The "Resolve All" button is not present in the footer.
- The error rows are still displayed in the table.
---
#### 5.5 Resolve All Fails Gracefully When Connector Is Paused
**Seed:** Create a file connector that is in PAUSED status with unresolved errors.
**Steps:**
1. Open the Indexing Errors modal.
2. Confirm the "Resolve All" button is visible (it renders based on `hasUnresolvedErrors`, not on
connector status).
3. Click "Resolve All".
**Expected Results:**
- The modal closes and the spinner appears briefly.
- An error toast appears (because `triggerIndexing` returns an error message for paused
connectors).
- The spinner disappears after the failed request.
- The banner is still visible with the "View details." link.
---
### 6. Banner Resolving State
#### 6.1 Banner Shows "Resolving Failures" While Re-Index Is In Progress
**Seed:** Trigger a full re-index from the modal.
**Steps:**
1. Trigger "Resolve All".
2. Return to the connector detail page without re-opening the modal.
3. Observe the yellow alert banner body.
**Expected Results:**
- The banner body no longer shows "We ran into some issues..." or the "View details." link.
- Instead, the banner body shows a pulsing "Resolving failures" span
(`animate-pulse` CSS class).
- The banner heading still reads "Some documents failed to index".
- The banner remains visible until the re-index completes and errors are resolved.
---
#### 6.2 Banner Reverts to "View Details" if Resolving Attempt Gains New Errors
**Seed:** A re-index is in progress (`isResolvingErrors = true`). Force a new
`IndexAttemptError` with `index_attempt_id` matching the running attempt.
**Steps:**
1. Observe the banner in "Resolving failures" state.
2. Insert a new `IndexAttemptError` with `index_attempt_id = <running attempt id>` into the DB.
3. Wait for the 5-second polling cycle.
4. Observe the banner.
**Expected Results:**
- `isResolvingErrors` transitions back to `false` (the loaded errors now include one belonging to
the latest attempt).
- The banner reverts to showing "We ran into some issues..." with the "View details." link.
---
### 7. Empty State
#### 7.1 Empty Table When No Items on Current Page
**Seed:** Construct a state where the `errors.items` array is empty (zero errors loaded for the
CC Pair — this requires that `indexAttemptErrors.total_items > 0` to keep the banner visible, but
a data race occurs where errors are deleted while the modal is open).
**Steps:**
1. Open the modal with some errors visible.
2. Delete all error rows from the DB while the modal is open.
3. Wait for the 5-second polling cycle.
4. Observe the table body.
**Expected Results:**
- The table body shows a single row spanning all four columns with the text "No errors found on
this page" (centered, grayed-out `text-gray-500` style).
- The `PageSelector` may disappear (if `totalPages` drops to 1).
---
### 8. Access Control
#### 8.1 Non-Admin User Cannot Access the Errors API Endpoint
**Seed:** Ensure a basic (non-admin, non-curator) user account exists.
**Steps:**
1. Log in as the basic user.
2. Make a `GET /api/manage/admin/cc-pair/{ccPairId}/errors` request.
**Expected Results:**
- The API returns a 401 or 403 HTTP status code.
- The basic user cannot access the connector detail admin page via the UI.
---
#### 8.2 Curator User Can Access the Errors Endpoint and Open Modal
**Seed:** Ensure a curator user account exists and is assigned to the CC Pair's access group.
**Steps:**
1. Log in as the curator user.
2. Navigate to `/admin/connector/{ccPairId}` for a connector the curator can edit.
3. If unresolved errors exist, click "View details." to open the modal.
**Expected Results:**
- The modal opens successfully.
- The errors table is populated.
- The "Resolve All" button is visible (if unresolved errors exist and no re-index is running).
---
### 9. Data Freshness and Auto-Refresh
#### 9.1 New Error Appears Without Page Reload
**Seed:** Create a file connector with zero errors. Keep the connector detail page open.
**Steps:**
1. Navigate to `/admin/connector/{ccPairId}` and confirm no yellow banner.
2. Insert a new `IndexAttemptError` with `is_resolved = false` via the DB.
3. Wait up to 10 seconds (two 5-second polling cycles).
4. Observe the page.
**Expected Results:**
- The yellow alert banner appears automatically without a manual page refresh.
- No full page reload occurs.
---
#### 9.2 Errors List Refreshes While Modal Is Open
**Seed:** Create a file connector with 2 unresolved errors.
**Steps:**
1. Open the Indexing Errors modal.
2. Confirm 2 rows are visible.
3. Insert a third `IndexAttemptError` via the DB.
4. Wait up to 10 seconds for the polling cycle.
5. Observe the modal table.
**Expected Results:**
- The third error row appears in the table without closing and reopening the modal.
- If the total errors now exceed the current page size, the `PageSelector` appears if it was not
already present.
---
### 10. Page-Level Integration
#### 10.1 Connector Detail Page Continues to Function With Modal Open
**Seed:** Create a file connector with unresolved errors.
**Steps:**
1. Navigate to `/admin/connector/{ccPairId}`.
2. Open the Indexing Errors modal.
3. With the modal open, attempt to scroll the page behind the modal.
4. Close the modal.
5. Verify the page remains functional: the Indexing section status card is visible, and the
"Manage" dropdown button is present.
**Expected Results:**
- The modal renders over page content with a dimmed backdrop.
- Scrolling behind the modal does not cause layout issues.
- After closing the modal, all page elements are interactive and properly displayed.
- No state corruption occurs.
---
#### 10.2 Other Manage Dropdown Actions Are Unaffected
**Seed:** Create a file connector with unresolved errors.
**Steps:**
1. Navigate to `/admin/connector/{ccPairId}`.
2. Open the Indexing Errors modal and close it without clicking "Resolve All".
3. Open the "Manage" dropdown.
4. Confirm the "Re-Index", "Pause", and "Delete" items are visible and have the correct enabled
state.
**Expected Results:**
- The Manage dropdown functions normally after interacting with the errors modal.
- No state from the modal (e.g., lingering `showIsResolvingKickoffLoader`) affects the dropdown.
---
### 11. Boundary Conditions
#### 11.1 Minimum Page Size of 3 Rows
**Seed:** Insert enough errors to exceed page size.
**Steps:**
1. Set the browser viewport to a very small height (e.g., 300px total).
2. Open the Indexing Errors modal.
3. Observe the number of rows rendered per page.
**Expected Results:**
- The page size does not drop below 3 rows (enforced by `Math.max(3, ...)` in the `ResizeObserver`
callback).
- At least 3 rows are displayed per page regardless of available container height.
---
#### 11.2 Exactly the API Page Limit (10 Items) Displayed
**Seed:** Insert exactly 10 unresolved errors for the CC Pair.
**Steps:**
1. Open the Indexing Errors modal with a sufficiently large viewport.
2. Observe that all 10 errors are visible (assuming the dynamic page size is >= 10).
**Expected Results:**
- All 10 errors are accessible in the modal.
- No pagination is needed if the computed page size is >= 10.
- Note: If 11+ errors exist in the DB, only the first 10 (from `usePaginatedFetch`) are surfaced
in the modal. The 11th error would require a separate API call or a larger `itemsPerPage` config
to verify.
---
#### 11.3 Modal Opens Only When indexAttemptErrors Is Non-Null
**Steps:**
1. Observe the condition in `page.tsx`: `{showIndexAttemptErrors && indexAttemptErrors && ...}`.
2. During the initial page load (before the first poll completes), `indexAttemptErrors` is `null`.
**Expected Results:**
- Clicking "View details." while `indexAttemptErrors` is still null has no effect
(`setShowIndexAttemptErrors(true)` is called but the modal renders only when both
`showIndexAttemptErrors` and `indexAttemptErrors` are truthy).
- Once the first poll completes and errors are available, the modal renders normally.
---
## Test File Location
These tests should be implemented as Playwright E2E specs at:
```
web/tests/e2e/connectors/index_attempt_errors_modal.spec.ts
```
### Recommended OnyxApiClient Additions
The following methods should be added to `web/tests/e2e/utils/onyxApiClient.ts` to support
seeding and cleanup:
- `createIndexAttemptError(ccPairId, options)` - inserts an error record via `psql` or a dedicated
test endpoint; options include `documentId`, `documentLink`, `entityId`, `failureMessage`,
`isResolved`, `indexAttemptId`.
- `resolveAllIndexAttemptErrors(ccPairId)` - marks all errors for a CC Pair as resolved.
- `getIndexAttemptErrors(ccPairId, includeResolved?)` - calls the errors API and returns the
parsed response.
### Cleanup Strategy
Each test must clean up its CC Pair in an `afterEach` hook:
```typescript
test.afterEach(async ({ page }) => {
const apiClient = new OnyxApiClient(page.request);
if (testCcPairId !== null) {
await apiClient.deleteCCPair(testCcPairId);
testCcPairId = null;
}
});
```
Cascade deletes on the CC Pair will remove associated `IndexAttemptError` rows automatically.
### Polling Guidance
For scenarios that require waiting for auto-refresh to propagate state changes, use
`expect.poll()` with a 10-second timeout to avoid flaky tests:
```typescript
await expect.poll(
async () => page.locator('[data-testid="error-banner"]').isVisible(),
{ timeout: 10000 }
).toBe(true);
```

View File

@@ -0,0 +1,705 @@
import { test, expect } from "@playwright/test";
import { loginAs } from "@tests/e2e/utils/auth";
import { OnyxApiClient } from "@tests/e2e/utils/onyxApiClient";
import { execSync } from "child_process";
// ─── Database Helpers ─────────────────────────────────────────────────────────
// IndexAttemptError rows are produced by background workers in production.
// In tests we seed them directly via psql since there is no public API for it.
const DB_CONTAINER = process.env.DB_CONTAINER || "onyx-relational_db-1";
function psql(sql: string): string {
return execSync(`docker exec -i ${DB_CONTAINER} psql -U postgres -t -A`, {
input: sql,
encoding: "utf-8",
}).trim();
}
function getSearchSettingsId(): number {
const result = psql(
"SELECT id FROM search_settings WHERE status = 'PRESENT' ORDER BY id DESC LIMIT 1;"
);
const id = parseInt(result, 10);
if (isNaN(id)) {
throw new Error(
`No search_settings with status PRESENT found: "${result}"`
);
}
return id;
}
function createIndexAttempt(
ccPairId: number,
options: { fromBeginning?: boolean; status?: string } = {}
): number {
const { fromBeginning = false, status = "success" } = options;
const searchSettingsId = getSearchSettingsId();
const result = psql(
`INSERT INTO index_attempt (
connector_credential_pair_id, from_beginning, status, search_settings_id,
time_created, time_started, time_updated
) VALUES (
${ccPairId}, ${fromBeginning}, '${status}', ${searchSettingsId},
NOW(), NOW(), NOW()
) RETURNING id;`
);
const id = parseInt(result, 10);
if (isNaN(id)) {
throw new Error(`Failed to create index attempt: "${result}"`);
}
return id;
}
function sqlVal(v: string | null): string {
return v === null ? "NULL" : `'${v.replace(/'/g, "''")}'`;
}
interface CreateErrorOptions {
indexAttemptId: number;
ccPairId: number;
documentId?: string | null;
documentLink?: string | null;
entityId?: string | null;
failureMessage?: string;
isResolved?: boolean;
}
function createError(options: CreateErrorOptions): number {
const {
indexAttemptId,
ccPairId,
documentId = null,
documentLink = null,
entityId = null,
failureMessage = "Test indexing error",
isResolved = false,
} = options;
const result = psql(
`INSERT INTO index_attempt_errors (
index_attempt_id, connector_credential_pair_id,
document_id, document_link, entity_id,
failure_message, is_resolved, time_created
) VALUES (
${indexAttemptId}, ${ccPairId},
${sqlVal(documentId)}, ${sqlVal(documentLink)}, ${sqlVal(entityId)},
${sqlVal(failureMessage)}, ${isResolved}, NOW()
) RETURNING id;`
);
const id = parseInt(result, 10);
if (isNaN(id)) {
throw new Error(`Failed to create index attempt error: "${result}"`);
}
return id;
}
function createMultipleErrors(
indexAttemptId: number,
ccPairId: number,
count: number
): number[] {
const ids: number[] = [];
for (let i = 0; i < count; i++) {
ids.push(
createError({
indexAttemptId,
ccPairId,
documentId: `doc-${i + 1}`,
failureMessage: `Error #${i + 1}: Failed to index document`,
})
);
}
return ids;
}
function resolveAllErrors(ccPairId: number): void {
psql(
`UPDATE index_attempt_errors SET is_resolved = true
WHERE connector_credential_pair_id = ${ccPairId};`
);
}
// ─── Shared UI Helpers ────────────────────────────────────────────────────────
async function waitForBanner(page: import("@playwright/test").Page) {
await expect(page.getByText("Some documents failed to index")).toBeVisible({
timeout: 15000,
});
}
async function openErrorsModal(page: import("@playwright/test").Page) {
await waitForBanner(page);
await page.getByText("View details.").click();
await expect(page.getByText("Indexing Errors")).toBeVisible();
}
// ─── Tests ────────────────────────────────────────────────────────────────────
test.describe("Index Attempt Errors Modal", () => {
test.describe.configure({ retries: 2 });
let testCcPairId: number | null = null;
let testIndexAttemptId: number | null = null;
test.beforeEach(async ({ page }) => {
await page.context().clearCookies();
await loginAs(page, "admin");
const apiClient = new OnyxApiClient(page.request);
testCcPairId = await apiClient.createFileConnector(
`Error Modal Test ${Date.now()}`
);
testIndexAttemptId = createIndexAttempt(testCcPairId);
});
test.afterEach(async ({ page }) => {
if (testCcPairId !== null) {
const apiClient = new OnyxApiClient(page.request);
try {
await apiClient.pauseConnector(testCcPairId);
} catch {
// May already be paused
}
try {
await apiClient.deleteCCPair(testCcPairId);
} catch (error) {
console.warn(`Cleanup failed for CC pair ${testCcPairId}: ${error}`);
}
testCcPairId = null;
testIndexAttemptId = null;
}
});
// ── 1. Alert Banner Visibility ────────────────────────────────────────────
test("1.1 banner is hidden when no errors exist", async ({ page }) => {
await page.goto(`/admin/connector/${testCcPairId}`);
await page.waitForLoadState("networkidle");
await expect(
page.getByText("Some documents failed to index")
).not.toBeVisible();
});
test("1.2 banner appears when unresolved errors exist", async ({ page }) => {
createError({
indexAttemptId: testIndexAttemptId!,
ccPairId: testCcPairId!,
documentId: "doc-banner-test",
failureMessage: "Test error for banner visibility",
});
await page.goto(`/admin/connector/${testCcPairId}`);
await page.waitForLoadState("networkidle");
await waitForBanner(page);
await expect(
page.getByText("We ran into some issues while processing some documents.")
).toBeVisible();
await expect(page.getByText("View details.")).toBeVisible();
});
test("1.3 banner disappears when all errors are resolved", async ({
page,
}) => {
createError({
indexAttemptId: testIndexAttemptId!,
ccPairId: testCcPairId!,
failureMessage: "Error to be resolved",
});
await page.goto(`/admin/connector/${testCcPairId}`);
await page.waitForLoadState("networkidle");
await waitForBanner(page);
// Resolve all errors via DB
resolveAllErrors(testCcPairId!);
// Wait for the 5-second polling cycle to pick up the change
await expect(
page.getByText("Some documents failed to index")
).not.toBeVisible({ timeout: 15000 });
});
// ── 2. Opening and Closing the Modal ──────────────────────────────────────
test("2.1 modal opens via View details link with correct content", async ({
page,
}) => {
createError({
indexAttemptId: testIndexAttemptId!,
ccPairId: testCcPairId!,
documentId: "doc-modal-open",
failureMessage: "Error for modal open test",
});
await page.goto(`/admin/connector/${testCcPairId}`);
await page.waitForLoadState("networkidle");
await openErrorsModal(page);
// Explanatory text
await expect(
page.getByText("Below are the errors encountered during indexing.")
).toBeVisible();
await expect(
page.getByText(
"Click the button below to kick off a full re-index to try and resolve these errors."
)
).toBeVisible();
// Table headers
await expect(
page.getByRole("columnheader", { name: "Time" })
).toBeVisible();
await expect(
page.getByRole("columnheader", { name: "Document ID" })
).toBeVisible();
await expect(
page.getByRole("columnheader", { name: "Error Message" })
).toBeVisible();
await expect(
page.getByRole("columnheader", { name: "Status" })
).toBeVisible();
// Error row content
await expect(page.getByText("doc-modal-open")).toBeVisible();
await expect(page.getByText("Error for modal open test")).toBeVisible();
await expect(page.getByText("Unresolved")).toBeVisible();
// Resolve All button
await expect(
page.getByRole("button", { name: "Resolve All" })
).toBeVisible();
});
test("2.2 modal closes via X button", async ({ page }) => {
createError({
indexAttemptId: testIndexAttemptId!,
ccPairId: testCcPairId!,
failureMessage: "Error for close-X test",
});
await page.goto(`/admin/connector/${testCcPairId}`);
await page.waitForLoadState("networkidle");
await openErrorsModal(page);
// The close button is the first <button> in the dialog (rendered in Modal.Header)
// "Resolve All" is in the footer, so .first() gets the X button
const dialog = page.getByRole("dialog");
await dialog
.locator("button")
.filter({ hasNotText: /Resolve All/ })
.first()
.click();
await expect(page.getByText("Indexing Errors")).not.toBeVisible();
// Banner should still be present
await expect(
page.getByText("Some documents failed to index")
).toBeVisible();
});
test("2.3 modal closes via Escape key", async ({ page }) => {
createError({
indexAttemptId: testIndexAttemptId!,
ccPairId: testCcPairId!,
failureMessage: "Error for close-escape test",
});
await page.goto(`/admin/connector/${testCcPairId}`);
await page.waitForLoadState("networkidle");
await openErrorsModal(page);
await page.keyboard.press("Escape");
await expect(page.getByText("Indexing Errors")).not.toBeVisible();
await expect(
page.getByText("Some documents failed to index")
).toBeVisible();
});
// ── 3. Table Content and Rendering ────────────────────────────────────────
test("3.1 error row with document link renders as hyperlink", async ({
page,
}) => {
createError({
indexAttemptId: testIndexAttemptId!,
ccPairId: testCcPairId!,
documentId: "doc-linked",
documentLink: "https://example.com/doc-linked",
failureMessage: "Timeout while fetching document content",
});
await page.goto(`/admin/connector/${testCcPairId}`);
await page.waitForLoadState("networkidle");
await openErrorsModal(page);
const docLink = page.getByRole("link", { name: "doc-linked" });
await expect(docLink).toBeVisible();
await expect(docLink).toHaveAttribute(
"href",
"https://example.com/doc-linked"
);
await expect(docLink).toHaveAttribute("target", "_blank");
await expect(docLink).toHaveAttribute("rel", "noopener noreferrer");
await expect(
page.getByText("Timeout while fetching document content")
).toBeVisible();
await expect(page.getByText("Unresolved")).toBeVisible();
});
test("3.2 error without document link shows plain text ID", async ({
page,
}) => {
createError({
indexAttemptId: testIndexAttemptId!,
ccPairId: testCcPairId!,
documentId: "doc-no-link",
documentLink: null,
failureMessage: "Error without link",
});
await page.goto(`/admin/connector/${testCcPairId}`);
await page.waitForLoadState("networkidle");
await openErrorsModal(page);
await expect(page.getByText("doc-no-link")).toBeVisible();
// Should NOT be a link
await expect(page.getByRole("link", { name: "doc-no-link" })).toHaveCount(
0
);
});
test("3.3 error with entity ID fallback when no document ID", async ({
page,
}) => {
createError({
indexAttemptId: testIndexAttemptId!,
ccPairId: testCcPairId!,
documentId: null,
entityId: "entity-abc",
failureMessage: "Error with entity ID only",
});
await page.goto(`/admin/connector/${testCcPairId}`);
await page.waitForLoadState("networkidle");
await openErrorsModal(page);
await expect(page.getByText("entity-abc")).toBeVisible();
});
test("3.4 error with no document ID or entity ID shows Unknown", async ({
page,
}) => {
createError({
indexAttemptId: testIndexAttemptId!,
ccPairId: testCcPairId!,
documentId: null,
entityId: null,
failureMessage: "Error with no identifiers",
});
await page.goto(`/admin/connector/${testCcPairId}`);
await page.waitForLoadState("networkidle");
await openErrorsModal(page);
// The table cell should display "Unknown" as fallback
const dialog = page.getByRole("dialog");
await expect(dialog.getByText("Unknown")).toBeVisible();
});
test("3.5 entity ID used as link text when document link exists but no document ID", async ({
page,
}) => {
createError({
indexAttemptId: testIndexAttemptId!,
ccPairId: testCcPairId!,
documentId: null,
entityId: "entity-link-test",
documentLink: "https://example.com/entity",
failureMessage: "Error with entity link",
});
await page.goto(`/admin/connector/${testCcPairId}`);
await page.waitForLoadState("networkidle");
await openErrorsModal(page);
const link = page.getByRole("link", { name: "entity-link-test" });
await expect(link).toBeVisible();
await expect(link).toHaveAttribute("href", "https://example.com/entity");
});
test("3.6 HTML in error message is escaped (XSS safe)", async ({ page }) => {
createError({
indexAttemptId: testIndexAttemptId!,
ccPairId: testCcPairId!,
documentId: "doc-xss",
failureMessage: "<script>alert('xss')</script>",
});
await page.goto(`/admin/connector/${testCcPairId}`);
await page.waitForLoadState("networkidle");
await openErrorsModal(page);
// Text should be rendered literally, not executed as HTML
await expect(page.getByText("<script>alert('xss')</script>")).toBeVisible();
});
// ── 4. Pagination ─────────────────────────────────────────────────────────
test("4.1 no pagination controls when errors fit on one page", async ({
page,
}) => {
createMultipleErrors(testIndexAttemptId!, testCcPairId!, 2);
await page.goto(`/admin/connector/${testCcPairId}`);
await page.waitForLoadState("networkidle");
await openErrorsModal(page);
// Both errors should be visible
await expect(page.getByText("doc-1")).toBeVisible();
await expect(page.getByText("doc-2")).toBeVisible();
// PageSelector should not appear (only renders when totalPages > 1)
// The "" next-page button only exists when pagination is shown
const dialog = page.getByRole("dialog");
await expect(dialog.locator('text=""')).not.toBeVisible();
});
test("4.2 pagination appears and navigation works with many errors", async ({
page,
}) => {
// 10 errors should span multiple pages given the modal's dynamic page size
// (viewport 1280x720 typically yields ~5 rows per page in the modal)
createMultipleErrors(testIndexAttemptId!, testCcPairId!, 10);
await page.goto(`/admin/connector/${testCcPairId}`);
await page.waitForLoadState("networkidle");
await openErrorsModal(page);
const dialog = page.getByRole("dialog");
const nextBtn = dialog.locator('text=""');
const prevBtn = dialog.locator('text=""');
// If the viewport produces a page size >= 10, pagination won't appear
// Skip the navigation part in that case
if (await nextBtn.isVisible()) {
// Record page 1 content
const page1Content = await dialog.locator("table tbody").textContent();
// Navigate to page 2
await nextBtn.click();
const page2Content = await dialog.locator("table tbody").textContent();
expect(page2Content).not.toBe(page1Content);
// Navigate back to page 1
await prevBtn.click();
const backToPage1 = await dialog.locator("table tbody").textContent();
expect(backToPage1).toBe(page1Content);
}
});
// ── 5. Resolve All Functionality ──────────────────────────────────────────
test("5.1 Resolve All triggers re-index and shows success toast", async ({
page,
}) => {
createError({
indexAttemptId: testIndexAttemptId!,
ccPairId: testCcPairId!,
documentId: "doc-resolve",
failureMessage: "Error to resolve via re-index",
});
// Activate the connector so the re-index request can succeed
// (createFileConnector pauses the connector by default)
await page.request.put(`/api/manage/admin/cc-pair/${testCcPairId}/status`, {
data: { status: "ACTIVE" },
});
await page.goto(`/admin/connector/${testCcPairId}`);
await page.waitForLoadState("networkidle");
await openErrorsModal(page);
await page.getByRole("button", { name: "Resolve All" }).click();
// Modal should close
await expect(page.getByText("Indexing Errors")).not.toBeVisible();
// Success toast should appear
await expect(
page.getByText("Complete re-indexing started successfully")
).toBeVisible({ timeout: 15000 });
});
test("5.2 Resolve All button is absent when isResolvingErrors is true", async ({
page,
}) => {
createError({
indexAttemptId: testIndexAttemptId!,
ccPairId: testCcPairId!,
documentId: "doc-resolving",
failureMessage: "Error during resolving state",
});
// Create a separate index attempt that simulates a from-beginning re-index
// in progress, with no errors belonging to it
createIndexAttempt(testCcPairId!, {
fromBeginning: true,
status: "in_progress",
});
await page.goto(`/admin/connector/${testCcPairId}`);
await page.waitForLoadState("networkidle");
// The banner should show "Resolving failures" instead of "View details."
await expect(page.getByText("Some documents failed to index")).toBeVisible({
timeout: 15000,
});
await expect(page.getByText("Resolving failures")).toBeVisible();
await expect(page.getByText("View details.")).not.toBeVisible();
});
// ── 6. Data Freshness and Auto-Refresh ────────────────────────────────────
test("6.1 new error appears on page without manual reload", async ({
page,
}) => {
await page.goto(`/admin/connector/${testCcPairId}`);
await page.waitForLoadState("networkidle");
// Initially no banner
await expect(
page.getByText("Some documents failed to index")
).not.toBeVisible();
// Insert an error via DB while the page is already open
createError({
indexAttemptId: testIndexAttemptId!,
ccPairId: testCcPairId!,
documentId: "doc-auto-refresh",
failureMessage: "Error added while page is open",
});
// Wait for the 5-second polling cycle to pick it up
await expect(page.getByText("Some documents failed to index")).toBeVisible({
timeout: 15000,
});
});
test("6.2 errors list refreshes while modal is open", async ({ page }) => {
createError({
indexAttemptId: testIndexAttemptId!,
ccPairId: testCcPairId!,
documentId: "doc-existing",
failureMessage: "Pre-existing error",
});
await page.goto(`/admin/connector/${testCcPairId}`);
await page.waitForLoadState("networkidle");
await openErrorsModal(page);
await expect(page.getByText("doc-existing")).toBeVisible();
// Add a second error while the modal is open
createError({
indexAttemptId: testIndexAttemptId!,
ccPairId: testCcPairId!,
documentId: "doc-new-while-open",
failureMessage: "Error added while modal is open",
});
// The new error should appear after the polling cycle
await expect(page.getByText("doc-new-while-open")).toBeVisible({
timeout: 15000,
});
});
// ── 7. Access Control ─────────────────────────────────────────────────────
test("7.1 non-admin user cannot access the errors API endpoint", async ({
page,
}) => {
// Register a basic (non-admin) user
const email = `basic_${Date.now()}@example.com`;
const password = "TestPassword123!";
await page.request.post("/api/auth/register", {
data: { email, username: email, password },
});
// Login as the basic user
await page.context().clearCookies();
await page.request.post("/api/auth/login", {
form: { username: email, password },
});
// Try to access the errors endpoint
const errorsRes = await page.request.get(
`/api/manage/admin/cc-pair/${testCcPairId}/errors`
);
expect([401, 403]).toContain(errorsRes.status());
// Re-login as admin for afterEach cleanup
await page.context().clearCookies();
await loginAs(page, "admin");
// Clean up the basic user
const apiClient = new OnyxApiClient(page.request);
try {
await apiClient.deleteUser(email);
} catch {
// Ignore cleanup failures
}
});
// ── 8. Resolved Errors Filtered by Default ────────────────────────────────
test("8.1 resolved errors are not shown in the modal and banner is absent", async ({
page,
}) => {
createError({
indexAttemptId: testIndexAttemptId!,
ccPairId: testCcPairId!,
documentId: "doc-resolved",
failureMessage: "Already resolved error",
isResolved: true,
});
// API without include_resolved should return 0 items
const defaultRes = await page.request.get(
`/api/manage/admin/cc-pair/${testCcPairId}/errors`
);
expect(defaultRes.ok()).toBe(true);
const defaultData = await defaultRes.json();
expect(defaultData.total_items).toBe(0);
// API with include_resolved=true should return the error
const resolvedRes = await page.request.get(
`/api/manage/admin/cc-pair/${testCcPairId}/errors?include_resolved=true`
);
expect(resolvedRes.ok()).toBe(true);
const resolvedData = await resolvedRes.json();
expect(resolvedData.total_items).toBe(1);
expect(resolvedData.items[0].is_resolved).toBe(true);
// Banner should not appear on the page
await page.goto(`/admin/connector/${testCcPairId}`);
await page.waitForLoadState("networkidle");
await expect(
page.getByText("Some documents failed to index")
).not.toBeVisible();
});
// ── 9. API Pagination Boundary ────────────────────────────────────────────
test("9.1 API rejects page_size over 100", async ({ page }) => {
const res = await page.request.get(
`/api/manage/admin/cc-pair/${testCcPairId}/errors?page_size=101`
);
expect(res.status()).toBe(422);
});
});

View File

@@ -0,0 +1,210 @@
#!/usr/bin/env bash
#
# Seed IndexAttemptError records for local testing of the Index Attempt Errors Modal.
#
# Usage:
# ./seed_index_attempt_errors.sh [--cc-pair-id <ID>] [--count <N>] [--clean]
#
# Options:
# --cc-pair-id <ID> Use an existing CC pair (skips connector creation)
# --count <N> Number of unresolved errors to insert (default: 7)
# --clean Remove ALL test-seeded errors (those with failure_message LIKE 'SEED:%') and exit
#
# Without --cc-pair-id, the script creates a file connector via the API
# and prints its CC pair ID so you can navigate to /admin/connector/<ID>.
#
# Prerequisites:
# - Onyx services running (docker compose up)
# - curl and jq installed
set -euo pipefail
BASE_URL="${BASE_URL:-http://localhost:3000}"
ADMIN_EMAIL="${ADMIN_EMAIL:-admin_user@example.com}"
ADMIN_PASSWORD="${ADMIN_PASSWORD:-TestPassword123!}"
DB_CONTAINER="${DB_CONTAINER:-onyx-relational_db-1}"
CC_PAIR_ID=""
ERROR_COUNT=7
CLEAN=false
# --- Parse args ---
while [[ $# -gt 0 ]]; do
case "$1" in
--cc-pair-id) CC_PAIR_ID="$2"; shift 2 ;;
--count) ERROR_COUNT="$2"; shift 2 ;;
--clean) CLEAN=true; shift ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
# --- Helper: run psql ---
psql_exec() {
docker exec "$DB_CONTAINER" psql -U postgres -qtAX -c "$1"
}
# --- Clean mode ---
if $CLEAN; then
deleted=$(psql_exec "DELETE FROM index_attempt_errors WHERE failure_message LIKE 'SEED:%' RETURNING id;" | wc -l)
echo "Deleted $deleted seeded error(s)."
exit 0
fi
# --- Authenticate and get session cookie ---
COOKIE_JAR=$(mktemp)
trap 'rm -f "$COOKIE_JAR"' EXIT
echo "Authenticating as $ADMIN_EMAIL..."
login_resp=$(curl -s -o /dev/null -w "%{http_code}" \
-c "$COOKIE_JAR" \
-X POST "$BASE_URL/api/auth/login" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=${ADMIN_EMAIL}&password=${ADMIN_PASSWORD}")
if [[ "$login_resp" != "200" && "$login_resp" != "204" && "$login_resp" != "302" ]]; then
echo "Login failed (HTTP $login_resp). Check credentials." >&2
# Try the simpler a@example.com / a creds as fallback
echo "Retrying with a@example.com / a..."
ADMIN_EMAIL="a@example.com"
ADMIN_PASSWORD="a"
login_resp=$(curl -s -o /dev/null -w "%{http_code}" \
-c "$COOKIE_JAR" \
-X POST "$BASE_URL/api/auth/login" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=${ADMIN_EMAIL}&password=${ADMIN_PASSWORD}")
if [[ "$login_resp" != "200" && "$login_resp" != "204" && "$login_resp" != "302" ]]; then
echo "Login failed again (HTTP $login_resp)." >&2
exit 1
fi
fi
echo "Authenticated."
# --- Create a file connector if no CC pair specified ---
if [[ -z "$CC_PAIR_ID" ]]; then
echo "Creating file connector..."
create_resp=$(curl -s -b "$COOKIE_JAR" \
-X POST "$BASE_URL/api/manage/admin/connector-with-mock-credential" \
-H "Content-Type: application/json" \
-d '{
"name": "Seed Errors Test Connector",
"source": "file",
"input_type": "load_state",
"connector_specific_config": {"file_locations": []},
"refresh_freq": null,
"prune_freq": null,
"indexing_start": null,
"access_type": "public",
"groups": []
}')
CC_PAIR_ID=$(echo "$create_resp" | jq -r '.data // empty')
if [[ -z "$CC_PAIR_ID" ]]; then
echo "Failed to create connector: $create_resp" >&2
exit 1
fi
echo "Created CC pair ID: $CC_PAIR_ID"
else
echo "Using existing CC pair ID: $CC_PAIR_ID"
fi
# --- Find or create an index attempt for this CC pair ---
ATTEMPT_ID=$(psql_exec "
SELECT id FROM index_attempt
WHERE connector_credential_pair_id = $CC_PAIR_ID
ORDER BY id DESC LIMIT 1;
")
if [[ -z "$ATTEMPT_ID" ]]; then
echo "No index attempt found. Creating one..."
SEARCH_SETTINGS_ID=$(psql_exec "SELECT id FROM search_settings ORDER BY id DESC LIMIT 1;")
if [[ -z "$SEARCH_SETTINGS_ID" ]]; then
echo "No search_settings found in DB." >&2
exit 1
fi
ATTEMPT_ID=$(psql_exec "
INSERT INTO index_attempt (connector_credential_pair_id, search_settings_id, from_beginning, status, new_docs_indexed, total_docs_indexed, docs_removed_from_index, time_updated, completed_batches, total_chunks)
VALUES ($CC_PAIR_ID, $SEARCH_SETTINGS_ID, true, 'completed_with_errors', 5, 10, 0, now(), 0, 0)
RETURNING id;
")
echo "Created index attempt ID: $ATTEMPT_ID"
else
echo "Using existing index attempt ID: $ATTEMPT_ID"
fi
# --- Insert the curated test errors ---
echo "Inserting test errors..."
# Error 1: Document with link (hyperlinked doc ID)
psql_exec "
INSERT INTO index_attempt_errors (index_attempt_id, connector_credential_pair_id, document_id, document_link, entity_id, failure_message, is_resolved)
VALUES ($ATTEMPT_ID, $CC_PAIR_ID, 'doc-001', 'https://example.com/doc-001', NULL, 'SEED: Timeout while fetching document content from remote server', false);
"
# Error 2: Document without link (plain text doc ID)
psql_exec "
INSERT INTO index_attempt_errors (index_attempt_id, connector_credential_pair_id, document_id, document_link, entity_id, failure_message, is_resolved)
VALUES ($ATTEMPT_ID, $CC_PAIR_ID, 'doc-no-link', NULL, NULL, 'SEED: Permission denied accessing resource - authentication token expired', false);
"
# Error 3: Entity ID only (no document_id, no link)
psql_exec "
INSERT INTO index_attempt_errors (index_attempt_id, connector_credential_pair_id, document_id, document_link, entity_id, failure_message, is_resolved)
VALUES ($ATTEMPT_ID, $CC_PAIR_ID, NULL, NULL, 'entity-abc', 'SEED: Entity sync failed due to upstream rate limiting', false);
"
# Error 4: Entity ID with link (hyperlinked entity)
psql_exec "
INSERT INTO index_attempt_errors (index_attempt_id, connector_credential_pair_id, document_id, document_link, entity_id, failure_message, is_resolved)
VALUES ($ATTEMPT_ID, $CC_PAIR_ID, NULL, 'https://example.com/entity', 'entity-link-test', 'SEED: Connection reset by peer during entity fetch', false);
"
# Error 5: Neither document_id nor entity_id (renders "Unknown")
psql_exec "
INSERT INTO index_attempt_errors (index_attempt_id, connector_credential_pair_id, document_id, document_link, entity_id, failure_message, is_resolved)
VALUES ($ATTEMPT_ID, $CC_PAIR_ID, NULL, NULL, NULL, 'SEED: Unknown document failed with a catastrophic internal error that produced a very long error message designed to test the scrollable cell behavior in the modal UI. This message continues for quite a while to ensure the 60px height overflow-y-auto container is properly exercised during manual testing.', false);
"
# Error 6: XSS test (special HTML characters)
psql_exec "
INSERT INTO index_attempt_errors (index_attempt_id, connector_credential_pair_id, document_id, document_link, entity_id, failure_message, is_resolved)
VALUES ($ATTEMPT_ID, $CC_PAIR_ID, 'doc-xss', NULL, NULL, 'SEED: <script>alert(''xss'')</script>', false);
"
# Error 7: Single-character error message
psql_exec "
INSERT INTO index_attempt_errors (index_attempt_id, connector_credential_pair_id, document_id, document_link, entity_id, failure_message, is_resolved)
VALUES ($ATTEMPT_ID, $CC_PAIR_ID, 'doc-short', NULL, NULL, 'SEED: X', false);
"
# Insert additional generic errors if --count > 7
if (( ERROR_COUNT > 7 )); then
extra=$(( ERROR_COUNT - 7 ))
echo "Inserting $extra additional generic errors..."
for i in $(seq 1 "$extra"); do
psql_exec "
INSERT INTO index_attempt_errors (index_attempt_id, connector_credential_pair_id, document_id, document_link, entity_id, failure_message, is_resolved)
VALUES ($ATTEMPT_ID, $CC_PAIR_ID, 'doc-extra-$i', NULL, NULL, 'SEED: Generic error #$i for pagination testing', false);
"
done
fi
# Error: One resolved error (to test filtering)
psql_exec "
INSERT INTO index_attempt_errors (index_attempt_id, connector_credential_pair_id, document_id, document_link, entity_id, failure_message, is_resolved)
VALUES ($ATTEMPT_ID, $CC_PAIR_ID, 'doc-resolved', NULL, NULL, 'SEED: This error was already resolved', true);
"
# --- Verify ---
total=$(psql_exec "SELECT count(*) FROM index_attempt_errors WHERE connector_credential_pair_id = $CC_PAIR_ID AND failure_message LIKE 'SEED:%';")
unresolved=$(psql_exec "SELECT count(*) FROM index_attempt_errors WHERE connector_credential_pair_id = $CC_PAIR_ID AND failure_message LIKE 'SEED:%' AND is_resolved = false;")
echo ""
echo "=== Done ==="
echo "CC Pair ID: $CC_PAIR_ID"
echo "Index Attempt ID: $ATTEMPT_ID"
echo "Seeded errors: $total ($unresolved unresolved, $(( total - unresolved )) resolved)"
echo ""
echo "View in browser: $BASE_URL/admin/connector/$CC_PAIR_ID"
echo "API check: curl -b <cookies> '$BASE_URL/api/manage/admin/cc-pair/$CC_PAIR_ID/errors'"
echo ""
echo "To clean up: $0 --clean"
echo "To delete connector: curl -b <cookies> -X DELETE '$BASE_URL/api/manage/admin/cc-pair/$CC_PAIR_ID'"