Skip to content
Open
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
151 changes: 151 additions & 0 deletions e2e-tests/fixtures/Search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import type { Locator, Page } from '@playwright/test';
import { expect } from '@playwright/test';

/**
* Page Object for the cross-plan activity search page (`/search`).
*/
export class Search {
activityNameInput: Locator;
argumentValueInput: Locator;
clearFiltersButton: Locator;
formReady: Locator;
modifiedAfterInput: Locator;
modifiedBeforeInput: Locator;
noResultsOverlay: Locator;
pageInfo: Locator;
paginationFirstButton: Locator;
paginationLastButton: Locator;
paginationNextButton: Locator;
paginationPreviousButton: Locator;
panelHeader: Locator;
planNameInput: Locator;
resultsCountLabel: Locator;
resultsGrid: Locator;
resultsPanel: Locator;
resultsRows: Locator;
schedulerOnlyCheckbox: Locator;
searchButton: Locator;
searchingIndicator: Locator;
startOffsetMaxInput: Locator;
startOffsetMinInput: Locator;

constructor(public page: Page) {
this.updatePage(page);
}

/** Reset all filters and clear results via the "Clear Filters" button. */
async clearFilters(): Promise<void> {
await this.clearFiltersButton.click();
}

/**
* Click the per-row "Open in plan" icon button on the row whose Activity Name
* cell matches `name`. The button is rendered in the pinned-left container,
* not the center container, so we use the grid's row-index attribute to
* cross-reference the right row across containers.
*/
async clickOpenInPlanForRow(name: string): Promise<void> {
const centerRow = this.resultsRows.filter({ hasText: name }).first();
const rowIndex = await centerRow.getAttribute('row-index');
if (rowIndex === null) {
throw new Error(`Could not resolve row-index for row containing "${name}"`);
}
const pinnedRow = this.resultsGrid.locator(`.ag-pinned-left-cols-container .ag-row[row-index="${rowIndex}"]`);
await pinnedRow.getByRole('button', { name: 'Open in plan' }).click();
}

/** Click the row whose Activity Name cell matches `name`. */
async clickRow(name: string): Promise<void> {
await this.resultsRows.filter({ hasText: name }).first().click();
}

/** AG Grid column header by visible header name. Use to assert presence or to sort. */
columnHeader(headerName: string): Locator {
return this.resultsGrid.locator('.ag-header-cell', { hasText: headerName }).first();
}

/** Returns the count of currently rendered result rows. */
async getResultRowCount(): Promise<number> {
return this.resultsRows.count();
}

async goto(): Promise<void> {
await this.page.goto('/search', { waitUntil: 'load' });
await this.waitForFormReady();
}

/** Navigate to /search with the given query string (deep-link). */
async gotoWithParams(params: Record<string, string>): Promise<void> {
const qs = new URLSearchParams(params).toString();
await this.page.goto(`/search?${qs}`, { waitUntil: 'load' });
await this.waitForFormReady();
}

/**
* Submit the form and wait for the next search to complete.
*
* Uses the `data-search-run-id` attribute on the results panel — every
* completed search bumps it. We snapshot the value before clicking, then
* wait for it to change. This avoids relying on the `Searching…` indicator,
* which has a deliberate 500ms reveal delay (to prevent flashing on fast
* responses) and isn't observable for sub-500ms searches.
*/
async submitAndWait(): Promise<void> {
const before = (await this.resultsPanel.getAttribute('data-search-run-id')) ?? '0';
await this.searchButton.click();
await expect(this.resultsPanel).not.toHaveAttribute('data-search-run-id', before);
}

updatePage(page: Page): void {
this.page = page;

// Hydration signal: form's `data-search-form-ready` flips to "true" once
// SearchPanel finishes its first reactive run (post-mount), which is when
// `on:submit` and the bind:value handlers are wired up.
this.formReady = page.locator('form[data-search-form-ready="true"]');

// Form inputs — locate by role + accessible name (matching the visible label)
// so tests interact with the form the way a user sees it.
this.activityNameInput = page.getByRole('textbox', { name: 'Activity Name' });
this.argumentValueInput = page.getByRole('textbox', { name: 'Argument Value' });
this.modifiedAfterInput = page.getByRole('textbox', { name: 'Last Modified After' });
this.modifiedBeforeInput = page.getByRole('textbox', { name: 'Last Modified Before' });
this.startOffsetMinInput = page.getByRole('textbox', { name: 'Start Offset (min)' });
this.startOffsetMaxInput = page.getByRole('textbox', { name: 'Start Offset (max)' });
this.planNameInput = page.getByRole('textbox', { name: 'Plan Name' });
this.schedulerOnlyCheckbox = page.getByRole('checkbox', { name: 'Scheduler-created only' });

// Buttons
this.searchButton = page.getByRole('button', { exact: true, name: 'Search' });
this.clearFiltersButton = page.getByRole('button', { name: 'Clear Filters' });

// Results panel
this.panelHeader = page.getByText('Search Results', { exact: true });
this.resultsPanel = page.locator('[data-search-run-id]');
this.resultsCountLabel = page.locator('span.text-xs.text-muted-foreground').filter({ hasText: ' of ' });
this.searchingIndicator = page.locator('span.text-xs').filter({ hasText: 'Searching…' });

// Grid
this.resultsGrid = page.locator('.ag-root-wrapper').last();
this.resultsRows = this.resultsGrid.locator('.ag-center-cols-container .ag-row');
this.noResultsOverlay = this.resultsGrid.locator('.ag-overlay-no-rows-wrapper');

// Pagination
this.paginationFirstButton = page.getByRole('button', { name: 'First page' });
this.paginationPreviousButton = page.getByRole('button', { name: 'Previous page' });
this.paginationNextButton = page.getByRole('button', { name: 'Next page' });
this.paginationLastButton = page.getByRole('button', { name: 'Last page' });
this.pageInfo = page.locator('span').filter({ hasText: /^Page \d+ of/ });
}

/**
* Wait until SearchPanel has finished its first reactive run, signaled by
* `data-search-form-ready="true"` on the form. Without this, headless
* Playwright can click Search before Svelte's `on:submit` listener attaches,
* which makes the browser perform a default form GET to `/search?` and
* `onSearch` is never called.
*/
async waitForFormReady(): Promise<void> {
await expect(this.formReady).toBeVisible();
}
}
227 changes: 227 additions & 0 deletions e2e-tests/tests/search.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import test, { expect } from '@playwright/test';
import { Search } from '../fixtures/Search.js';
import { setupTest, teardownTest, type FullSetupResult } from '../utilities/api.js';

