Back to Kibana

Example Migration: Timeline Creation

.agents/skills/cypress-to-scout-migration/references/example-migration.md

9.4.011.7 KB
Original Source

Example Migration: Timeline Creation

Real migration of investigations/timelines/creation.cy.ts to Scout. Demonstrates triage decisions, pattern mapping, page object design, API service design, and cleanup strategy.

Triage summary

GateResult
Gate 0: Feature valid?Yes — timeline creation UI unchanged
Gate 1: Already covered?No Scout or API test covers these flows
Gate 2: Right layer?UI — tests user workflows (create, save, RBAC, state lifecycle)
Gate 3: Adds value?Yes — validates save states, RBAC, template creation
Gate 4: Flakiness risk?Medium — { force: true } on collapsed actions button (app bug), LOADING_INDICATOR waits

Before: Cypress (creation.cy.ts, abbreviated)

typescript
import { ROWS } from '../../../screens/timelines';
import { deleteTimelines, createTimelineTemplate } from '../../../tasks/api_calls/timelines';
import { login } from '../../../tasks/login';
import { addNameToTimelineAndSave, executeTimelineKQL, closeTimeline } from '../../../tasks/timeline';

describe('Timelines', { tags: ['@ess', '@serverless'] }, () => {
  beforeEach(() => {
    deleteTimelines();                                  // API cleanup — no afterEach
  });

  it('should show the different timeline states', () => {
    login();
    visitWithTimeRange(TIMELINES_URL);
    openTimelineUsingToggle();

    cy.get(TIMELINE_STATUS).invoke('text').should('match', /^Unsaved/);

    addNameToTimelineAndSave('Test');
    cy.get(TIMELINE_STATUS).should('not.exist');

    cy.get(LOADING_INDICATOR).should('be.visible');     // Sync on background save
    cy.get(LOADING_INDICATOR).should('not.exist');

    executeTimelineKQL('agent.name : *');
    cy.get(TIMELINE_STATUS).invoke('text').should('match', /^Unsaved changes/);
  });

  it('should save timelines as new', () => {
    login();
    visitWithTimeRange(TIMELINES_URL);
    cy.get(ROWS).should('have.length', '0');            // EuiBasicTable quirk — see below

    openTimelineUsingToggle();
    addNameToTimelineAndSave('First');

    cy.get(LOADING_INDICATOR).should('be.visible');
    cy.get(LOADING_INDICATOR).should('not.exist');

    addNameToTimelineAndSaveAsNew('Second');
    closeTimeline();

    cy.get(ROWS).should('have.length', '2');
    cy.get(ROWS).first().invoke('text').should('match', /Second/);  // .first()/.last() — see below
    cy.get(ROWS).last().invoke('text').should('match', /First/);
  });
});

Key problems in the Cypress source

PatternRiskScout approach
LOADING_INDICATOR waitCritical — hard-coded UI sync pointReplaced with waitForSaveComplete() using expect.poll() or locator assertion
No afterEach cleanupCritical — Scout shares environmentAdded beforeEach + afterAll cleanup via apiServices.timeline.deleteAll()
.first() / .last() on rowsHigh — forbidden by playwright/no-nth-methodsReplaced with toContainText(['Second', 'First']) (ordered array)
cy.get(ROWS).should('have.length', '0')Medium — EuiBasicTable always renders an empty-state rowAssert on empty-state message text instead
Selectors in separate screens/ filesStructuralMoved to page object readonly properties
Actions in separate tasks/ filesStructuralMoved to page object methods

After: Scout (split into two files — one role per file)

The migration splits tests by role: CRUD tests in timeline_creation.spec.ts (platform engineer) and read-only tests in timeline_read_only.spec.ts (T1 analyst). Each file is self-contained with its own setup/teardown and login in beforeEach.

timeline_creation.spec.ts (CRUD role)

typescript
import { spaceTest, tags } from '@kbn/scout-security';
import { expect } from '@kbn/scout-security/ui';

