From ae0514e67ff902b6c79460c2817846fdee7f5788 Mon Sep 17 00:00:00 2001 From: Justin Bennett Date: Tue, 16 Aug 2022 11:17:32 -0400 Subject: [PATCH 01/12] WIP: Add data independent tests --- app/msw-mock-api.ts | 9 +++ app/pages/__tests__/orgs.e2e.ts | 33 ++++++----- app/test/helpers.ts | 70 ++++++++++++++++++++++++ libs/api-mocks/msw/db.ts | 12 ++-- package.json | 1 + patches/storage-as-an-object+1.0.1.patch | 32 +++++++++++ yarn.lock | 5 ++ 7 files changed, 143 insertions(+), 19 deletions(-) create mode 100644 app/test/helpers.ts create mode 100644 patches/storage-as-an-object+1.0.1.patch diff --git a/app/msw-mock-api.ts b/app/msw-mock-api.ts index 1d23f93cee..140c3e86fd 100644 --- a/app/msw-mock-api.ts +++ b/app/msw-mock-api.ts @@ -1,3 +1,5 @@ +import { resetDb } from '@oxide/api-mocks' + function getChaos() { const chaos = parseFloat(process.env.CHAOS || '') return Number.isNaN(chaos) ? null : chaos @@ -36,6 +38,13 @@ export async function startMockAPI() { const { handlers } = await import('@oxide/api-mocks') const { setupWorker, rest, compose } = await import('msw') + const params = window.location.search.slice(1).split('&') + if (params.includes('clear')) { + console.log('resetting db...') + resetDb() + window.location.search = params.filter((p) => p !== 'clear').join('&') + } + // defined in here because it depends on the dynamic import const chaosInterceptor = rest.all('/api/*', (_req, res, ctx) => { if (shouldFail(chaos)) { diff --git a/app/pages/__tests__/orgs.e2e.ts b/app/pages/__tests__/orgs.e2e.ts index 287b518647..69f11f231f 100644 --- a/app/pages/__tests__/orgs.e2e.ts +++ b/app/pages/__tests__/orgs.e2e.ts @@ -1,17 +1,19 @@ import { test } from '@playwright/test' +import { createOrganization } from 'app/test/helpers' import { expectVisible } from 'app/util/e2e' -test('Orgs list and detail click work', async ({ page }) => { +test('Root to orgs redirect', async ({ page }) => { await page.goto('/') - await expectVisible(page, [ - // note substring matcher bc headers have icons that mess with accessible name - // TODO: maybe that's bad and we should fix it in the code - 'role=heading[name*="Organizations"]', - 'role=cell[name="maze-war"]', - ]) + await page.waitForURL('/orgs') + await expectVisible(page, ['role=heading[name="Organizations"]']) +}) - // create org form +test('Orgs list and detail click work', async ({ page }) => { + await page.goto('/orgs') + await expectVisible(page, ['role=heading[name="Organizations"]']) + + // verify create org form await page.click('role=link[name="New Organization"]') await expectVisible(page, [ 'role=heading[name*="Create organization"]', @@ -21,14 +23,15 @@ test('Orgs list and detail click work', async ({ page }) => { ]) await page.goBack() + await createOrganization(page, { + name: 'org-create-test', + description: 'used to test org creation', + }) + // org page (redirects to /org/org-name/projects) - await page.click('role=link[name="maze-war"]') + await page.click('role=link[name="org-create-test"]') await expectVisible(page, [ - 'role=heading[name*="Projects"]', - 'role=cell[name="mock-project"]', + 'role=heading[name="Projects"]', + 'role=heading[name="No projects"]', ]) - - // new project button works - await page.click('role=link[name="New Project"]') - await expectVisible(page, ['role=heading[name*="Create project"]']) }) diff --git a/app/test/helpers.ts b/app/test/helpers.ts new file mode 100644 index 0000000000..8148230082 --- /dev/null +++ b/app/test/helpers.ts @@ -0,0 +1,70 @@ +import type { Page, Response } from '@playwright/test' + +import type { OrganizationCreate, ProjectCreate, ProjectCreateParams } from '@oxide/api' + +type Ok = [T, null] +type Err = [null, E] +type Result = Ok | Err +type ReturnFn = () => Promise + +// imitate Rust's Result constructors +const Ok = (o: T): Ok => [o, null] +const Err = (err: E): Err => [null, err] + +interface ReqError { + code: number + msg: string +} + +const goto = async (page: Page, url: string): Promise> => { + const currentUrl = page.url() + const response = await page.goto(url) + if (!response) throw new Error(`No response recieved for request to ${url}`) + const status = response.status() + if (status < 200 || status > 299) { + return Err({ + msg: `Loading ${url} failed with a status code of ${status}`, + code: status, + }) + } + return Ok(() => page.goto(currentUrl)) +} + +// --- Organizations --------- + +export async function createOrganization(page: Page, body: OrganizationCreate) { + const [back, err] = await goto(page, '/orgs/new') + if (err) { + throw new Error(err.msg) + } + await page.fill('role=textbox[name="Name"]', body.name) + await page.fill('role=textbox[name="Description"]', body.description) + await page.click('role=button[name="Create organization"]') + await page.waitForNavigation() + await back() +} + +// --- Projects -------------- + +export async function createProject( + page: Page, + params: ProjectCreateParams, + body: ProjectCreate +) { + const [back, err] = await goto(page, `/orgs/${params.orgName}/projects/new`) + // If there's a 404, the org likely doesn't exist. Try to create it before erroring. + if (err && err.code === 404) { + await createOrganization(page, { name: params.orgName, description: '' }) + const [, err] = await goto(page, `/orgs/${params.orgName}/projects/new`) + if (err) { + throw new Error( + `Failed to create project, couldn't navigate to new project view:\n ${err.msg}` + ) + } + } else if (err) { + throw new Error(err.msg) + } + await page.fill('role=textbox[name="Name"]', body.name) + await page.fill('role=textbox[name="Description"]', body.description) + await back!() +} diff --git a/libs/api-mocks/msw/db.ts b/libs/api-mocks/msw/db.ts index 49786b8d5d..9aae67999e 100644 --- a/libs/api-mocks/msw/db.ts +++ b/libs/api-mocks/msw/db.ts @@ -1,4 +1,6 @@ import { response } from 'msw' +// @ts-ignore +import createStore from 'storage-as-an-object' import type { Merge } from 'type-fest' import * as mock from '@oxide/api-mocks' @@ -166,10 +168,12 @@ const initDb = { vpcSubnets: [mock.vpcSubnet], } -const clone = (o: unknown) => JSON.parse(JSON.stringify(o)) +export const db: typeof initDb = createStore('msw-db', { + initialValues: structuredClone(initDb), + store: window.localStorage, +}) export function resetDb() { - db = clone(initDb) + // @ts-ignore + db.clear() } - -export let db: typeof initDb = clone(initDb) diff --git a/package.json b/package.json index c4462a099e..85d4de2e2e 100644 --- a/package.json +++ b/package.json @@ -126,6 +126,7 @@ "postcss-pseudo-classes": "^0.2.1", "prettier": "2.7.0", "require-from-string": "^2.0.2", + "storage-as-an-object": "^1.0.1", "style-dictionary": "^3.1.1", "tailwindcss": "^3.1.6", "token-transformer": "^0.0.18", diff --git a/patches/storage-as-an-object+1.0.1.patch b/patches/storage-as-an-object+1.0.1.patch new file mode 100644 index 0000000000..b854766ed6 --- /dev/null +++ b/patches/storage-as-an-object+1.0.1.patch @@ -0,0 +1,32 @@ +diff --git a/node_modules/storage-as-an-object/storageobject.js b/node_modules/storage-as-an-object/storageobject.js +index bdc7b3c..d080c00 100644 +--- a/node_modules/storage-as-an-object/storageobject.js ++++ b/node_modules/storage-as-an-object/storageobject.js +@@ -22,7 +22,7 @@ const defaults = { + function StorageObject(store_key, options={}) { + + let cache = {}; +- const opt = Object.assign({}, options); ++ const opt = Object.assign(defaults, options); + let timer = null + + const proxify = (obj) => { +@@ -82,8 +82,7 @@ function StorageObject(store_key, options={}) { + } + + const clear = () => { +- for (var key in cache) delete cache[key]; +- Object.assign(cache, opt.initialValues); ++ cache = structuredClone(opt.initialValues) + proxify(cache); + write(); + } +@@ -94,7 +93,7 @@ function StorageObject(store_key, options={}) { + try { + Object.assign(cache, JSON.parse(opt.store[store_key])); + } catch { +- Object.assign(cache, opt.initialValues); ++ Object.assign(cache, structuredClone(opt.initialValues)); + } + cache = proxify(cache); + write(); diff --git a/yarn.lock b/yarn.lock index 633e9b2e24..a8ffb65229 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14110,6 +14110,11 @@ statuses@^2.0.0: resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== +storage-as-an-object@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/storage-as-an-object/-/storage-as-an-object-1.0.1.tgz#aaa68bef61cfed30a92423e15b4e840279a6bc03" + integrity sha512-K19025EcIlolmiLCnGLUiMbWwW8+tbaEhu4lL4FsRvwWADM6WNFGIhbK1pSMdExPWV53rvVKOEVfoHQ/Ui3fPw== + store2@^2.12.0: version "2.12.0" resolved "https://registry.npmjs.org/store2/-/store2-2.12.0.tgz" From 9305e5973ec721c88080c7e76cd279f5cc3f6ffc Mon Sep 17 00:00:00 2001 From: Justin Bennett Date: Tue, 16 Aug 2022 15:50:22 -0400 Subject: [PATCH 02/12] Cleanup helpers --- app/pages/__tests__/orgs.e2e.ts | 4 ++-- app/test/helpers.ts | 25 ++++++++++++++----------- package.json | 2 +- yarn.lock | 18 +++++++++--------- 4 files changed, 26 insertions(+), 23 deletions(-) diff --git a/app/pages/__tests__/orgs.e2e.ts b/app/pages/__tests__/orgs.e2e.ts index 69f11f231f..6aa0961b43 100644 --- a/app/pages/__tests__/orgs.e2e.ts +++ b/app/pages/__tests__/orgs.e2e.ts @@ -1,6 +1,6 @@ import { test } from '@playwright/test' -import { createOrganization } from 'app/test/helpers' +import { createOrg } from 'app/test/helpers' import { expectVisible } from 'app/util/e2e' test('Root to orgs redirect', async ({ page }) => { @@ -23,7 +23,7 @@ test('Orgs list and detail click work', async ({ page }) => { ]) await page.goBack() - await createOrganization(page, { + await createOrg(page, { name: 'org-create-test', description: 'used to test org creation', }) diff --git a/app/test/helpers.ts b/app/test/helpers.ts index 8148230082..c9ab81b7a2 100644 --- a/app/test/helpers.ts +++ b/app/test/helpers.ts @@ -30,9 +30,18 @@ const goto = async (page: Page, url: string): Promise return Ok(() => page.goto(currentUrl)) } +export async function checkIfExists(page: Page, path: string) { + const res = await page.request.get(path) + const status = res.status() + if (status >= 500) { + throw new Error(`${path} failed to load with a ${status} response code`) + } + return status >= 200 && status < 300 +} + // --- Organizations --------- -export async function createOrganization(page: Page, body: OrganizationCreate) { +export async function createOrg(page: Page, body: OrganizationCreate) { const [back, err] = await goto(page, '/orgs/new') if (err) { throw new Error(err.msg) @@ -51,17 +60,11 @@ export async function createProject( params: ProjectCreateParams, body: ProjectCreate ) { + if (!checkIfExists(page, `/orgs/${params.orgName}`)) { + await createOrg(page, { name: params.orgName, description: '' }) + } const [back, err] = await goto(page, `/orgs/${params.orgName}/projects/new`) - // If there's a 404, the org likely doesn't exist. Try to create it before erroring. - if (err && err.code === 404) { - await createOrganization(page, { name: params.orgName, description: '' }) - const [, err] = await goto(page, `/orgs/${params.orgName}/projects/new`) - if (err) { - throw new Error( - `Failed to create project, couldn't navigate to new project view:\n ${err.msg}` - ) - } - } else if (err) { + if (err) { throw new Error(err.msg) } await page.fill('role=textbox[name="Name"]', body.name) diff --git a/package.json b/package.json index 85d4de2e2e..882fa89010 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "@figma-export/cli": "^4.3.0", "@figma-export/output-components-as-svgr": "^4.2.0", "@figma-export/transform-svg-with-svgo": "^4.3.0", - "@playwright/test": "^1.23.4", + "@playwright/test": "^1.25.0", "@storybook/addon-docs": "^6.5.9", "@storybook/addon-essentials": "^6.5.9", "@storybook/addon-links": "^6.5.9", diff --git a/yarn.lock b/yarn.lock index a8ffb65229..d932fddcf3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2606,13 +2606,13 @@ resolved "https://registry.yarnpkg.com/@open-draft/until/-/until-1.0.3.tgz#db9cc719191a62e7d9200f6e7bab21c5b848adca" integrity sha512-Aq58f5HiWdyDlFffbbSjAlv596h/cOnt2DO1w3DOC7OJ5EHs0hd/nycJfiu9RJbT6Yk6F1knnRRXNSpxoIVZ9Q== -"@playwright/test@^1.23.4": - version "1.23.4" - resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.23.4.tgz#d742ed34fc3eb09e29c2e652c2a646ba1e3c6b08" - integrity sha512-iIsoMJDS/lyuhw82FtcV/B3PXikgVD3hNe5hyvOpRM0uRr1OIpN3LgPeRbBjhzBWmyf6RgRg5fqK5sVcpA03yA== +"@playwright/test@^1.25.0": + version "1.25.0" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.25.0.tgz#e0de134651e78e45e986c5f16578188dd5937331" + integrity sha512-j4EZhTTQI3dBeWblE21EV//swwmBtOpIrLdOIJIRv4uqsLdHgBg1z+JtTg+AeC5o2bAXIE26kDNW5A0TimG8Bg== dependencies: "@types/node" "*" - playwright-core "1.23.4" + playwright-core "1.25.0" "@pmmmwh/react-refresh-webpack-plugin@^0.5.3": version "0.5.7" @@ -12256,10 +12256,10 @@ pkg-dir@^5.0.0: dependencies: find-up "^5.0.0" -playwright-core@1.23.4: - version "1.23.4" - resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.23.4.tgz#e8a45e549faf6bfad24a0e9998e451979514d41e" - integrity sha512-h5V2yw7d8xIwotjyNrkLF13nV9RiiZLHdXeHo+nVJIYGVlZ8U2qV0pMxNJKNTvfQVT0N8/A4CW6/4EW2cOcTiA== +playwright-core@1.25.0: + version "1.25.0" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.25.0.tgz#54dc867c6c2cc5e4233905e249206a02914d14f1" + integrity sha512-kZ3Jwaf3wlu0GgU0nB8UMQ+mXFTqBIFz9h1svTlNduNKjnbPXFxw7mJanLVjqxHJRn62uBfmgBj93YHidk2N5Q== plop@^2.7.6: version "2.7.6" From 919b57bd68209b3f3b38d349c7c6747581927a65 Mon Sep 17 00:00:00 2001 From: Justin Bennett Date: Tue, 16 Aug 2022 16:13:26 -0400 Subject: [PATCH 03/12] Add removal handlers --- app/test/helpers.ts | 43 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/app/test/helpers.ts b/app/test/helpers.ts index c9ab81b7a2..3efd7da497 100644 --- a/app/test/helpers.ts +++ b/app/test/helpers.ts @@ -1,6 +1,12 @@ import type { Page, Response } from '@playwright/test' -import type { OrganizationCreate, ProjectCreate, ProjectCreateParams } from '@oxide/api' +import type { + OrganizationCreate, + OrganizationDeleteParams, + ProjectCreate, + ProjectCreateParams, + ProjectDeleteParams, +} from '@oxide/api' type Ok = [T, null] type Err = [null, E] @@ -41,6 +47,10 @@ export async function checkIfExists(page: Page, path: string) { // --- Organizations --------- +/** + * Creates an Organization and returns to the page it was called from. Returns + * a cleanup function that can be used to cleanup the org later. + */ export async function createOrg(page: Page, body: OrganizationCreate) { const [back, err] = await goto(page, '/orgs/new') if (err) { @@ -51,17 +61,33 @@ export async function createOrg(page: Page, body: OrganizationCreate) { await page.click('role=button[name="Create organization"]') await page.waitForNavigation() await back() + return () => deleteOrg(page, { orgName: body.name }) +} + +export async function deleteOrg(page: Page, params: OrganizationDeleteParams) { + const res = await page.request.delete(`/orgs/${params.orgName}`) + const status = res.status() + if (status >= 300) { + throw new Error(`Failed to delete org ${params.orgName} with response code ${status}`) + } } // --- Projects -------------- +/* + * Creates a project (and its parent org if it doesn't already exist) then returns + * to the page it was called from. Returns a function that will delete the project + * (and org if one was created). + */ export async function createProject( page: Page, params: ProjectCreateParams, body: ProjectCreate ) { + let orgCreated = false if (!checkIfExists(page, `/orgs/${params.orgName}`)) { await createOrg(page, { name: params.orgName, description: '' }) + orgCreated = true } const [back, err] = await goto(page, `/orgs/${params.orgName}/projects/new`) if (err) { @@ -70,4 +96,19 @@ export async function createProject( await page.fill('role=textbox[name="Name"]', body.name) await page.fill('role=textbox[name="Description"]', body.description) await back!() + + return async () => { + await deleteProject(page, { ...params, projectName: body.name }) + if (orgCreated) { + await deleteOrg(page, params) + } + } +} + +export async function deleteProject(page: Page, params: ProjectDeleteParams) { + const res = await page.request.delete(`/orgs/${params.orgName}`) + const status = res.status() + if (status >= 300) { + throw new Error(`Failed to delete org ${params.orgName} with response code ${status}`) + } } From 9f06a79e24241b967903ec7224da82202e123f1e Mon Sep 17 00:00:00 2001 From: Justin Bennett Date: Wed, 17 Aug 2022 13:46:58 -0400 Subject: [PATCH 04/12] Make cleanup implicit --- app/test/helpers.ts | 23 ++++++++++++++++------- app/test/setup.ts | 4 +++- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/app/test/helpers.ts b/app/test/helpers.ts index 3efd7da497..124a23541e 100644 --- a/app/test/helpers.ts +++ b/app/test/helpers.ts @@ -22,6 +22,17 @@ interface ReqError { msg: string } +const cleanupTasks: Array<() => Promise> = [] + +/** + * Cleans any resources that might've been left around after a test + */ +export const cleanup = async () => { + for (const task of cleanupTasks) { + await task() + } +} + const goto = async (page: Page, url: string): Promise> => { const currentUrl = page.url() const response = await page.goto(url) @@ -48,8 +59,7 @@ export async function checkIfExists(page: Page, path: string) { // --- Organizations --------- /** - * Creates an Organization and returns to the page it was called from. Returns - * a cleanup function that can be used to cleanup the org later. + * Creates an Organization and returns to the page it was called from. */ export async function createOrg(page: Page, body: OrganizationCreate) { const [back, err] = await goto(page, '/orgs/new') @@ -61,7 +71,7 @@ export async function createOrg(page: Page, body: OrganizationCreate) { await page.click('role=button[name="Create organization"]') await page.waitForNavigation() await back() - return () => deleteOrg(page, { orgName: body.name }) + cleanupTasks.push(() => deleteOrg(page, { orgName: body.name })) } export async function deleteOrg(page: Page, params: OrganizationDeleteParams) { @@ -76,8 +86,7 @@ export async function deleteOrg(page: Page, params: OrganizationDeleteParams) { /* * Creates a project (and its parent org if it doesn't already exist) then returns - * to the page it was called from. Returns a function that will delete the project - * (and org if one was created). + * to the page it was called from. */ export async function createProject( page: Page, @@ -97,12 +106,12 @@ export async function createProject( await page.fill('role=textbox[name="Description"]', body.description) await back!() - return async () => { + cleanupTasks.push(async () => { await deleteProject(page, { ...params, projectName: body.name }) if (orgCreated) { await deleteOrg(page, params) } - } + }) } export async function deleteProject(page: Page, params: ProjectDeleteParams) { diff --git a/app/test/setup.ts b/app/test/setup.ts index 7af716a641..f42d315499 100644 --- a/app/test/setup.ts +++ b/app/test/setup.ts @@ -5,10 +5,12 @@ import 'whatwg-fetch' // fancy asserts import { resetDb } from '@oxide/api-mocks' +import { cleanup } from './helpers' import { server } from './server' beforeAll(() => server.listen()) -afterEach(() => { +afterEach(async () => { + await cleanup() resetDb() server.resetHandlers() }) From 80c51f77c2117f13aa3891225c286562550b7c75 Mon Sep 17 00:00:00 2001 From: Justin Bennett Date: Wed, 17 Aug 2022 16:04:19 -0400 Subject: [PATCH 05/12] Fork off simplified version of store proxy --- libs/api-mocks/msw/db.ts | 8 +- libs/api-mocks/msw/store.ts | 109 +++++++++++++++++++++++ package.json | 1 - patches/storage-as-an-object+1.0.1.patch | 32 ------- yarn.lock | 5 -- 5 files changed, 112 insertions(+), 43 deletions(-) create mode 100644 libs/api-mocks/msw/store.ts delete mode 100644 patches/storage-as-an-object+1.0.1.patch diff --git a/libs/api-mocks/msw/db.ts b/libs/api-mocks/msw/db.ts index 9aae67999e..ced6c76cf6 100644 --- a/libs/api-mocks/msw/db.ts +++ b/libs/api-mocks/msw/db.ts @@ -1,6 +1,5 @@ import { response } from 'msw' // @ts-ignore -import createStore from 'storage-as-an-object' import type { Merge } from 'type-fest' import * as mock from '@oxide/api-mocks' @@ -8,6 +7,7 @@ import type { ApiTypes as Api } from '@oxide/api' import { sessionMe } from '@oxide/api-mocks' import type { Json } from '../json-type' +import { createStore } from './store' import { json } from './util' const notFoundBody = { error_code: 'ObjectNotFound' } as const @@ -168,12 +168,10 @@ const initDb = { vpcSubnets: [mock.vpcSubnet], } -export const db: typeof initDb = createStore('msw-db', { - initialValues: structuredClone(initDb), - store: window.localStorage, +export const db = createStore('msw-db', { + initialValues: structuredClone(initDb) as typeof initDb, }) export function resetDb() { - // @ts-ignore db.clear() } diff --git a/libs/api-mocks/msw/store.ts b/libs/api-mocks/msw/store.ts new file mode 100644 index 0000000000..8bd282da88 --- /dev/null +++ b/libs/api-mocks/msw/store.ts @@ -0,0 +1,109 @@ +/** + * This is a fork of https://github.com/ropg/storage-as-an-object + * storage-as-an-object is MIT license which can be found here: + * https://github.com/ropg/storage-as-an-object/blob/7a12a1dd3bcc1b87a77e8202fae98ae25323b968/LICENSE + */ + +interface StoreOptions> { + initialValues: T + store?: Storage + debounceTime?: number +} + +/** + * createStore Creates an object that's persisted to a storage target like + * localStorage or sessionStorage + * + * @param {string} key - The name of the string in the store + * @param {StoreOptions} [options] - Optional configuration + * @returns {Object} - The object that your code interfaces with + */ +export function createStore>( + key: string, + options: StoreOptions +) { + const debounceTime = options.debounceTime ?? 100 + let cache = {} as T + let timer: NodeJS.Timeout | null = null + const store = options.store ?? window.localStorage + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const proxify = (obj: any) => { + const validTypes = ['string', 'number', 'object', 'undefined', 'boolean'] + if (!validTypes.includes(typeof obj)) { + throw new Error('StorageObject does not store this variable type') + } + if (typeof obj === 'object' && obj !== null) { + if (obj instanceof Date) { + return { __DATE: obj.toJSON() } + } + for (const key in obj) { + obj[key] = proxify(obj[key]) + } + return new Proxy(obj, { + get(target, name) { + const val = target[name] + if (val && typeof val === 'object' && val.__DATE) { + return new Date(val.__DATE) + } + return val + }, + set(target, name, value) { + target[name] = proxify(value) + debounceWrite() + return true + }, + deleteProperty(target, name) { + delete target[name] + debounceWrite() + return true + }, + }) + } + return obj + } + + const write = () => { + if (timer) { + clearTimeout(timer) + timer = null + } + store[key] = JSON.stringify(cache) + } + + const debounceWrite = () => { + if (debounceTime === 0) { + write() + return + } + if (timer) { + clearTimeout(timer) + } + timer = setTimeout(write, debounceTime) + } + + const clear = () => { + cache = structuredClone(options.initialValues) + proxify(cache) + write() + } + Object.defineProperty(cache, 'clear', { value: clear }) + + const unload = () => { + if (timer) write() + } + + try { + Object.assign(cache, JSON.parse(store[key])) + } catch { + Object.assign(cache, structuredClone(options.initialValues)) + } + cache = proxify(cache) + write() + + if (debounceTime > 0) { + window.addEventListener('unload', unload) + } + + return cache as T & { clear: () => void } +} diff --git a/package.json b/package.json index 882fa89010..9c9948ab36 100644 --- a/package.json +++ b/package.json @@ -126,7 +126,6 @@ "postcss-pseudo-classes": "^0.2.1", "prettier": "2.7.0", "require-from-string": "^2.0.2", - "storage-as-an-object": "^1.0.1", "style-dictionary": "^3.1.1", "tailwindcss": "^3.1.6", "token-transformer": "^0.0.18", diff --git a/patches/storage-as-an-object+1.0.1.patch b/patches/storage-as-an-object+1.0.1.patch deleted file mode 100644 index b854766ed6..0000000000 --- a/patches/storage-as-an-object+1.0.1.patch +++ /dev/null @@ -1,32 +0,0 @@ -diff --git a/node_modules/storage-as-an-object/storageobject.js b/node_modules/storage-as-an-object/storageobject.js -index bdc7b3c..d080c00 100644 ---- a/node_modules/storage-as-an-object/storageobject.js -+++ b/node_modules/storage-as-an-object/storageobject.js -@@ -22,7 +22,7 @@ const defaults = { - function StorageObject(store_key, options={}) { - - let cache = {}; -- const opt = Object.assign({}, options); -+ const opt = Object.assign(defaults, options); - let timer = null - - const proxify = (obj) => { -@@ -82,8 +82,7 @@ function StorageObject(store_key, options={}) { - } - - const clear = () => { -- for (var key in cache) delete cache[key]; -- Object.assign(cache, opt.initialValues); -+ cache = structuredClone(opt.initialValues) - proxify(cache); - write(); - } -@@ -94,7 +93,7 @@ function StorageObject(store_key, options={}) { - try { - Object.assign(cache, JSON.parse(opt.store[store_key])); - } catch { -- Object.assign(cache, opt.initialValues); -+ Object.assign(cache, structuredClone(opt.initialValues)); - } - cache = proxify(cache); - write(); diff --git a/yarn.lock b/yarn.lock index d932fddcf3..ddc7da012c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14110,11 +14110,6 @@ statuses@^2.0.0: resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== -storage-as-an-object@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/storage-as-an-object/-/storage-as-an-object-1.0.1.tgz#aaa68bef61cfed30a92423e15b4e840279a6bc03" - integrity sha512-K19025EcIlolmiLCnGLUiMbWwW8+tbaEhu4lL4FsRvwWADM6WNFGIhbK1pSMdExPWV53rvVKOEVfoHQ/Ui3fPw== - store2@^2.12.0: version "2.12.0" resolved "https://registry.npmjs.org/store2/-/store2-2.12.0.tgz" From 834ac422775008e63464872a50d75e1f57bfa250 Mon Sep 17 00:00:00 2001 From: Justin Bennett Date: Wed, 17 Aug 2022 16:11:43 -0400 Subject: [PATCH 06/12] Remove stray ts-ignore --- libs/api-mocks/msw/db.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/libs/api-mocks/msw/db.ts b/libs/api-mocks/msw/db.ts index ced6c76cf6..b0f4105a52 100644 --- a/libs/api-mocks/msw/db.ts +++ b/libs/api-mocks/msw/db.ts @@ -1,5 +1,4 @@ import { response } from 'msw' -// @ts-ignore import type { Merge } from 'type-fest' import * as mock from '@oxide/api-mocks' From 12b2c74ef7377e59b59ff8dbe6d79631ba3caf67 Mon Sep 17 00:00:00 2001 From: Justin Bennett Date: Wed, 17 Aug 2022 16:58:09 -0400 Subject: [PATCH 07/12] Fix tests --- libs/api-mocks/msw/db.ts | 17 ++++++++++++----- libs/api-mocks/msw/store.ts | 5 +++-- libs/api-mocks/msw/util.ts | 5 +++++ libs/api/__tests__/safety.spec.ts | 2 +- 4 files changed, 21 insertions(+), 8 deletions(-) diff --git a/libs/api-mocks/msw/db.ts b/libs/api-mocks/msw/db.ts index b0f4105a52..4aa0f084dd 100644 --- a/libs/api-mocks/msw/db.ts +++ b/libs/api-mocks/msw/db.ts @@ -7,7 +7,7 @@ import { sessionMe } from '@oxide/api-mocks' import type { Json } from '../json-type' import { createStore } from './store' -import { json } from './util' +import { clone, json } from './util' const notFoundBody = { error_code: 'ObjectNotFound' } as const export type NotFound = typeof notFoundBody @@ -167,10 +167,17 @@ const initDb = { vpcSubnets: [mock.vpcSubnet], } -export const db = createStore('msw-db', { - initialValues: structuredClone(initDb) as typeof initDb, -}) +export let db: typeof initDb & { clear?: () => void } = + typeof window !== 'undefined' + ? createStore('msw-db', { + initialValues: clone(initDb), + }) + : initDb export function resetDb() { - db.clear() + if (db.clear) { + db.clear() + } else { + db = clone(db) + } } diff --git a/libs/api-mocks/msw/store.ts b/libs/api-mocks/msw/store.ts index 8bd282da88..5b9d9a9410 100644 --- a/libs/api-mocks/msw/store.ts +++ b/libs/api-mocks/msw/store.ts @@ -3,6 +3,7 @@ * storage-as-an-object is MIT license which can be found here: * https://github.com/ropg/storage-as-an-object/blob/7a12a1dd3bcc1b87a77e8202fae98ae25323b968/LICENSE */ +import { clone } from './util' interface StoreOptions> { initialValues: T @@ -83,7 +84,7 @@ export function createStore>( } const clear = () => { - cache = structuredClone(options.initialValues) + cache = clone(options.initialValues) proxify(cache) write() } @@ -96,7 +97,7 @@ export function createStore>( try { Object.assign(cache, JSON.parse(store[key])) } catch { - Object.assign(cache, structuredClone(options.initialValues)) + Object.assign(cache, clone(options.initialValues)) } cache = proxify(cache) write() diff --git a/libs/api-mocks/msw/util.ts b/libs/api-mocks/msw/util.ts index ddbf792612..fddbfb01a1 100644 --- a/libs/api-mocks/msw/util.ts +++ b/libs/api-mocks/msw/util.ts @@ -55,3 +55,8 @@ export const paginated = ( // testing pagination export const repeat = (obj: T, n: number): T[] => new Array(n).fill(0).map((_, i) => ({ ...obj, id: obj.id + i, name: obj.name + i })) + +export const clone = (obj: T): T => + typeof structuredClone !== 'undefined' + ? structuredClone(obj) + : JSON.parse(JSON.stringify(obj)) diff --git a/libs/api/__tests__/safety.spec.ts b/libs/api/__tests__/safety.spec.ts index 303ad9e2af..ee68190e97 100644 --- a/libs/api/__tests__/safety.spec.ts +++ b/libs/api/__tests__/safety.spec.ts @@ -27,7 +27,7 @@ const grepFiles = (s: string) => it('@oxide/api-mocks is only referenced in test files', () => { const files = grepFiles("from '@oxide/api-mocks'") for (const file of files) { - expect(file).toMatch(/__tests__\/|app\/test\/|\.spec\.|tsconfig|api-mocks/) + expect(file).toMatch(/__tests__\/|app\/test\/|\.spec\.|tsconfig|api-mocks|msw-mock-api/) } }) From f6b18825bc17f8767db80acd8f89cfdc47ab3fb4 Mon Sep 17 00:00:00 2001 From: Justin Bennett Date: Wed, 17 Aug 2022 17:51:59 -0400 Subject: [PATCH 08/12] Restore manual cleanup --- app/pages/__tests__/orgs.e2e.ts | 4 +++- app/test/helpers.ts | 17 +++-------------- app/test/setup.ts | 6 ++++-- 3 files changed, 10 insertions(+), 17 deletions(-) diff --git a/app/pages/__tests__/orgs.e2e.ts b/app/pages/__tests__/orgs.e2e.ts index 6aa0961b43..1942a6d287 100644 --- a/app/pages/__tests__/orgs.e2e.ts +++ b/app/pages/__tests__/orgs.e2e.ts @@ -23,7 +23,7 @@ test('Orgs list and detail click work', async ({ page }) => { ]) await page.goBack() - await createOrg(page, { + const removeOrg = await createOrg(page, { name: 'org-create-test', description: 'used to test org creation', }) @@ -34,4 +34,6 @@ test('Orgs list and detail click work', async ({ page }) => { 'role=heading[name="Projects"]', 'role=heading[name="No projects"]', ]) + + await removeOrg() }) diff --git a/app/test/helpers.ts b/app/test/helpers.ts index 124a23541e..4b313a4270 100644 --- a/app/test/helpers.ts +++ b/app/test/helpers.ts @@ -22,17 +22,6 @@ interface ReqError { msg: string } -const cleanupTasks: Array<() => Promise> = [] - -/** - * Cleans any resources that might've been left around after a test - */ -export const cleanup = async () => { - for (const task of cleanupTasks) { - await task() - } -} - const goto = async (page: Page, url: string): Promise> => { const currentUrl = page.url() const response = await page.goto(url) @@ -71,7 +60,7 @@ export async function createOrg(page: Page, body: OrganizationCreate) { await page.click('role=button[name="Create organization"]') await page.waitForNavigation() await back() - cleanupTasks.push(() => deleteOrg(page, { orgName: body.name })) + return () => deleteOrg(page, { orgName: body.name }) } export async function deleteOrg(page: Page, params: OrganizationDeleteParams) { @@ -106,12 +95,12 @@ export async function createProject( await page.fill('role=textbox[name="Description"]', body.description) await back!() - cleanupTasks.push(async () => { + return async () => { await deleteProject(page, { ...params, projectName: body.name }) if (orgCreated) { await deleteOrg(page, params) } - }) + } } export async function deleteProject(page: Page, params: ProjectDeleteParams) { diff --git a/app/test/setup.ts b/app/test/setup.ts index f42d315499..1b0c2030fa 100644 --- a/app/test/setup.ts +++ b/app/test/setup.ts @@ -1,3 +1,7 @@ +/** + * This file is ran by vitest before any tests are ran. Configuration + * in this file does _not_ impact end-to-end tests. + */ // our node fetch polyfill of choice import '@testing-library/jest-dom' import 'whatwg-fetch' @@ -5,12 +9,10 @@ import 'whatwg-fetch' // fancy asserts import { resetDb } from '@oxide/api-mocks' -import { cleanup } from './helpers' import { server } from './server' beforeAll(() => server.listen()) afterEach(async () => { - await cleanup() resetDb() server.resetHandlers() }) From 619bf6e69941c4312ac4f92fe6a2eb3479ae02a2 Mon Sep 17 00:00:00 2001 From: Justin Bennett Date: Wed, 17 Aug 2022 18:39:07 -0400 Subject: [PATCH 09/12] Flesh out deletes --- app/pages/__tests__/orgs.e2e.ts | 2 +- app/test/helpers.ts | 34 +++++++++++++++++++++++++-------- libs/api-mocks/msw/handlers.ts | 14 +++++++------- 3 files changed, 34 insertions(+), 16 deletions(-) diff --git a/app/pages/__tests__/orgs.e2e.ts b/app/pages/__tests__/orgs.e2e.ts index 1942a6d287..e4b53cfff8 100644 --- a/app/pages/__tests__/orgs.e2e.ts +++ b/app/pages/__tests__/orgs.e2e.ts @@ -9,7 +9,7 @@ test('Root to orgs redirect', async ({ page }) => { await expectVisible(page, ['role=heading[name="Organizations"]']) }) -test('Orgs list and detail click work', async ({ page }) => { +test('Create org and navigate to project', async ({ page }) => { await page.goto('/orgs') await expectVisible(page, ['role=heading[name="Organizations"]']) diff --git a/app/test/helpers.ts b/app/test/helpers.ts index 4b313a4270..991a90da55 100644 --- a/app/test/helpers.ts +++ b/app/test/helpers.ts @@ -8,6 +8,8 @@ import type { ProjectDeleteParams, } from '@oxide/api' +import { expectNotVisible } from 'app/util/e2e' + type Ok = [T, null] type Err = [null, E] type Result = Ok | Err @@ -64,11 +66,19 @@ export async function createOrg(page: Page, body: OrganizationCreate) { } export async function deleteOrg(page: Page, params: OrganizationDeleteParams) { - const res = await page.request.delete(`/orgs/${params.orgName}`) - const status = res.status() - if (status >= 300) { - throw new Error(`Failed to delete org ${params.orgName} with response code ${status}`) + const [back, err] = await goto(page, '/orgs') + if (err) { + throw new Error(err.msg) } + + await page + .locator('role=row', { hasText: params.orgName }) + .locator('role=button[name="Row actions"]') + .click() + await page.click('role=menuitem[name="Delete"]') + await expectNotVisible(page, [`role=cell[name="${params.orgName}"]`]) + + await back() } // --- Projects -------------- @@ -104,9 +114,17 @@ export async function createProject( } export async function deleteProject(page: Page, params: ProjectDeleteParams) { - const res = await page.request.delete(`/orgs/${params.orgName}`) - const status = res.status() - if (status >= 300) { - throw new Error(`Failed to delete org ${params.orgName} with response code ${status}`) + const [back, err] = await goto(page, `/orgs/${params.orgName}/projects`) + if (err) { + throw new Error(err.msg) } + + await page + .locator('role=row', { hasText: params.projectName }) + .locator('role=button[name="Row actions"]') + .click() + await page.click('role=menuitem[name="Delete"]') + await expectNotVisible(page, [`role=cell[name="${params.projectName}"]`]) + + await back() } diff --git a/libs/api-mocks/msw/handlers.ts b/libs/api-mocks/msw/handlers.ts index f52ca0b509..467930addf 100644 --- a/libs/api-mocks/msw/handlers.ts +++ b/libs/api-mocks/msw/handlers.ts @@ -184,6 +184,13 @@ export const handlers = [ } ), + rest.delete('/api/organizations/:orgName', (req, res, ctx) => { + const [org, err] = lookupOrg(req.params) + if (err) return res(err) + db.orgs = db.orgs.filter((o) => o.id !== org.id) + return res(ctx.status(204)) + }), + rest.get | GetErr>( '/api/organizations/:orgName/policy', (req, res) => { @@ -221,13 +228,6 @@ export const handlers = [ return res(json(req.body)) }), - rest.delete('/api/organizations/:orgName', (req, res, ctx) => { - const [org, err] = lookupOrg(req.params) - if (err) return res(err) - db.orgs = db.orgs.filter((o) => o.id !== org.id) - return res(ctx.status(204)) - }), - rest.get | GetErr>( '/api/organizations/:orgName/projects', (req, res) => { From f9eb04a16a5bab948d63ea8509e3ac013b4c3ed8 Mon Sep 17 00:00:00 2001 From: Justin Bennett Date: Wed, 17 Aug 2022 19:21:32 -0400 Subject: [PATCH 10/12] Revert handler changes --- libs/api-mocks/msw/handlers.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/libs/api-mocks/msw/handlers.ts b/libs/api-mocks/msw/handlers.ts index 0f4438166c..9d97ad7438 100644 --- a/libs/api-mocks/msw/handlers.ts +++ b/libs/api-mocks/msw/handlers.ts @@ -232,6 +232,13 @@ export const handlers = [ return res(json(req.body)) }), + rest.delete('/api/organizations/:orgName', (req, res, ctx) => { + const [org, err] = lookupOrg(req.params) + if (err) return res(err) + db.orgs = db.orgs.filter((o) => o.id !== org.id) + return res(ctx.status(204)) + }), + rest.get | GetErr>( '/api/organizations/:orgName/projects', (req, res) => { From 0329fcbf8ded102abbe910518bd19f9881a7b935 Mon Sep 17 00:00:00 2001 From: Justin Bennett Date: Wed, 17 Aug 2022 19:26:29 -0400 Subject: [PATCH 11/12] Make org tests work in parallel --- app/pages/__tests__/orgs.e2e.ts | 7 ++++--- app/test/helpers.ts | 21 +++++++++++++++++++-- libs/api-mocks/msw/db.ts | 15 ++++----------- 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/app/pages/__tests__/orgs.e2e.ts b/app/pages/__tests__/orgs.e2e.ts index e4b53cfff8..7623c12515 100644 --- a/app/pages/__tests__/orgs.e2e.ts +++ b/app/pages/__tests__/orgs.e2e.ts @@ -1,6 +1,6 @@ import { test } from '@playwright/test' -import { createOrg } from 'app/test/helpers' +import { createOrg, genName } from 'app/test/helpers' import { expectVisible } from 'app/util/e2e' test('Root to orgs redirect', async ({ page }) => { @@ -23,13 +23,14 @@ test('Create org and navigate to project', async ({ page }) => { ]) await page.goBack() + const orgName = genName('org-create-test') const removeOrg = await createOrg(page, { - name: 'org-create-test', + name: orgName, description: 'used to test org creation', }) // org page (redirects to /org/org-name/projects) - await page.click('role=link[name="org-create-test"]') + await page.click(`role=link[name="${orgName}"]`) await expectVisible(page, [ 'role=heading[name="Projects"]', 'role=heading[name="No projects"]', diff --git a/app/test/helpers.ts b/app/test/helpers.ts index 991a90da55..443460fe07 100644 --- a/app/test/helpers.ts +++ b/app/test/helpers.ts @@ -24,12 +24,27 @@ interface ReqError { msg: string } +// TODO: This is duplicated from `@oxide/api` and can't be pulled in due to +// inlined tests using `import.meta` being imported and causing a syntax error +// given that playwright converts all the tests to commonjs. +export const genName = (...parts: [string, ...string[]]) => { + const numParts = parts.length + const partLength = Math.floor(63 / numParts) - Math.ceil(6 / numParts) - 1 + return ( + parts + .map((part) => part.substring(0, partLength)) + .join('-') + // generate random hex string of 6 characters + .concat(`-${Math.random().toString(16).substring(2, 8)}`) + ) +} + const goto = async (page: Page, url: string): Promise> => { const currentUrl = page.url() const response = await page.goto(url) if (!response) throw new Error(`No response recieved for request to ${url}`) const status = response.status() - if (status < 200 || status > 299) { + if (status > 399) { return Err({ msg: `Loading ${url} failed with a status code of ${status}`, code: status, @@ -62,7 +77,9 @@ export async function createOrg(page: Page, body: OrganizationCreate) { await page.click('role=button[name="Create organization"]') await page.waitForNavigation() await back() - return () => deleteOrg(page, { orgName: body.name }) + return () => { + return deleteOrg(page, { orgName: body.name }) + } } export async function deleteOrg(page: Page, params: OrganizationDeleteParams) { diff --git a/libs/api-mocks/msw/db.ts b/libs/api-mocks/msw/db.ts index 4aa0f084dd..2b4e5cdbc7 100644 --- a/libs/api-mocks/msw/db.ts +++ b/libs/api-mocks/msw/db.ts @@ -167,17 +167,10 @@ const initDb = { vpcSubnets: [mock.vpcSubnet], } -export let db: typeof initDb & { clear?: () => void } = - typeof window !== 'undefined' - ? createStore('msw-db', { - initialValues: clone(initDb), - }) - : initDb +export const db = createStore('msw-db', { + initialValues: clone(initDb), +}) export function resetDb() { - if (db.clear) { - db.clear() - } else { - db = clone(db) - } + db.clear() } From c9d9806eb1a3c4332179f9660e0815697a265c03 Mon Sep 17 00:00:00 2001 From: Justin Bennett Date: Thu, 18 Aug 2022 13:50:59 -0400 Subject: [PATCH 12/12] Cleanup helpers --- app/test/helpers.ts | 47 +++++++++------------------------------------ 1 file changed, 9 insertions(+), 38 deletions(-) diff --git a/app/test/helpers.ts b/app/test/helpers.ts index 443460fe07..6ccfac0059 100644 --- a/app/test/helpers.ts +++ b/app/test/helpers.ts @@ -1,4 +1,4 @@ -import type { Page, Response } from '@playwright/test' +import type { Page } from '@playwright/test' import type { OrganizationCreate, @@ -10,20 +10,6 @@ import type { import { expectNotVisible } from 'app/util/e2e' -type Ok = [T, null] -type Err = [null, E] -type Result = Ok | Err -type ReturnFn = () => Promise - -// imitate Rust's Result constructors -const Ok = (o: T): Ok => [o, null] -const Err = (err: E): Err => [null, err] - -interface ReqError { - code: number - msg: string -} - // TODO: This is duplicated from `@oxide/api` and can't be pulled in due to // inlined tests using `import.meta` being imported and causing a syntax error // given that playwright converts all the tests to commonjs. @@ -39,18 +25,15 @@ export const genName = (...parts: [string, ...string[]]) => { ) } -const goto = async (page: Page, url: string): Promise> => { +const goto = async (page: Page, url: string) => { const currentUrl = page.url() const response = await page.goto(url) if (!response) throw new Error(`No response recieved for request to ${url}`) const status = response.status() if (status > 399) { - return Err({ - msg: `Loading ${url} failed with a status code of ${status}`, - code: status, - }) + throw new Error(`Loading ${url} failed with a status code of ${status}`) } - return Ok(() => page.goto(currentUrl)) + return () => page.goto(currentUrl) } export async function checkIfExists(page: Page, path: string) { @@ -68,10 +51,7 @@ export async function checkIfExists(page: Page, path: string) { * Creates an Organization and returns to the page it was called from. */ export async function createOrg(page: Page, body: OrganizationCreate) { - const [back, err] = await goto(page, '/orgs/new') - if (err) { - throw new Error(err.msg) - } + const back = await goto(page, '/orgs/new') await page.fill('role=textbox[name="Name"]', body.name) await page.fill('role=textbox[name="Description"]', body.description) await page.click('role=button[name="Create organization"]') @@ -83,10 +63,7 @@ export async function createOrg(page: Page, body: OrganizationCreate) { } export async function deleteOrg(page: Page, params: OrganizationDeleteParams) { - const [back, err] = await goto(page, '/orgs') - if (err) { - throw new Error(err.msg) - } + const back = await goto(page, '/orgs') await page .locator('role=row', { hasText: params.orgName }) @@ -114,13 +91,10 @@ export async function createProject( await createOrg(page, { name: params.orgName, description: '' }) orgCreated = true } - const [back, err] = await goto(page, `/orgs/${params.orgName}/projects/new`) - if (err) { - throw new Error(err.msg) - } + const back = await goto(page, `/orgs/${params.orgName}/projects/new`) await page.fill('role=textbox[name="Name"]', body.name) await page.fill('role=textbox[name="Description"]', body.description) - await back!() + await back() return async () => { await deleteProject(page, { ...params, projectName: body.name }) @@ -131,10 +105,7 @@ export async function createProject( } export async function deleteProject(page: Page, params: ProjectDeleteParams) { - const [back, err] = await goto(page, `/orgs/${params.orgName}/projects`) - if (err) { - throw new Error(err.msg) - } + const back = await goto(page, `/orgs/${params.orgName}/projects`) await page .locator('role=row', { hasText: params.projectName })