let setup: FullSetupResult;
let search: Search;

// We run `addActivity` via the API so the search has known data without depending on
// the timeline drag UI, and we filter every assertion by the unique plan name so other
// tests sharing the same backend can't pollute the result set.
const ACTIVITY_NAMES = {
bake: 'searchTest_BakeBananaBread',
grow1: 'searchTest_GrowBanana_alpha',
grow2: 'searchTest_GrowBanana_beta',
pick: 'searchTest_PickBanana',
};

test.beforeAll(async ({ browser }) => {
setup = await setupTest(browser);

await setup.api.createActivityDirective({
anchor_id: null,
anchored_to_start: true,
arguments: { quantity: 5 },
metadata: {},
name: ACTIVITY_NAMES.grow1,
plan_id: setup.planId,
start_offset: '01:00:00',
type: 'GrowBanana',
});
await setup.api.createActivityDirective({
anchor_id: null,
anchored_to_start: true,
arguments: { quantity: 7 },
metadata: {},
name: ACTIVITY_NAMES.grow2,
plan_id: setup.planId,
start_offset: '02:00:00',
type: 'GrowBanana',
});
await setup.api.createActivityDirective({
anchor_id: null,
anchored_to_start: true,
arguments: { quantity: 1 },
metadata: {},
name: ACTIVITY_NAMES.pick,
plan_id: setup.planId,
start_offset: '03:00:00',
type: 'PickBanana',
});
await setup.api.createActivityDirective({
anchor_id: null,
anchored_to_start: true,
arguments: { tbButter: 2, tbSugar: 1, temperature: 350 },
metadata: {},
name: ACTIVITY_NAMES.bake,
plan_id: setup.planId,
start_offset: '04:00:00',
type: 'BakeBananaBread',
});

search = new Search(setup.page);
});