spaceTest.describe(
  'Timeline creation',
  { tag: [...tags.stateful.classic, ...tags.serverless.security.complete] },
  () => {
    spaceTest.beforeEach(async ({ browserAuth, apiServices, pageObjects }) => {
      await apiServices.timeline.deleteAll();
      await browserAuth.loginAsPlatformEngineer();
      await pageObjects.timelinePage.navigateToTimelines();
    });

    spaceTest.afterAll(async ({ apiServices }) => {
      await apiServices.timeline.deleteAll();
    });

    spaceTest('should show the different timeline states', async ({ pageObjects }) => {
      const { timelinePage } = pageObjects;

      await timelinePage.open();

      await spaceTest.step('Verify unsaved state', async () => {
        await expect(timelinePage.saveStatus).toHaveText(/^Unsaved/);
      });

      await spaceTest.step('Save and verify saved state', async () => {
        await timelinePage.saveWithName('Test');
        await expect(timelinePage.saveStatus).toBeHidden();
      });

      await spaceTest.step('Modify query and verify unsaved changes', async () => {
        await timelinePage.executeKQL('agent.name : *');
        await expect(timelinePage.saveStatus).toHaveText(/^Unsaved changes/);
      });
    });

    spaceTest('should save timelines as new', async ({ pageObjects }) => {
      const { timelinePage } = pageObjects;

      await spaceTest.step('Verify empty state', async () => {
        await expect(timelinePage.timelinesTable).toContainText(
          '0 timelines match the search criteria'
        );
      });

      await spaceTest.step('Create, save, and save as new', async () => {
        await timelinePage.open();
        await timelinePage.saveWithName('First');
        await expect(timelinePage.saveStatus).toBeHidden();
        await timelinePage.saveAsNew('Second');
      });

      await spaceTest.step('Verify both timelines in list', async () => {
        await timelinePage.close();
        await expect(timelinePage.timelineRows).toHaveCount(2);
        await expect(timelinePage.timelineRows).toContainText(['Second', 'First']);
      });
    });
  }
);

timeline_read_only.spec.ts (read-only role)

typescript
import { spaceTest, tags } from '@kbn/scout-security';
import { expect } from '@kbn/scout-security/ui';

spaceTest.describe(
  'Timeline read-only',
  { tag: [...tags.stateful.classic, ...tags.serverless.security.complete] },
  () => {
    spaceTest.beforeEach(async ({ browserAuth, apiServices, pageObjects }) => {
      await apiServices.timeline.deleteAll();
      await browserAuth.loginAsT1Analyst();
      await pageObjects.timelinePage.navigateToTimelines();
    });

    spaceTest.afterAll(async ({ apiServices }) => {
      await apiServices.timeline.deleteAll();
    });

    spaceTest(
      'should not be able to create/update timeline with only read privileges',
      async ({ pageObjects }) => {
        const { timelinePage } = pageObjects;

        await timelinePage.open();
        await timelinePage.createNew();

        await expect(timelinePage.panel).toBeVisible();
        await expect(timelinePage.saveButton).toBeDisabled();

        await spaceTest.step('Hover save button and verify read-only tooltip', async () => {
          await timelinePage.hoverSaveButton();
          await expect(timelinePage.saveTooltip).toContainText(
            'you do not have the required permissions to save timelines'
          );
        });
      }
    );
  }
);

Key decisions annotated

DecisionWhy
spaceTest (not test)Enables parallel execution — each worker gets its own Kibana space
One role per fileSimulates a realistic user flow — each file = one role, one full-flow
Login + navigation in beforeEachShared setup across all tests in the file — avoids duplication
browserAuth.loginAsPlatformEngineer()Least-privileged role for CRUD. Not loginAsAdmin() (masks permission bugs)
browserAuth.loginAsT1Analyst()Read-only RBAC test — verifies save button is disabled
spaceTest.step() for multi-step flowsReuses browser context within a single test (each spaceTest() creates a new context)
beforeEach + afterAll cleanupbeforeEach handles prior failed runs; afterAll cleans up after the suite
apiServices.timeline.deleteAll()API-based cleanup — not UI-based (faster, more reliable)
toContainText(['Second', 'First'])Ordered array assertion — replaces .first() / .last() (forbidden by playwright/no-nth-methods)

