diff --git a/.github/workflows/lintBuildTest.yml b/.github/workflows/lintBuildTest.yml index ad5246112..d7efcd9c8 100644 --- a/.github/workflows/lintBuildTest.yml +++ b/.github/workflows/lintBuildTest.yml @@ -46,4 +46,4 @@ jobs: - name: Install Playwright run: npx playwright install --with-deps - name: Run Playwright tests - run: yarn playwright test + run: yarn playwright test --workers=3 diff --git a/OMICRON_VERSION b/OMICRON_VERSION index 8b63d6dff..f5244aa2a 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -5e835f19680655a89ff1dca2a6e18f1f269ac021 +6d8d6a4580db44a885f7a213aa39ada76d8af2d6 diff --git a/app/pages/__tests__/instance/networking.e2e.ts b/app/pages/__tests__/instance/networking.e2e.ts index 915d2b2ef..8b12937ea 100644 --- a/app/pages/__tests__/instance/networking.e2e.ts +++ b/app/pages/__tests__/instance/networking.e2e.ts @@ -9,14 +9,9 @@ test('Instance networking tab', async ({ page }) => { // Instance networking tab await page.click('role=tab[name="Networking"]') - await expectRowVisible(page, 'my-nic', [ - 'my-nic', - 'a network interface', - '172.30.0.10', - 'mock-vpc', - 'mock-subnet', - 'primary', - ]) + + const table = page.locator('table') + await expectRowVisible(table, { name: 'my-nic', primary: 'primary' }) // check VPC link in table points to the right page await expect(page.locator('role=cell >> role=link[name="mock-vpc"]')).toHaveAttribute( @@ -54,15 +49,8 @@ test('Instance networking tab', async ({ page }) => { .locator('role=button[name="Row actions"]') .click() await page.click('role=menuitem[name="Make primary"]') - await expectRowVisible(page, 'my-nic', [ - 'my-nic', - 'a network interface', - '172.30.0.10', - 'mock-vpc', - 'mock-subnet', - '', - ]) - await expectRowVisible(page, 'nic-2', ['nic-2', null, null, null, null, 'primary']) + await expectRowVisible(table, { name: 'my-nic', primary: '' }) + await expectRowVisible(table, { name: 'nic-2', primary: 'primary' }) // Make an edit to the network interface await page diff --git a/app/pages/__tests__/org-access.e2e.ts b/app/pages/__tests__/org-access.e2e.ts index effd8b03a..582ab1739 100644 --- a/app/pages/__tests__/org-access.e2e.ts +++ b/app/pages/__tests__/org-access.e2e.ts @@ -5,13 +5,15 @@ import { expectNotVisible, expectRowVisible, expectVisible } from 'app/util/e2e' test('Click through org access page', async ({ page }) => { await page.goto('/orgs/maze-war') - // page is there, we see AL but not FDR + const table = page.locator('role=table') + + // page is there, we see user 1 but not 2 await page.click('role=link[name*="Access & IAM"]') await expectVisible(page, ['role=heading[name*="Access & IAM"]']) - await expectRowVisible(page, 'user-1', ['user-1', 'Hannah Arendt', 'admin']) + await expectRowVisible(table, { ID: 'user-1', Name: 'Hannah Arendt', Role: 'admin' }) await expectNotVisible(page, ['role=cell[name="user-2"]']) - // Add FDR as collab + // Add user 2 as collab await page.click('role=button[name="Add user to organization"]') await expectVisible(page, ['role=heading[name*="Add user to organization"]']) @@ -32,10 +34,10 @@ test('Click through org access page', async ({ page }) => { await page.click('role=option[name="Collaborator"]') await page.click('role=button[name="Add user"]') - // FDR shows up in the table - await expectRowVisible(page, 'user-2', ['user-2', 'Hans Jonas', 'collaborator']) + // User 2 shows up in the table + await expectRowVisible(table, { ID: 'user-2', Name: 'Hans Jonas', Role: 'collaborator' }) - // now change FDR's role from collab to viewer + // now change user 2's role from collab to viewer await page .locator('role=row', { hasText: 'user-2' }) .locator('role=button[name="Row actions"]') @@ -49,9 +51,9 @@ test('Click through org access page', async ({ page }) => { await page.click('role=option[name="Viewer"]') await page.click('role=button[name="Update role"]') - await expectRowVisible(page, 'user-2', ['user-2', 'Hans Jonas', 'viewer']) + await expectRowVisible(table, { ID: 'user-2', Role: 'viewer' }) - // now delete FDR + // now delete user 2 await page .locator('role=row', { hasText: 'user-2' }) .locator('role=button[name="Row actions"]') diff --git a/app/pages/__tests__/project-access.e2e.ts b/app/pages/__tests__/project-access.e2e.ts index 8cc5f3695..177760d28 100644 --- a/app/pages/__tests__/project-access.e2e.ts +++ b/app/pages/__tests__/project-access.e2e.ts @@ -4,14 +4,15 @@ import { expectNotVisible, expectRowVisible, expectVisible } from 'app/util/e2e' test('Click through project access page', async ({ page }) => { await page.goto('/orgs/maze-war/projects/mock-project') - - // page is there, we see AL but not FDR await page.click('role=link[name*="Access & IAM"]') + + // page is there, we see user 1 but not 2 await expectVisible(page, ['role=heading[name*="Access & IAM"]']) - await expectRowVisible(page, 'user-1', ['user-1', 'Hannah Arendt', 'admin']) + const table = page.locator('table') + await expectRowVisible(table, { ID: 'user-1', Name: 'Hannah Arendt', Role: 'admin' }) await expectNotVisible(page, ['role=cell[name="user-2"]']) - // Add FDR as collab + // Add user 2 as collab await page.click('role=button[name="Add user to project"]') await expectVisible(page, ['role=heading[name*="Add user to project"]']) @@ -32,10 +33,10 @@ test('Click through project access page', async ({ page }) => { await page.click('role=option[name="Collaborator"]') await page.click('role=button[name="Add user"]') - // FDR shows up in the table - await expectRowVisible(page, 'user-2', ['user-2', 'Hans Jonas', 'collaborator']) + // User 2 shows up in the table + await expectRowVisible(table, { ID: 'user-2', Name: 'Hans Jonas', Role: 'collaborator' }) - // now change FDR's role from collab to viewer + // now change user 2 role from collab to viewer await page .locator('role=row', { hasText: 'user-2' }) .locator('role=button[name="Row actions"]') @@ -49,9 +50,9 @@ test('Click through project access page', async ({ page }) => { await page.click('role=option[name="Viewer"]') await page.click('role=button[name="Update role"]') - await expectRowVisible(page, 'user-2', ['user-2', 'Hans Jonas', 'viewer']) + await expectRowVisible(table, { ID: 'user-2', Role: 'viewer' }) - // now delete FDR + // now delete user 2 await page .locator('role=row', { hasText: 'user-2' }) .locator('role=button[name="Row actions"]') diff --git a/app/pages/__tests__/ssh-keys.e2e.ts b/app/pages/__tests__/ssh-keys.e2e.ts index 45240c237..ebccd913c 100644 --- a/app/pages/__tests__/ssh-keys.e2e.ts +++ b/app/pages/__tests__/ssh-keys.e2e.ts @@ -18,7 +18,8 @@ test('SSH keys', async ({ page }) => { // it's there in the table await expectNotVisible(page, ['text="No SSH keys"']) - await expectRowVisible(page, 'my-key', ['my-key', 'definitely a key']) + const table = page.locator('role=table') + await expectRowVisible(table, { Name: 'my-key', Description: 'definitely a key' }) // now delete it await page.click('role=button[name="Row actions"]') diff --git a/app/pages/project/instances/instance/tabs/NetworkingTab.tsx b/app/pages/project/instances/instance/tabs/NetworkingTab.tsx index 4f17d6bed..dfd7b2be2 100644 --- a/app/pages/project/instances/instance/tabs/NetworkingTab.tsx +++ b/app/pages/project/instances/instance/tabs/NetworkingTab.tsx @@ -39,6 +39,13 @@ const SubnetNameFromId = ({ value }: { value: string }) => ( ) +function ExternalIpsFromInstanceName({ value: primary }: { value: boolean }) { + const instanceParams = useParams('orgName', 'projectName', 'instanceName') + const { data } = useApiQuery('instanceExternalIpList', instanceParams) + const ips = data?.items.map((eip) => eip.ip).join(', ') + return {primary ? ips : <>—} +} + export function NetworkingTab() { const instanceParams = useParams('orgName', 'projectName', 'instanceName') const queryClient = useApiQueryClient() @@ -119,6 +126,12 @@ export function NetworkingTab() { {/* TODO: mark v4 or v6 explicitly? */} + void) { +export async function forEach(loc: Locator, fn: (loc0: Locator, i: number) => void) { const count = await loc.count() for (let i = 0; i < count; i++) { - await fn(loc.nth(i)) + await fn(loc.nth(i), i) } } +export async function map( + loc: Locator, + fn: (loc0: Locator, i: number) => Promise +): Promise { + const result: T[] = [] + await forEach(loc, async (loc0, i) => { + result.push(await fn(loc0, i)) + }) + return result +} + export async function expectVisible(page: Page, selectors: string[]) { for (const selector of selectors) { await expect(page.locator(selector)).toBeVisible() @@ -21,26 +32,36 @@ export async function expectNotVisible(page: Page, selectors: string[]) { } /** - * Assert about the values of a row, identified by `rowSelectorText`. It doesn't - * need to be the entire row; the test will pass as long as the identified row - * exists and the first N cells match the N values in `cellTexts`. Pass `''` for - * a checkbox cell. - * - * @param rowSelectorText Text that should uniquely identify the row, like an ID - * @param cellTexts Text to match in each cell of that row + * Assert that a row matching `expectedRow` is present in `table`. The match + * uses `objectContaining`, so `expectedRow` does not need to contain every + * cell. Works by converting `table` to a list of objects where the keys are + * header cell text and the values are row cell text. */ export async function expectRowVisible( - page: Page, - rowSelectorText: string, - cellTexts: Array + table: Locator, + expectedRow: Record ) { - const row = page.locator(`tr:has-text("${rowSelectorText}")`) - await expect(row).toBeVisible() - for (let i = 0; i < cellTexts.length; i++) { - const text = cellTexts[i] - if (text === null) { - continue - } - await expect(row.locator(`role=cell >> nth=${i}`)).toHaveText(text) - } + // wait for header and rows to avoid flake town + const headerLoc = table.locator('thead >> role=cell') + await headerLoc.locator('nth=0').waitFor() // nth=0 bc error if there's more than 1 + + const rowLoc = table.locator('tbody >> role=row') + await rowLoc.locator('nth=0').waitFor() + + const headerKeys = await map( + table.locator('thead >> role=cell'), + async (cell) => await cell.textContent() + ) + + const rows = await map(table.locator('tbody >> role=row'), async (row) => { + const rowPairs = await map(row.locator('role=cell'), async (cell, i) => [ + headerKeys[i], + // accessible name would be better but it's not in yet + // https://github.com/microsoft/playwright/issues/13517 + await cell.textContent(), + ]) + return Object.fromEntries(rowPairs.filter(([k]) => k && k.length > 0)) + }) + + await expect(rows).toEqual(expect.arrayContaining([expect.objectContaining(expectedRow)])) } diff --git a/libs/api-mocks/msw/handlers.ts b/libs/api-mocks/msw/handlers.ts index 753d22314..8af9054d2 100644 --- a/libs/api-mocks/msw/handlers.ts +++ b/libs/api-mocks/msw/handlers.ts @@ -462,6 +462,22 @@ export const handlers = [ } ), + rest.get | GetErr>( + '/api/organizations/:orgName/projects/:projectName/instances/:instanceName/external-ips', + (req, res) => { + const [, err] = lookupInstance(req.params) + if (err) return res(err) + // TODO: proper mock table + const items = [ + { + ip: '123.4.56.7', + kind: 'ephemeral', + } as const, + ] + return res(json({ items })) + } + ), + rest.get | GetErr>( '/api/organizations/:orgName/projects/:projectName/instances/:instanceName/network-interfaces', (req, res) => { diff --git a/libs/api/__generated__/Api.ts b/libs/api/__generated__/Api.ts index 6f53e2170..9282f929a 100644 --- a/libs/api/__generated__/Api.ts +++ b/libs/api/__generated__/Api.ts @@ -180,6 +180,20 @@ export type ExternalIp = { */ export type ExternalIpCreate = { poolName?: Name | null; type: 'ephemeral' } +/** + * A single page of results + */ +export type ExternalIpResultsPage = { + /** + * list of items on this page of results + */ + items: ExternalIp[] + /** + * token used to fetch the next page of results (if any) + */ + nextPage?: string | null +} + /** * The name and type information for a field of a timeseries schema. */ @@ -3704,7 +3718,7 @@ export class Api extends HttpClient { { instanceName, orgName, projectName }: InstanceExternalIpListParams, params: RequestParams = {} ) => - this.request({ + this.request({ path: `/organizations/${orgName}/projects/${projectName}/instances/${instanceName}/external-ips`, method: 'GET', ...params, diff --git a/libs/api/__generated__/OMICRON_VERSION b/libs/api/__generated__/OMICRON_VERSION index a7f71408e..e43aa56fe 100644 --- a/libs/api/__generated__/OMICRON_VERSION +++ b/libs/api/__generated__/OMICRON_VERSION @@ -1,2 +1,2 @@ # generated file. do not update manually. see docs/update-pinned-api.md -5e835f19680655a89ff1dca2a6e18f1f269ac021 +6d8d6a4580db44a885f7a213aa39ada76d8af2d6