test.afterAll(async () => {
await teardownTest(setup);
});

test.describe.serial('Activity Search', () => {
test('Should render the search page with empty results state', async () => {
await search.goto();
await expect(search.searchButton).toBeVisible();
await expect(search.clearFiltersButton).toBeVisible();
await expect(search.panelHeader).toBeVisible();
await expect(search.noResultsOverlay).toBeVisible();
});

test('Should return all 4 activities for the test plan when filtering by plan name', async () => {
await search.goto();
await search.planNameInput.fill(setup.planName);
await search.submitAndWait();

await expect(search.resultsCountLabel).toContainText('of 4');
await expect(search.resultsRows).toHaveCount(4);
for (const name of Object.values(ACTIVITY_NAMES)) {
await expect(search.resultsGrid.getByText(name, { exact: true })).toBeVisible();
}
});

test('Should filter by activity name substring', async () => {
await search.goto();
await search.planNameInput.fill(setup.planName);
await search.activityNameInput.fill('GrowBanana');
await search.submitAndWait();

await expect(search.resultsRows).toHaveCount(2);
await expect(search.resultsGrid.getByText(ACTIVITY_NAMES.grow1, { exact: true })).toBeVisible();
await expect(search.resultsGrid.getByText(ACTIVITY_NAMES.grow2, { exact: true })).toBeVisible();
});

test('Should filter by argument value (numeric)', async () => {
await search.goto();
await search.planNameInput.fill(setup.planName);
await search.argumentValueInput.fill('7');
await search.submitAndWait();

await expect(search.resultsRows).toHaveCount(1);
await expect(search.resultsGrid.getByText(ACTIVITY_NAMES.grow2, { exact: true })).toBeVisible();
});

test('Should filter by start_offset range', async () => {
await search.goto();
await search.planNameInput.fill(setup.planName);
await search.startOffsetMinInput.fill('02:00:00');
await search.startOffsetMaxInput.fill('03:00:00');
await search.submitAndWait();

await expect(search.resultsRows).toHaveCount(2);
await expect(search.resultsGrid.getByText(ACTIVITY_NAMES.grow2, { exact: true })).toBeVisible();
await expect(search.resultsGrid.getByText(ACTIVITY_NAMES.pick, { exact: true })).toBeVisible();
});

test('Should not show pagination controls when total results fit in one page', async () => {
await search.goto();
await search.planNameInput.fill(setup.planName);
await search.submitAndWait();

await expect(search.paginationFirstButton).toBeHidden();
await expect(search.paginationNextButton).toBeHidden();
});

test('Should clear all filters and reset results', async () => {
await search.goto();
await search.planNameInput.fill(setup.planName);
await search.activityNameInput.fill('GrowBanana');
await search.submitAndWait();
await expect(search.resultsRows).toHaveCount(2);

await search.clearFilters();

await expect(search.planNameInput).toHaveValue('');
await expect(search.activityNameInput).toHaveValue('');
await expect(search.noResultsOverlay).toBeVisible();
// `goto($page.url.pathname, ...)` is async — wait for the URL to drop its query.
await expect(search.page).toHaveURL(/\/search$/);
});

test('Should reflect form state in the URL after a search', async () => {
await search.goto();
await search.planNameInput.fill(setup.planName);
await search.activityNameInput.fill('GrowBanana');
await search.submitAndWait();

const url = new URL(search.page.url());
expect(url.searchParams.get('planName')).toEqual(setup.planName);
expect(url.searchParams.get('actName')).toEqual('GrowBanana');
});

test('Should populate the form and run a search when navigating with deep-link params', async () => {
await search.gotoWithParams({
actName: 'GrowBanana',
planName: setup.planName,
});
// The deep-link path auto-runs a search on mount; wait for it to complete.
await expect(search.resultsPanel).toHaveAttribute('data-search-run-id', /^[1-9]\d*$/);

await expect(search.planNameInput).toHaveValue(setup.planName);
await expect(search.activityNameInput).toHaveValue('GrowBanana');
await expect(search.resultsRows).toHaveCount(2);
});

test('Should open the activity in a new tab when the per-row "Open in plan" button is clicked', async () => {
await search.goto();
await search.planNameInput.fill(setup.planName);
await search.activityNameInput.fill(ACTIVITY_NAMES.pick);
await search.submitAndWait();
await expect(search.resultsRows).toHaveCount(1);

const [popup] = await Promise.all([
search.page.waitForEvent('popup'),
search.clickOpenInPlanForRow(ACTIVITY_NAMES.pick),
]);

await popup.waitForLoadState('domcontentloaded');
expect(popup.url()).toContain(`/plans/${setup.planId}`);
expect(popup.url()).toContain('activityId=');
await popup.close();
});

test('Should render Model, Model ID, and Absolute Start Time columns by default', async () => {
await search.goto();
await search.planNameInput.fill(setup.planName);
await search.submitAndWait();

await expect(search.columnHeader('Model')).toBeVisible();
await expect(search.columnHeader('Model ID')).toBeVisible();
await expect(search.columnHeader('Absolute Start Time')).toBeVisible();
});

test('Should filter by union of activity types via deep-link multi-select', async () => {
// Multi-type filter is array-valued and serialized comma-joined into the URL.
// Use the deep-link path to exercise the _in clause without driving the multi-select dropdown UI.
await search.gotoWithParams({
actType: 'GrowBanana,PickBanana',
planName: setup.planName,
});
await expect(search.resultsPanel).toHaveAttribute('data-search-run-id', /^[1-9]\d*$/);

await expect(search.resultsRows).toHaveCount(3);
await expect(search.resultsGrid.getByText(ACTIVITY_NAMES.grow1, { exact: true })).toBeVisible();
await expect(search.resultsGrid.getByText(ACTIVITY_NAMES.grow2, { exact: true })).toBeVisible();
await expect(search.resultsGrid.getByText(ACTIVITY_NAMES.pick, { exact: true })).toBeVisible();
await expect(search.resultsGrid.getByText(ACTIVITY_NAMES.bake, { exact: true })).toBeHidden();
});

test('Should filter by argument value when typed as a JSON array (subset containment)', async () => {
await search.goto();
await search.planNameInput.fill(setup.planName);
await search.argumentValueInput.fill('[5]');
await search.submitAndWait();

// Type `[5]` would match an arg whose value is a superset like `[1, 2, 5]`.
// No activity in this test plan has an array-valued arg, so this should
// return zero rows — the assertion is that the query doesn't error.
await expect(search.noResultsOverlay).toBeVisible();
});
});
4 changes: 2 additions & 2 deletions e2e-tests/utilities/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,10 @@ export class AerieApi {
}

async createActivityDirective(activityDirective: ActivityDirectiveInsertInput): Promise<{ id: number }> {
const data = await this.gqlQuery<{ createActivityDirective: { id: number } }>(gql.CREATE_ACTIVITY_DIRECTIVE, {
const data = await this.gqlQuery<{ insert_activity_directive_one: { id: number } }>(gql.CREATE_ACTIVITY_DIRECTIVE, {
activityDirectiveInsertInput: activityDirective,
});
return { id: data.createActivityDirective.id };
return { id: data.insert_activity_directive_one.id };
}

async createConstraint(constraint: ConstraintDefinitionInsertInput): Promise<{ id: number }> {
Expand Down
Loading
Loading