Page object: TimelinePage (abbreviated)

typescript
export class TimelinePage {
  // All locators as readonly constructor properties — centralized, auditable
  readonly panel: Locator;
  readonly saveStatus: Locator;
  readonly saveButton: Locator;
  readonly kqlTextarea: Locator;
  readonly saveButtonTooltipAnchor: Locator;

  constructor(private readonly page: ScoutPage) {
    this.panel = this.page.testSubj.locator('timeline-modal-header-panel');
    // saveStatus scoped to panel — avoids strict mode violation (appears in header AND bottom bar)
    this.saveStatus = this.panel.locator('[data-test-subj="timeline-save-status"]');
    this.kqlTextarea = this.page.testSubj
      .locator('timeline-search-or-filter-search-container')
      .locator('textarea');
    // CSS :has() for parent selection — EUI wraps disabled buttons in a tooltip anchor <span>
    this.saveButtonTooltipAnchor = this.page.locator(
      'span:has([data-test-subj="timeline-modal-save-timeline"])'
    );
  }

  async executeKQL(query: string) {
    await this.kqlTextarea.click();
    await this.kqlTextarea.clear();
    // pressSequentially — QueryStringInput submits React props on Enter, not DOM value.
    // fill() sets DOM value synchronously but React props update asynchronously.
    await this.kqlTextarea.pressSequentially(query);
    await this.kqlTextarea.press('Enter');
  }

  async hoverSaveButton() {
    // EUI wraps disabled buttons in a tooltip anchor that intercepts pointer events.
    await this.saveButtonTooltipAnchor.hover();
  }
}

Patterns demonstrated

PatternWhereWhy
Scoped locatorssaveStatus scoped to panelAvoids strict mode violation when same data-test-subj appears in multiple DOM locations
CSS :has() for parent selectionsaveButtonTooltipAnchorEUI wraps disabled buttons — hover the wrapper, not the button. Avoids XPath.
pressSequentially for query barsexecuteKQL()QueryStringInput React prop sync race — fill() + Enter submits stale value
Private helpersopenSaveModalAndSetTitle(), confirmSaveModal()Shared logic between saveWithName(), saveAsNew(), addNameAndDescription()

API service: TimelineApiService (abbreviated)

typescript
export const getTimelineApiService = ({
  kbnClient, log, scoutSpace,
}: {
  kbnClient: KbnClient;
  log: ScoutLogger;
  scoutSpace?: ScoutParallelWorkerFixtures['scoutSpace'];  // Space-aware for parallel tests
}): TimelineApiService => {
  const basePath = scoutSpace?.id ? `/s/${scoutSpace.id}` : '';

  return {
    createTimeline: async (input = {}) => { /* POST to /api/timeline */ },
    createTimelineTemplate: async (input = {}) => { /* POST with timelineType: 'template' */ },
    deleteAll: async () => {
      // Must fetch and delete both 'default' and 'template' types separately
      const [defaultIds, templateIds] = await Promise.all([
        fetchAllSavedObjectIds('default'),
        fetchAllSavedObjectIds('template'),
      ]);
      // ...
    },
  };
};

Key design decisions

DecisionWhy
Space-aware basePathSupports spaceTest parallel execution — requests go to the worker's isolated space
deleteAll() fetches both typesTimelines and templates use the same API but different timeline_type — must delete both
measurePerformanceAsync wrapperBuilt-in Scout performance instrumentation
Default values with spread override{ ...DEFAULT_TIMELINE, ...input } — callers only specify what they need