diff --git a/e2e-tests/tests/plan-console.test.ts b/e2e-tests/tests/plan-console.test.ts new file mode 100644 index 0000000000..2f6a1a6c68 --- /dev/null +++ b/e2e-tests/tests/plan-console.test.ts @@ -0,0 +1,109 @@ +import test, { expect } from '@playwright/test'; +import { Status } from '../../src/enums/status.js'; +import { setupTest, teardownTest, type FullSetupResult } from '../utilities/api.js'; + +let setup: FullSetupResult; + +test.beforeAll(async ({ browser }) => { + setup = await setupTest(browser); + await setup.plan.goto(); +}); + +test.afterAll(async () => { + await teardownTest(setup); +}); + +test.describe.serial('Plan error console', () => { + test('All Problems aggregates BakeBananaBread validation errors', async () => { + await setup.plan.waitForActivityCheckingStatus(Status.Complete); + await setup.plan.addActivity('BakeBananaBread'); + await setup.plan.waitForActivityCheckingStatus(Status.Failed); + + // Open the console at the All Problems tab — clicking a tab always expands the pane. + const allProblemsTab = setup.plan.consoleContainer.getByRole('tab', { name: /All Problems/ }); + await allProblemsTab.click(); + await expect(allProblemsTab).toHaveAttribute('data-state', 'active'); + + // Validation errors for the new activity should be present. + const tabPanel = setup.plan.consoleContainer.getByRole('tabpanel').first(); + await expect(tabPanel.getByText('BakeBananaBread').first()).toBeVisible(); + }); + + test('Search filter narrows All Problems and shows the empty-state message', async () => { + const search = setup.plan.consoleContainer.getByPlaceholder('Search'); + const tabPanel = setup.plan.consoleContainer.getByRole('tabpanel').first(); + + await search.fill('BakeBananaBread'); + await expect(tabPanel.getByText('BakeBananaBread').first()).toBeVisible(); + + await search.fill('definitely-no-such-error'); + await expect(tabPanel.getByText(/No matches/i).first()).toBeVisible(); + + await search.fill(''); + }); + + test('Expanding a row reveals its full timestamp', async () => { + const tabPanel = setup.plan.consoleContainer.getByRole('tabpanel').first(); + const firstRow = tabPanel.locator('[data-index="0"]').first(); + const details = firstRow.locator('details'); + + await expect(details).not.toHaveAttribute('open', ''); + await firstRow.locator('summary').click(); + await expect(details).toHaveAttribute('open', ''); + await expect(firstRow.getByText(/Timestamp:/).first()).toBeVisible(); + + // Collapse to leave clean state for the next test. + await firstRow.locator('summary').click(); + await expect(details).not.toHaveAttribute('open', ''); + }); + + test('Open row state survives scrolling out of view and back', async () => { + // Bulk-create invalid activities via the API so the virtualized list is long + // enough that the first row gets recycled out of the DOM when scrolled away. + const bulk = Array.from({ length: 60 }, () => + setup.api.createActivityDirective({ + anchor_id: null, + anchored_to_start: true, + arguments: {}, + metadata: {}, + name: 'bad', + plan_id: setup.planId, + start_offset: 'PT0S', + type: 'BakeBananaBread', + }), + ); + await Promise.all(bulk); + await setup.plan.waitForActivityCheckingStatus(Status.Failed); + + // Wait for X/X in the activity-checking menu (matching numerator + denominator + // via the backreference) — strongest signal that the full batch has validated. + await setup.plan.hoverMenu(setup.plan.navButtonActivityChecking); + await expect(setup.plan.navButtonActivityCheckingMenu).toContainText(/(\d+)\/\1 activities checked/, { + timeout: 30_000, + }); + + // $allProblems regenerates `new Date()` timestamps on every derive, so + // `[data-index="0"]` is unstable. Pin to a directive ID instead — the row's + // message embeds it, so a hasText filter survives re-sorts and remounts. + const allProblemsTab = setup.plan.consoleContainer.getByRole('tab', { name: /All Problems/ }); + await allProblemsTab.click(); + const tabPanel = setup.plan.consoleContainer.getByRole('tabpanel').first(); + const firstRowText = await tabPanel.locator('[data-index="0"]').first().textContent(); + const idMatch = firstRowText?.match(/Activity Directive (\d+)/); + if (!idMatch) { + throw new Error(`Could not extract directive ID from first row: ${firstRowText}`); + } + const targetRow = tabPanel.locator('details').filter({ hasText: `Activity Directive ${idMatch[1]} ` }); + + await targetRow.locator('summary').click(); + await expect(targetRow).toHaveAttribute('open', ''); + + // Scroll the virtualized list to the bottom and back. After the roundtrip the + // targeted row must still be open — that's the contract `openIndices` keeps + // when the virtualizer remounts a recycled row. + const scrollContainer = tabPanel.getByTestId('console-logs-list'); + await scrollContainer.evaluate(el => el.scrollTo(0, el.scrollHeight)); + await scrollContainer.evaluate(el => el.scrollTo(0, 0)); + await expect(targetRow).toHaveAttribute('open', ''); + }); +}); diff --git a/e2e-tests/tests/workspace.test.ts b/e2e-tests/tests/workspace.test.ts index bd3dc33039..44c1474941 100644 --- a/e2e-tests/tests/workspace.test.ts +++ b/e2e-tests/tests/workspace.test.ts @@ -754,6 +754,33 @@ test.describe.serial('Workspace', () => { await expect(consoleNode.getByRole('tab', { name: 'Adaptation' })).toHaveAttribute('data-state', 'active'); }); + test('Linting tab populates from invalid sequence content and respects the search filter', async () => { + const { sequenceName } = await workspace.createSequence(); + await workspace.searchForFileAndWait(sequenceName); + await workspace.clickFile(sequenceName); + + // Type a command that's not in the dictionary so the CodeMirror linter fires. + await workspace.fillSequenceContent('R00:00:00 ZZZ_NOT_A_REAL_COMMAND\n'); + await workspace.saveSequence(); + + const consoleNode = setup.page.getByTestId('console'); + await consoleNode.getByRole('tab', { name: 'Linting' }).click(); + const tabPanel = consoleNode.getByRole('tabpanel').first(); + await expect(tabPanel.getByText(sequenceName).first()).toBeVisible(); + + // Now that we know the Linting tab has at least one row, exercise the shared + // ConsoleLogs filter path on the workspace side. A filter that excludes every + // row should swap the list for the noMatchingResultsMessage empty state. + const search = consoleNode.getByPlaceholder('Search'); + await search.fill('definitely-no-such-lint-error'); + await expect(tabPanel.getByText(/No matches/i).first()).toBeVisible(); + await search.fill(''); + await expect(tabPanel.getByText(sequenceName).first()).toBeVisible(); + + await workspace.searchForFileAndWait(sequenceName); + await workspace.deleteFile(sequenceName); + }); + test('Users not authorized to modify the workspace should not be able to', async () => { // Use userB's separate browser context - no login/logout needed! // userB is NOT a collaborator on this workspace diff --git a/e2e-tests/utilities/api.ts b/e2e-tests/utilities/api.ts index 3658093bff..6ad2b60871 100644 --- a/e2e-tests/utilities/api.ts +++ b/e2e-tests/utilities/api.ts @@ -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 }> { diff --git a/src/components/console/Console.svelte b/src/components/console/Console.svelte index 3f08c23981..fec940f6d7 100644 --- a/src/components/console/Console.svelte +++ b/src/components/console/Console.svelte @@ -127,7 +127,7 @@ -