Skip to content
Draft
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
109 changes: 109 additions & 0 deletions e2e-tests/tests/plan-console.test.ts
Original file line number Diff line number Diff line change
@@ -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', '');
});
});
27 changes: 27 additions & 0 deletions e2e-tests/tests/workspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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
2 changes: 1 addition & 1 deletion src/components/console/Console.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@
</div>
</Tabs.List>
<!-- Always render content, it will be hidden by parent's Resizable pane -->
<div class="flex-1 overflow-y-auto">
<div class="min-h-0 flex-1 overflow-hidden">
<slot />
</div>
</Tabs.Root>
Expand Down
43 changes: 39 additions & 4 deletions src/components/console/views/ConsoleLog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,26 @@
<script lang="ts">
import { cn } from '@nasa-jpl/stellar-svelte';
import { ChevronDown, ChevronRight } from 'lucide-svelte';
import { onMount } from 'svelte';
import { createEventDispatcher, onMount, tick } from 'svelte';
import type { BaseError, LogMessage } from '../../../types/errors';
import { isLogMessage } from '../../../utilities/errors';

import { safeStringify } from '../../../utilities/text';
import { formatMS } from '../../../utilities/time';

export let log: BaseError;
export let index: number = -1;
export let defaultExpanded: boolean = false;
export let showLevel: boolean = true;
export let showTimestamp: boolean = true;
export let showLongTimestamp: boolean = true;
export let showType: boolean = true;

const dispatch = createEventDispatcher<{
toggle: { index: number; open: boolean; size: number };
}>();

let detailsEl: HTMLDetailsElement;
let expandable: boolean = false;
let leftContents: HTMLDivElement;
let open: boolean = defaultExpanded;
Expand All @@ -30,10 +36,37 @@
$: renderedMessage =
!log.message.trim() && log.data && !(expandable && open) ? safeStringify(log.data) : (log.message ?? '');

// All layout measurement is deferred to toggle time. Per-row measurement on mount
// (via `use:measureElement` or onMount reads) was the dominant cost during scroll —
// each row mount forced a sync layout, scaling with overscan and scroll velocity.
// On toggle we read once, then push the new size into the virtualizer imperatively
// via `resizeItem` (handled by the parent through the dispatched event).
async function dispatchSize() {
if (index < 0) {
return;
}
await tick();
if (!detailsEl) {
return;
}
const size = detailsEl.getBoundingClientRect().height;
dispatch('toggle', { index, open, size });
}

async function onToggle() {
if (open && leftContents && !expansionPadding) {
expansionPadding = leftContents.clientWidth + 12;
}
await dispatchSize();
}

// When a row mounts already open (e.g. it was open before the virtualizer
// recycled it out of view), the browser does not fire a `toggle` event, so
// we push the real size to the virtualizer once on mount. Collapsed rows
// skip this — they match the estimate, so no measurement is needed.
onMount(() => {
// On mount, calculate the amount of padding needed for the expansion content
if (leftContents) {
expansionPadding = leftContents.clientWidth + 12; // Add 12px to account for gaps
if (open) {
dispatchSize();
}
});

Expand Down Expand Up @@ -72,8 +105,10 @@

<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<details
bind:this={detailsEl}
class="group"
bind:open
on:toggle={onToggle}
on:keypress={e => {
// prevent expansion when no content is available
if (!expandable) {
Expand Down
Loading
Loading