From fc5d4194f4383cb1637049bb6e4648a18eb9cdda Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Tue, 10 Oct 2023 12:28:22 +0100 Subject: [PATCH 01/11] Make imports more consistent --- app/components/form/fields/index.ts | 10 ---------- app/components/form/index.ts | 2 ++ app/forms/idp/create.tsx | 9 +++++++-- app/forms/idp/shared.tsx | 3 +-- app/forms/image-upload.tsx | 2 +- 5 files changed, 11 insertions(+), 15 deletions(-) delete mode 100644 app/components/form/fields/index.ts diff --git a/app/components/form/fields/index.ts b/app/components/form/fields/index.ts deleted file mode 100644 index 3550d6b550..0000000000 --- a/app/components/form/fields/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright Oxide Computer Company - */ - -export * from './DateTimeRangePicker' -export * from './FileField' diff --git a/app/components/form/index.ts b/app/components/form/index.ts index 1d90ac3954..0fa6f0b108 100644 --- a/app/components/form/index.ts +++ b/app/components/form/index.ts @@ -22,3 +22,5 @@ export * from './fields/NetworkInterfaceField' export * from './fields/RadioField' export * from './fields/SubnetListbox' export * from './fields/TextField' +export * from './fields/TlsCertsField' +export * from './fields/FileField' diff --git a/app/forms/idp/create.tsx b/app/forms/idp/create.tsx index fec6d6b3ff..8fb0f7f964 100644 --- a/app/forms/idp/create.tsx +++ b/app/forms/idp/create.tsx @@ -9,8 +9,13 @@ import { useNavigate } from 'react-router-dom' import { useApiMutation, useApiQueryClient } from '@oxide/api' -import { DescriptionField, NameField, SideModalForm, TextField } from 'app/components/form' -import { FileField } from 'app/components/form/fields' +import { + DescriptionField, + FileField, + NameField, + SideModalForm, + TextField, +} from 'app/components/form' import { useForm, useSiloSelector, useToast } from 'app/hooks' import { readBlobAsBase64 } from 'app/util/file' import { pb } from 'app/util/path-builder' diff --git a/app/forms/idp/shared.tsx b/app/forms/idp/shared.tsx index ad1e3ea247..4e1744112c 100644 --- a/app/forms/idp/shared.tsx +++ b/app/forms/idp/shared.tsx @@ -11,8 +11,7 @@ import type { Merge } from 'type-fest' import type { IdpMetadataSource, SamlIdentityProviderCreate } from '@oxide/api' import { Radio, RadioGroup } from '@oxide/ui' -import { TextField } from 'app/components/form' -import { FileField } from 'app/components/form/fields' +import { FileField, TextField } from 'app/components/form' export type IdpCreateFormValues = { type: 'saml' } & Merge< SamlIdentityProviderCreate, diff --git a/app/forms/image-upload.tsx b/app/forms/image-upload.tsx index 6abd51d75d..950cc662ce 100644 --- a/app/forms/image-upload.tsx +++ b/app/forms/image-upload.tsx @@ -27,12 +27,12 @@ import { GiB, KiB, invariant } from '@oxide/util' import { DescriptionField, + FileField, NameField, RadioField, SideModalForm, TextField, } from 'app/components/form' -import { FileField } from 'app/components/form/fields' import { useForm, useProjectSelector } from 'app/hooks' import { readBlobAsBase64 } from 'app/util/file' import { pb } from 'app/util/path-builder' From f9fe2f4a0d086fb3a73d63e1b6ecb53fa2c5270c Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Tue, 10 Oct 2023 12:35:30 +0100 Subject: [PATCH 02/11] Rough up adding TLS certs to silo create form --- app/components/form/fields/TlsCertsField.tsx | 137 +++++++++++++++++++ app/forms/silo-create.tsx | 8 +- app/util/file.ts | 17 +++ 3 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 app/components/form/fields/TlsCertsField.tsx diff --git a/app/components/form/fields/TlsCertsField.tsx b/app/components/form/fields/TlsCertsField.tsx new file mode 100644 index 0000000000..d7d85306d8 --- /dev/null +++ b/app/components/form/fields/TlsCertsField.tsx @@ -0,0 +1,137 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { useState } from 'react' +import type { Control } from 'react-hook-form' +import { useController } from 'react-hook-form' + +import { Button, Error16Icon, FieldLabel, MiniTable, Modal } from '@oxide/ui' + +import { DescriptionField, FileField, NameField } from 'app/components/form' +import type { SiloCreateInput } from 'app/forms/silo-create' +import { useForm } from 'app/hooks' +import { readBlobAsText } from 'app/util/file' + +export function TlsCertsField({ control }: { control: Control }) { + const [showAddCert, setShowAddCert] = useState(false) + + const { + field: { value: items, onChange }, + } = useController({ control, name: 'tlsCertificates' }) + + return ( + <> +
+ + TLS Certificates + + {!!items.length && ( + + + Name + {/* For remove button */} + + + + {items.map((item, index) => ( + + {item.name} + + + + + ))} + + + )} + + +
+ + {showAddCert && ( + setShowAddCert(false)} + onSubmit={async (values) => { + const keypair = { + cert: await readBlobAsText(values.cert), + key: await readBlobAsText(values.key), + } + onChange([...items, { ...values, ...keypair }]) + setShowAddCert(false) + }} + /> + )} + + ) +} + +export type TlsCertificate = Omit< + SiloCreateInput['tlsCertificates'][number], + 'key' | 'cert' +> & { + key: File + cert: File +} + +const defaultValues: Partial = { + description: '', + name: '', + service: 'external_api', +} + +const AddCertModal = ({ + onDismiss, + onSubmit, +}: { + onDismiss: () => void + onSubmit: (values: TlsCertificate) => void +}) => { + const { control, handleSubmit } = useForm({ defaultValues }) + + return ( + + +
{ + e.stopPropagation() + handleSubmit(onSubmit)(e) + }} + > + + + + + + +
+
+ +
+ ) +} diff --git a/app/forms/silo-create.tsx b/app/forms/silo-create.tsx index 98f8cd72cd..9fbf65afaf 100644 --- a/app/forms/silo-create.tsx +++ b/app/forms/silo-create.tsx @@ -9,6 +9,7 @@ import { useNavigate } from 'react-router-dom' import type { SiloCreate } from '@oxide/api' import { useApiMutation, useApiQueryClient } from '@oxide/api' +import { FormDivider } from '@oxide/ui' import { CheckboxField, @@ -17,16 +18,17 @@ import { RadioField, SideModalForm, TextField, + TlsCertsField, } from 'app/components/form' import { useForm, useToast } from 'app/hooks' import { pb } from 'app/util/path-builder' -type FormValues = Omit & { +export type SiloCreateInput = Omit & { siloAdminGetsFleetAdmin: boolean siloViewerGetsFleetViewer: boolean } -const defaultValues: FormValues = { +const defaultValues: SiloCreateInput = { name: '', description: '', discoverable: true, @@ -117,6 +119,8 @@ export function CreateSiloSideModalForm() { Grant fleet viewer role to silo viewers + + ) } diff --git a/app/util/file.ts b/app/util/file.ts index 3e9ae26668..d249d2c2ac 100644 --- a/app/util/file.ts +++ b/app/util/file.ts @@ -22,3 +22,20 @@ export async function readBlobAsBase64(blob: Blob): Promise { fileReader.readAsDataURL(blob) }) } + +/** async wrapper for reading a file as text */ +export async function readBlobAsText(blob: Blob): Promise { + return new Promise((resolve) => { + const fileReader = new FileReader() + + // split on comma and pop because data URL looks like + // 'data:[][;base64],' and we only want . + // e.target is never null and result is always a string + fileReader.onload = function (e) { + const base64Chunk = (e.target!.result as string).split(',').pop()! + resolve(base64Chunk) + } + + fileReader.readAsText(blob) + }) +} From 1153dc61991b4dd99b33fe92f3e2e9f66a0b900e Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Tue, 10 Oct 2023 14:45:19 +0100 Subject: [PATCH 03/11] Add test for adding a silo cert --- app/components/form/fields/TlsCertsField.tsx | 2 +- app/test/e2e/silos.e2e.ts | 61 +++++++++++++++++++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/app/components/form/fields/TlsCertsField.tsx b/app/components/form/fields/TlsCertsField.tsx index d7d85306d8..b7a1d464e7 100644 --- a/app/components/form/fields/TlsCertsField.tsx +++ b/app/components/form/fields/TlsCertsField.tsx @@ -104,7 +104,7 @@ const AddCertModal = ({ const { control, handleSubmit } = useForm({ defaultValues }) return ( - +
{ await page.goto('/system/silos') @@ -31,6 +46,50 @@ test('Silos page', async ({ page }) => { await page.click('role=radio[name="Local only"]') await page.fill('role=textbox[name="Admin group name"]', 'admins') await page.click('role=checkbox[name="Grant fleet admin role to silo admins"]') + + // Add a TLS cert + await page.click('role=button[name="Add TLS certificate"]') + + const certRequired = 'role=dialog[name="Add TLS certificate"] >> text="Cert is required"' + const keyRequired = 'role=dialog[name="Add TLS certificate"] >> text="Key is required"' + await expectNotVisible(page, [certRequired, keyRequired]) + await page.click('role=button[name="Add Certificate"]') + // Check that the modal cannot be submitted without cert and + // key and that an error is displayed + await expectVisible(page, [certRequired, keyRequired]) + + await chooseFile('Cert', page) + await chooseFile('Key', page) + await page.fill( + 'role=dialog[name="Add TLS certificate"] >> role=textbox[name="Name"]', + 'test-cert' + ) + + await page.click('role=button[name="Add Certificate"]') + + // Check cert appears in the mini-table + await expectVisible(page, ['role=cell[name="test-cert"]']) + + await page.click('role=button[name="remove test-cert"]') + // Cert should not appear after it has been deleted + await expectNotVisible(page, ['role=cell[name="test-cert"]']) + + await page.click('role=button[name="Add TLS certificate"]') + + // Adding another after the first cert is deleted + await page.fill( + 'role=dialog[name="Add TLS certificate"] >> role=textbox[name="Name"]', + 'test-cert-2' + ) + await page.fill( + 'role=dialog[name="Add TLS certificate"] >> role=textbox[name="Description"]', + 'definitely a cert' + ) + await chooseFile('Cert', page) + await chooseFile('Key', page) + await page.click('role=button[name="Add Certificate"]') + await expectVisible(page, ['role=cell[name="test-cert-2"]']) + await page.click('role=button[name="Create silo"]') // it's there in the table From 053b95dccf870160d2fa28091fac9de5da710dda Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Thu, 12 Oct 2023 10:39:39 +0100 Subject: [PATCH 04/11] TSC onchange and remove unnecessary `readBlobAsText` --- app/components/form/fields/TlsCertsField.tsx | 10 +++++----- app/util/file.ts | 17 ----------------- 2 files changed, 5 insertions(+), 22 deletions(-) diff --git a/app/components/form/fields/TlsCertsField.tsx b/app/components/form/fields/TlsCertsField.tsx index b7a1d464e7..9dd0383820 100644 --- a/app/components/form/fields/TlsCertsField.tsx +++ b/app/components/form/fields/TlsCertsField.tsx @@ -14,7 +14,6 @@ import { Button, Error16Icon, FieldLabel, MiniTable, Modal } from '@oxide/ui' import { DescriptionField, FileField, NameField } from 'app/components/form' import type { SiloCreateInput } from 'app/forms/silo-create' import { useForm } from 'app/hooks' -import { readBlobAsText } from 'app/util/file' export function TlsCertsField({ control }: { control: Control }) { const [showAddCert, setShowAddCert] = useState(false) @@ -67,11 +66,12 @@ export function TlsCertsField({ control }: { control: Control } setShowAddCert(false)} onSubmit={async (values) => { - const keypair = { - cert: await readBlobAsText(values.cert), - key: await readBlobAsText(values.key), + const certCreate: (typeof items)[number] = { + ...values, + cert: await values.cert.text(), + key: await values.key.text(), } - onChange([...items, { ...values, ...keypair }]) + onChange([...items, certCreate]) setShowAddCert(false) }} /> diff --git a/app/util/file.ts b/app/util/file.ts index d249d2c2ac..3e9ae26668 100644 --- a/app/util/file.ts +++ b/app/util/file.ts @@ -22,20 +22,3 @@ export async function readBlobAsBase64(blob: Blob): Promise { fileReader.readAsDataURL(blob) }) } - -/** async wrapper for reading a file as text */ -export async function readBlobAsText(blob: Blob): Promise { - return new Promise((resolve) => { - const fileReader = new FileReader() - - // split on comma and pop because data URL looks like - // 'data:[][;base64],' and we only want . - // e.target is never null and result is always a string - fileReader.onload = function (e) { - const base64Chunk = (e.target!.result as string).split(',').pop()! - resolve(base64Chunk) - } - - fileReader.readAsText(blob) - }) -} From 42ab6e8c99add5245636e0a66543b11f895a568a Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Thu, 12 Oct 2023 11:12:32 +0100 Subject: [PATCH 05/11] Ensure certificate name is unique --- app/components/form/fields/TlsCertsField.tsx | 41 ++++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/app/components/form/fields/TlsCertsField.tsx b/app/components/form/fields/TlsCertsField.tsx index 9dd0383820..d4ba844d99 100644 --- a/app/components/form/fields/TlsCertsField.tsx +++ b/app/components/form/fields/TlsCertsField.tsx @@ -6,12 +6,18 @@ * Copyright Oxide Computer Company */ import { useState } from 'react' -import type { Control } from 'react-hook-form' +import type { Control, FieldPath, FieldValues } from 'react-hook-form' import { useController } from 'react-hook-form' import { Button, Error16Icon, FieldLabel, MiniTable, Modal } from '@oxide/ui' +import { capitalize } from '@oxide/util' -import { DescriptionField, FileField, NameField } from 'app/components/form' +import { + DescriptionField, + FileField, + TextField, + type TextFieldProps, +} from 'app/components/form' import type { SiloCreateInput } from 'app/forms/silo-create' import { useForm } from 'app/hooks' @@ -74,6 +80,7 @@ export function TlsCertsField({ control }: { control: Control } onChange([...items, certCreate]) setShowAddCert(false) }} + allNames={items.map((item) => item.name)} /> )} @@ -94,12 +101,40 @@ const defaultValues: Partial = { service: 'external_api', } +function UniqueNameField< + TFieldValues extends FieldValues, + TName extends FieldPath +>({ + required = true, + name, + label = capitalize(name), + allNames, + ...textFieldProps +}: Omit, 'validate'> & { + label?: string + allNames: string[] +}) { + return ( + + allNames.includes(name) ? 'Certificate with this name already exists' : true + } + required={required} + label={label} + name={name} + {...textFieldProps} + /> + ) +} + const AddCertModal = ({ onDismiss, onSubmit, + allNames, }: { onDismiss: () => void onSubmit: (values: TlsCertificate) => void + allNames: string[] }) => { const { control, handleSubmit } = useForm({ defaultValues }) @@ -114,7 +149,7 @@ const AddCertModal = ({ }} > - + Date: Thu, 12 Oct 2023 14:48:06 +0100 Subject: [PATCH 06/11] Don't validate file input on blur --- libs/ui/lib/file-input/FileInput.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/libs/ui/lib/file-input/FileInput.tsx b/libs/ui/lib/file-input/FileInput.tsx index de54768c9e..373d96896d 100644 --- a/libs/ui/lib/file-input/FileInput.tsx +++ b/libs/ui/lib/file-input/FileInput.tsx @@ -62,6 +62,10 @@ export const FileInput = forwardRef( onDragEnter={() => setDragOver(true)} onDragLeave={() => setDragOver(false)} onDrop={() => setDragOver(false)} + /* Validating this onBlur causes the required + error message to appear on click. Instead we can + validate on form submit by overriding onBlur */ + onBlur={() => {}} />
Date: Thu, 12 Oct 2023 16:38:13 -0500 Subject: [PATCH 07/11] don't need the onBlur noop if we're not validating on blur --- libs/ui/lib/file-input/FileInput.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/libs/ui/lib/file-input/FileInput.tsx b/libs/ui/lib/file-input/FileInput.tsx index 373d96896d..de54768c9e 100644 --- a/libs/ui/lib/file-input/FileInput.tsx +++ b/libs/ui/lib/file-input/FileInput.tsx @@ -62,10 +62,6 @@ export const FileInput = forwardRef( onDragEnter={() => setDragOver(true)} onDragLeave={() => setDragOver(false)} onDrop={() => setDragOver(false)} - /* Validating this onBlur causes the required - error message to appear on click. Instead we can - validate on form submit by overriding onBlur */ - onBlur={() => {}} />
Date: Thu, 12 Oct 2023 17:00:01 -0500 Subject: [PATCH 08/11] use TextField directly for unique name, make it required, delete unused file --- app/components/form/fields/TextField.tsx | 1 - app/components/form/fields/TlsCertsField.tsx | 51 ++++++-------------- app/util/validate.ts | 23 --------- 3 files changed, 16 insertions(+), 59 deletions(-) delete mode 100644 app/util/validate.ts diff --git a/app/components/form/fields/TextField.tsx b/app/components/form/fields/TextField.tsx index 54c4eba163..f82789ae69 100644 --- a/app/components/form/fields/TextField.tsx +++ b/app/components/form/fields/TextField.tsx @@ -53,7 +53,6 @@ export interface TextFieldProps< description?: string placeholder?: string units?: string - // TODO: think about this doozy of a type validate?: Validate, TFieldValues> control: Control } diff --git a/app/components/form/fields/TlsCertsField.tsx b/app/components/form/fields/TlsCertsField.tsx index d4ba844d99..2c47265a26 100644 --- a/app/components/form/fields/TlsCertsField.tsx +++ b/app/components/form/fields/TlsCertsField.tsx @@ -6,18 +6,12 @@ * Copyright Oxide Computer Company */ import { useState } from 'react' -import type { Control, FieldPath, FieldValues } from 'react-hook-form' +import type { Control } from 'react-hook-form' import { useController } from 'react-hook-form' import { Button, Error16Icon, FieldLabel, MiniTable, Modal } from '@oxide/ui' -import { capitalize } from '@oxide/util' -import { - DescriptionField, - FileField, - TextField, - type TextFieldProps, -} from 'app/components/form' +import { DescriptionField, FileField, TextField, validateName } from 'app/components/form' import type { SiloCreateInput } from 'app/forms/silo-create' import { useForm } from 'app/hooks' @@ -101,32 +95,6 @@ const defaultValues: Partial = { service: 'external_api', } -function UniqueNameField< - TFieldValues extends FieldValues, - TName extends FieldPath ->({ - required = true, - name, - label = capitalize(name), - allNames, - ...textFieldProps -}: Omit, 'validate'> & { - label?: string - allNames: string[] -}) { - return ( - - allNames.includes(name) ? 'Certificate with this name already exists' : true - } - required={required} - label={label} - name={name} - {...textFieldProps} - /> - ) -} - const AddCertModal = ({ onDismiss, onSubmit, @@ -149,7 +117,20 @@ const AddCertModal = ({ }} > - + { + if (allNames.includes(name)) { + return 'A certificate with this name already exists' + } + return validateName(name, 'Name', true) + }} + /> { - // if (!/^[a-z](|[a-zA-Z0-9-]*[a-zA-Z0-9])$/.test(value)) { - if (value.length === 0) { - return 'A name is required' - } else if (!/^[a-z]/.test(value)) { - return 'Must start with a lower-case letter' - } else if (!/[a-z0-9]$/.test(value)) { - return 'Must end with a letter or number' - } else if (!/^[a-z0-9-]+$/.test(value)) { - return 'Can only contain lower-case letters, numbers, and dashes' - } -} From b6ea919047afef7aaf711ecde9828b0c0d966552 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 13 Oct 2023 14:49:59 -0500 Subject: [PATCH 09/11] polish e2e tests: extract common chooseFile, wrap in test.step(), use getByRole where possible --- app/test/e2e/image-upload.e2e.ts | 27 +++----- app/test/e2e/silos.e2e.ts | 108 +++++++++++++++---------------- app/test/e2e/utils.ts | 14 ++++ package-lock.json | 46 ++++++------- package.json | 2 +- 5 files changed, 98 insertions(+), 99 deletions(-) diff --git a/app/test/e2e/image-upload.e2e.ts b/app/test/e2e/image-upload.e2e.ts index 704575d72b..fe7490919a 100644 --- a/app/test/e2e/image-upload.e2e.ts +++ b/app/test/e2e/image-upload.e2e.ts @@ -8,22 +8,13 @@ import type { Page } from '@playwright/test' import { expect, test } from '@playwright/test' -import { MiB } from '@oxide/util' - -import { expectNotVisible, expectRowVisible, expectVisible, sleep } from './utils' - -async function chooseFile(page: Page, size = 15 * MiB) { - const fileChooserPromise = page.waitForEvent('filechooser') - await page.getByText('Image file', { exact: true }).click() - const fileChooser = await fileChooserPromise - await fileChooser.setFiles({ - name: 'my-image.iso', - mimeType: 'application/octet-stream', - // fill with nonzero content, otherwise we'll skip the whole thing, which - // makes the test too fast for playwright to catch anything - buffer: Buffer.alloc(size, 'a'), - }) -} +import { + chooseFile, + expectNotVisible, + expectRowVisible, + expectVisible, + sleep, +} from './utils' // playwright isn't quick enough to catch each step going from ready to running // to complete in time, so we just assert that they all start out ready and end @@ -56,7 +47,7 @@ async function fillForm(page: Page, name: string) { await page.fill('role=textbox[name="Description"]', 'image description') await page.fill('role=textbox[name="OS"]', 'Ubuntu') await page.fill('role=textbox[name="Version"]', 'Dapper Drake') - await chooseFile(page) + await chooseFile(page, page.getByLabel('Image file')) } test.describe('Image upload', () => { @@ -117,7 +108,7 @@ test.describe('Image upload', () => { await expectNotVisible(page, [nameRequired]) // now set the file, clear it, and submit again - await chooseFile(page) + await chooseFile(page, page.getByLabel('Image file')) await expectNotVisible(page, [fileRequired]) await page.click('role=button[name="Clear file"]') diff --git a/app/test/e2e/silos.e2e.ts b/app/test/e2e/silos.e2e.ts index 3c8ce72e4f..9a205a2fcd 100644 --- a/app/test/e2e/silos.e2e.ts +++ b/app/test/e2e/silos.e2e.ts @@ -5,26 +5,13 @@ * * Copyright Oxide Computer Company */ -import { type Page, expect, test } from '@playwright/test' +import { expect, test } from '@playwright/test' import { MiB } from '@oxide/util' -import { expectNotVisible, expectRowVisible, expectVisible } from './utils' - -async function chooseFile(fieldName: string, page: Page) { - const fileChooserPromise = page.waitForEvent('filechooser') - await page.getByLabel(fieldName, { exact: true }).click() - const fileChooser = await fileChooserPromise - await fileChooser.setFiles({ - name: 'my-image.iso', - mimeType: 'application/octet-stream', - // fill with nonzero content, otherwise we'll skip the whole thing, which - // makes the test too fast for playwright to catch anything - buffer: Buffer.alloc(0.1 * MiB, 'a'), - }) -} +import { chooseFile, expectNotVisible, expectRowVisible, expectVisible } from './utils' -test('Silos page', async ({ page }) => { +test('Create silo', async ({ page }) => { await page.goto('/system/silos') await expectVisible(page, ['role=heading[name*="Silos"]']) @@ -48,47 +35,54 @@ test('Silos page', async ({ page }) => { await page.click('role=checkbox[name="Grant fleet admin role to silo admins"]') // Add a TLS cert - await page.click('role=button[name="Add TLS certificate"]') - - const certRequired = 'role=dialog[name="Add TLS certificate"] >> text="Cert is required"' - const keyRequired = 'role=dialog[name="Add TLS certificate"] >> text="Key is required"' - await expectNotVisible(page, [certRequired, keyRequired]) - await page.click('role=button[name="Add Certificate"]') - // Check that the modal cannot be submitted without cert and - // key and that an error is displayed - await expectVisible(page, [certRequired, keyRequired]) - - await chooseFile('Cert', page) - await chooseFile('Key', page) - await page.fill( - 'role=dialog[name="Add TLS certificate"] >> role=textbox[name="Name"]', - 'test-cert' - ) - - await page.click('role=button[name="Add Certificate"]') - - // Check cert appears in the mini-table - await expectVisible(page, ['role=cell[name="test-cert"]']) - - await page.click('role=button[name="remove test-cert"]') - // Cert should not appear after it has been deleted - await expectNotVisible(page, ['role=cell[name="test-cert"]']) - - await page.click('role=button[name="Add TLS certificate"]') - - // Adding another after the first cert is deleted - await page.fill( - 'role=dialog[name="Add TLS certificate"] >> role=textbox[name="Name"]', - 'test-cert-2' - ) - await page.fill( - 'role=dialog[name="Add TLS certificate"] >> role=textbox[name="Description"]', - 'definitely a cert' - ) - await chooseFile('Cert', page) - await chooseFile('Key', page) - await page.click('role=button[name="Add Certificate"]') - await expectVisible(page, ['role=cell[name="test-cert-2"]']) + await test.step('Add TLS cert', async () => { + const openCertModalButton = page.getByRole('button', { name: 'Add TLS certificate' }) + await openCertModalButton.click() + + const certDialog = page.getByRole('dialog', { name: 'Add TLS certificate' }) + + const certRequired = certDialog.getByText('Cert is required') + const keyRequired = certDialog.getByText('Key is required') + const nameRequired = certDialog.getByText('Name is required') + await expectNotVisible(page, [certRequired, keyRequired, nameRequired]) + + const certSubmit = page.getByRole('button', { name: 'Add Certificate' }) + await certSubmit.click() + + // Validation error for missing name + key and cert files + await expectVisible(page, [certRequired, keyRequired, nameRequired]) + + await chooseFile(page, page.getByLabel('Cert', { exact: true }), 0.1 * MiB) + await chooseFile(page, page.getByLabel('Key'), 0.1 * MiB) + const certName = certDialog.getByRole('textbox', { name: 'Name' }) + await certName.fill('test-cert') + + await certSubmit.click() + + // Check cert appears in the mini-table + const certCell = page.getByRole('cell', { name: 'test-cert', exact: true }) + await expect(certCell).toBeVisible() + + // check unique name validation + await openCertModalButton.click() + await certName.fill('test-cert') + await certSubmit.click() + await expect( + certDialog.getByText('A certificate with this name already exists') + ).toBeVisible() + + // Change the name so it's unique + await certName.fill('test-cert-2') + await chooseFile(page, page.getByLabel('Cert', { exact: true }), 0.1 * MiB) + await chooseFile(page, page.getByLabel('Key'), 0.1 * MiB) + await certSubmit.click() + await expect(page.getByRole('cell', { name: 'test-cert-2', exact: true })).toBeVisible() + + // now delete the first + await page.getByRole('button', { name: 'remove test-cert', exact: true }).click() + // Cert should not appear after it has been deleted + await expect(certCell).toBeHidden() + }) await page.click('role=button[name="Create silo"]') diff --git a/app/test/e2e/utils.ts b/app/test/e2e/utils.ts index 0554391f3b..697457130d 100644 --- a/app/test/e2e/utils.ts +++ b/app/test/e2e/utils.ts @@ -9,6 +9,7 @@ import type { Browser, Locator, Page } from '@playwright/test' import { expect } from '@playwright/test' import { MSW_USER_COOKIE } from '@oxide/api-mocks' +import { MiB } from '@oxide/util' export * from '@playwright/test' @@ -148,3 +149,16 @@ export async function expectObscured(locator: Locator) { } export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +export async function chooseFile(page: Page, inputLocator: Locator, size = 15 * MiB) { + const fileChooserPromise = page.waitForEvent('filechooser') + await inputLocator.click() + const fileChooser = await fileChooserPromise + await fileChooser.setFiles({ + name: 'my-image.iso', + mimeType: 'application/octet-stream', + // fill with nonzero content, otherwise we'll skip the whole thing, which + // makes the test too fast for playwright to catch anything + buffer: Buffer.alloc(size, 'a'), + }) +} diff --git a/package-lock.json b/package-lock.json index 05a9aeaedd..2b8ea84a93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,7 +55,7 @@ "devDependencies": { "@ladle/react": "^3.2.1", "@mswjs/http-middleware": "^0.8.0", - "@playwright/test": "^1.38.1", + "@playwright/test": "^1.39.0", "@testing-library/dom": "^9.3.1", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^14.0.0", @@ -2039,12 +2039,12 @@ } }, "node_modules/@playwright/test": { - "version": "1.38.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.38.1.tgz", - "integrity": "sha512-NqRp8XMwj3AK+zKLbZShl0r/9wKgzqI/527bkptKXomtuo+dOjU9NdMASQ8DNC9z9zLOMbG53T4eihYr3XR+BQ==", + "version": "1.39.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.39.0.tgz", + "integrity": "sha512-3u1iFqgzl7zr004bGPYiN/5EZpRUSFddQBra8Rqll5N0/vfpqlP9I9EXqAoGacuAbX6c9Ulg/Cjqglp5VkK6UQ==", "dev": true, "dependencies": { - "playwright": "1.38.1" + "playwright": "1.39.0" }, "bin": { "playwright": "cli.js" @@ -15796,12 +15796,12 @@ } }, "node_modules/playwright": { - "version": "1.38.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.38.1.tgz", - "integrity": "sha512-oRMSJmZrOu1FP5iu3UrCx8JEFRIMxLDM0c/3o4bpzU5Tz97BypefWf7TuTNPWeCe279TPal5RtPPZ+9lW/Qkow==", + "version": "1.39.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.39.0.tgz", + "integrity": "sha512-naE5QT11uC/Oiq0BwZ50gDmy8c8WLPRTEWuSSFVG2egBka/1qMoSqYQcROMT9zLwJ86oPofcTH2jBY/5wWOgIw==", "dev": true, "dependencies": { - "playwright-core": "1.38.1" + "playwright-core": "1.39.0" }, "bin": { "playwright": "cli.js" @@ -15814,9 +15814,9 @@ } }, "node_modules/playwright-core": { - "version": "1.38.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.38.1.tgz", - "integrity": "sha512-tQqNFUKa3OfMf4b2jQ7aGLB8o9bS3bOY0yMEtldtC2+spf8QXG9zvXLTXUeRsoNuxEYMgLYR+NXfAa1rjKRcrg==", + "version": "1.39.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.39.0.tgz", + "integrity": "sha512-+k4pdZgs1qiM+OUkSjx96YiKsXsmb59evFoqv8SKO067qBA+Z2s/dCzJij/ZhdQcs2zlTAgRKfeiiLm8PQ2qvw==", "dev": true, "bin": { "playwright-core": "cli.js" @@ -21291,12 +21291,12 @@ } }, "@playwright/test": { - "version": "1.38.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.38.1.tgz", - "integrity": "sha512-NqRp8XMwj3AK+zKLbZShl0r/9wKgzqI/527bkptKXomtuo+dOjU9NdMASQ8DNC9z9zLOMbG53T4eihYr3XR+BQ==", + "version": "1.39.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.39.0.tgz", + "integrity": "sha512-3u1iFqgzl7zr004bGPYiN/5EZpRUSFddQBra8Rqll5N0/vfpqlP9I9EXqAoGacuAbX6c9Ulg/Cjqglp5VkK6UQ==", "dev": true, "requires": { - "playwright": "1.38.1" + "playwright": "1.39.0" } }, "@radix-ui/primitive": { @@ -31485,19 +31485,19 @@ } }, "playwright": { - "version": "1.38.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.38.1.tgz", - "integrity": "sha512-oRMSJmZrOu1FP5iu3UrCx8JEFRIMxLDM0c/3o4bpzU5Tz97BypefWf7TuTNPWeCe279TPal5RtPPZ+9lW/Qkow==", + "version": "1.39.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.39.0.tgz", + "integrity": "sha512-naE5QT11uC/Oiq0BwZ50gDmy8c8WLPRTEWuSSFVG2egBka/1qMoSqYQcROMT9zLwJ86oPofcTH2jBY/5wWOgIw==", "dev": true, "requires": { "fsevents": "2.3.2", - "playwright-core": "1.38.1" + "playwright-core": "1.39.0" } }, "playwright-core": { - "version": "1.38.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.38.1.tgz", - "integrity": "sha512-tQqNFUKa3OfMf4b2jQ7aGLB8o9bS3bOY0yMEtldtC2+spf8QXG9zvXLTXUeRsoNuxEYMgLYR+NXfAa1rjKRcrg==", + "version": "1.39.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.39.0.tgz", + "integrity": "sha512-+k4pdZgs1qiM+OUkSjx96YiKsXsmb59evFoqv8SKO067qBA+Z2s/dCzJij/ZhdQcs2zlTAgRKfeiiLm8PQ2qvw==", "dev": true }, "postcss": { diff --git a/package.json b/package.json index 1f058def5c..be144ff3f7 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "devDependencies": { "@ladle/react": "^3.2.1", "@mswjs/http-middleware": "^0.8.0", - "@playwright/test": "^1.38.1", + "@playwright/test": "^1.39.0", "@testing-library/dom": "^9.3.1", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^14.0.0", From 310738136a26ef3b017ec6ff9db74bb145695b4b Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 13 Oct 2023 15:03:50 -0500 Subject: [PATCH 10/11] code tweaks, mostly names --- app/components/form/fields/TlsCertsField.tsx | 48 +++++++++----------- app/forms/silo-create.tsx | 4 +- 2 files changed, 23 insertions(+), 29 deletions(-) diff --git a/app/components/form/fields/TlsCertsField.tsx b/app/components/form/fields/TlsCertsField.tsx index 2c47265a26..c19a0d143b 100644 --- a/app/components/form/fields/TlsCertsField.tsx +++ b/app/components/form/fields/TlsCertsField.tsx @@ -8,14 +8,16 @@ import { useState } from 'react' import type { Control } from 'react-hook-form' import { useController } from 'react-hook-form' +import type { Merge } from 'type-fest' +import type { CertificateCreate } from '@oxide/api' import { Button, Error16Icon, FieldLabel, MiniTable, Modal } from '@oxide/ui' import { DescriptionField, FileField, TextField, validateName } from 'app/components/form' -import type { SiloCreateInput } from 'app/forms/silo-create' +import type { SiloCreateFormValues } from 'app/forms/silo-create' import { useForm } from 'app/hooks' -export function TlsCertsField({ control }: { control: Control }) { +export function TlsCertsField({ control }: { control: Control }) { const [showAddCert, setShowAddCert] = useState(false) const { @@ -68,8 +70,9 @@ export function TlsCertsField({ control }: { control: Control } onSubmit={async (values) => { const certCreate: (typeof items)[number] = { ...values, - cert: await values.cert.text(), - key: await values.key.text(), + // cert and key are required fields. they will always be present if we get here + cert: await values.cert!.text(), + key: await values.key!.text(), } onChange([...items, certCreate]) setShowAddCert(false) @@ -81,41 +84,32 @@ export function TlsCertsField({ control }: { control: Control } ) } -export type TlsCertificate = Omit< - SiloCreateInput['tlsCertificates'][number], - 'key' | 'cert' -> & { - key: File - cert: File -} +export type CertFormValues = Merge< + CertificateCreate, + { key: File | null; cert: File | null } // swap strings for Files +> -const defaultValues: Partial = { +const defaultValues: CertFormValues = { description: '', name: '', service: 'external_api', + key: null, + cert: null, } -const AddCertModal = ({ - onDismiss, - onSubmit, - allNames, -}: { +type AddCertModalProps = { onDismiss: () => void - onSubmit: (values: TlsCertificate) => void + onSubmit: (values: CertFormValues) => void allNames: string[] -}) => { - const { control, handleSubmit } = useForm({ defaultValues }) +} + +const AddCertModal = ({ onDismiss, onSubmit, allNames }: AddCertModalProps) => { + const { control, handleSubmit } = useForm({ defaultValues }) return ( - { - e.stopPropagation() - handleSubmit(onSubmit)(e) - }} - > + & { +export type SiloCreateFormValues = Omit & { siloAdminGetsFleetAdmin: boolean siloViewerGetsFleetViewer: boolean } -const defaultValues: SiloCreateInput = { +const defaultValues: SiloCreateFormValues = { name: '', description: '', discoverable: true, From cc4fa1d46d23fcdd8eb172f0c2c6f2f9f58e3058 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 13 Oct 2023 15:19:41 -0500 Subject: [PATCH 11/11] undo upgrade to playwright 1.39 for now. it break unrelated tests --- app/test/e2e/silos.e2e.ts | 94 +++++++++++++++++++-------------------- package-lock.json | 46 +++++++++---------- package.json | 2 +- 3 files changed, 70 insertions(+), 72 deletions(-) diff --git a/app/test/e2e/silos.e2e.ts b/app/test/e2e/silos.e2e.ts index 9a205a2fcd..3aaca313c9 100644 --- a/app/test/e2e/silos.e2e.ts +++ b/app/test/e2e/silos.e2e.ts @@ -35,54 +35,52 @@ test('Create silo', async ({ page }) => { await page.click('role=checkbox[name="Grant fleet admin role to silo admins"]') // Add a TLS cert - await test.step('Add TLS cert', async () => { - const openCertModalButton = page.getByRole('button', { name: 'Add TLS certificate' }) - await openCertModalButton.click() - - const certDialog = page.getByRole('dialog', { name: 'Add TLS certificate' }) - - const certRequired = certDialog.getByText('Cert is required') - const keyRequired = certDialog.getByText('Key is required') - const nameRequired = certDialog.getByText('Name is required') - await expectNotVisible(page, [certRequired, keyRequired, nameRequired]) - - const certSubmit = page.getByRole('button', { name: 'Add Certificate' }) - await certSubmit.click() - - // Validation error for missing name + key and cert files - await expectVisible(page, [certRequired, keyRequired, nameRequired]) - - await chooseFile(page, page.getByLabel('Cert', { exact: true }), 0.1 * MiB) - await chooseFile(page, page.getByLabel('Key'), 0.1 * MiB) - const certName = certDialog.getByRole('textbox', { name: 'Name' }) - await certName.fill('test-cert') - - await certSubmit.click() - - // Check cert appears in the mini-table - const certCell = page.getByRole('cell', { name: 'test-cert', exact: true }) - await expect(certCell).toBeVisible() - - // check unique name validation - await openCertModalButton.click() - await certName.fill('test-cert') - await certSubmit.click() - await expect( - certDialog.getByText('A certificate with this name already exists') - ).toBeVisible() - - // Change the name so it's unique - await certName.fill('test-cert-2') - await chooseFile(page, page.getByLabel('Cert', { exact: true }), 0.1 * MiB) - await chooseFile(page, page.getByLabel('Key'), 0.1 * MiB) - await certSubmit.click() - await expect(page.getByRole('cell', { name: 'test-cert-2', exact: true })).toBeVisible() - - // now delete the first - await page.getByRole('button', { name: 'remove test-cert', exact: true }).click() - // Cert should not appear after it has been deleted - await expect(certCell).toBeHidden() - }) + const openCertModalButton = page.getByRole('button', { name: 'Add TLS certificate' }) + await openCertModalButton.click() + + const certDialog = page.getByRole('dialog', { name: 'Add TLS certificate' }) + + const certRequired = certDialog.getByText('Cert is required') + const keyRequired = certDialog.getByText('Key is required') + const nameRequired = certDialog.getByText('Name is required') + await expectNotVisible(page, [certRequired, keyRequired, nameRequired]) + + const certSubmit = page.getByRole('button', { name: 'Add Certificate' }) + await certSubmit.click() + + // Validation error for missing name + key and cert files + await expectVisible(page, [certRequired, keyRequired, nameRequired]) + + await chooseFile(page, page.getByLabel('Cert', { exact: true }), 0.1 * MiB) + await chooseFile(page, page.getByLabel('Key'), 0.1 * MiB) + const certName = certDialog.getByRole('textbox', { name: 'Name' }) + await certName.fill('test-cert') + + await certSubmit.click() + + // Check cert appears in the mini-table + const certCell = page.getByRole('cell', { name: 'test-cert', exact: true }) + await expect(certCell).toBeVisible() + + // check unique name validation + await openCertModalButton.click() + await certName.fill('test-cert') + await certSubmit.click() + await expect( + certDialog.getByText('A certificate with this name already exists') + ).toBeVisible() + + // Change the name so it's unique + await certName.fill('test-cert-2') + await chooseFile(page, page.getByLabel('Cert', { exact: true }), 0.1 * MiB) + await chooseFile(page, page.getByLabel('Key'), 0.1 * MiB) + await certSubmit.click() + await expect(page.getByRole('cell', { name: 'test-cert-2', exact: true })).toBeVisible() + + // now delete the first + await page.getByRole('button', { name: 'remove test-cert', exact: true }).click() + // Cert should not appear after it has been deleted + await expect(certCell).toBeHidden() await page.click('role=button[name="Create silo"]') diff --git a/package-lock.json b/package-lock.json index 2b8ea84a93..05a9aeaedd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,7 +55,7 @@ "devDependencies": { "@ladle/react": "^3.2.1", "@mswjs/http-middleware": "^0.8.0", - "@playwright/test": "^1.39.0", + "@playwright/test": "^1.38.1", "@testing-library/dom": "^9.3.1", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^14.0.0", @@ -2039,12 +2039,12 @@ } }, "node_modules/@playwright/test": { - "version": "1.39.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.39.0.tgz", - "integrity": "sha512-3u1iFqgzl7zr004bGPYiN/5EZpRUSFddQBra8Rqll5N0/vfpqlP9I9EXqAoGacuAbX6c9Ulg/Cjqglp5VkK6UQ==", + "version": "1.38.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.38.1.tgz", + "integrity": "sha512-NqRp8XMwj3AK+zKLbZShl0r/9wKgzqI/527bkptKXomtuo+dOjU9NdMASQ8DNC9z9zLOMbG53T4eihYr3XR+BQ==", "dev": true, "dependencies": { - "playwright": "1.39.0" + "playwright": "1.38.1" }, "bin": { "playwright": "cli.js" @@ -15796,12 +15796,12 @@ } }, "node_modules/playwright": { - "version": "1.39.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.39.0.tgz", - "integrity": "sha512-naE5QT11uC/Oiq0BwZ50gDmy8c8WLPRTEWuSSFVG2egBka/1qMoSqYQcROMT9zLwJ86oPofcTH2jBY/5wWOgIw==", + "version": "1.38.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.38.1.tgz", + "integrity": "sha512-oRMSJmZrOu1FP5iu3UrCx8JEFRIMxLDM0c/3o4bpzU5Tz97BypefWf7TuTNPWeCe279TPal5RtPPZ+9lW/Qkow==", "dev": true, "dependencies": { - "playwright-core": "1.39.0" + "playwright-core": "1.38.1" }, "bin": { "playwright": "cli.js" @@ -15814,9 +15814,9 @@ } }, "node_modules/playwright-core": { - "version": "1.39.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.39.0.tgz", - "integrity": "sha512-+k4pdZgs1qiM+OUkSjx96YiKsXsmb59evFoqv8SKO067qBA+Z2s/dCzJij/ZhdQcs2zlTAgRKfeiiLm8PQ2qvw==", + "version": "1.38.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.38.1.tgz", + "integrity": "sha512-tQqNFUKa3OfMf4b2jQ7aGLB8o9bS3bOY0yMEtldtC2+spf8QXG9zvXLTXUeRsoNuxEYMgLYR+NXfAa1rjKRcrg==", "dev": true, "bin": { "playwright-core": "cli.js" @@ -21291,12 +21291,12 @@ } }, "@playwright/test": { - "version": "1.39.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.39.0.tgz", - "integrity": "sha512-3u1iFqgzl7zr004bGPYiN/5EZpRUSFddQBra8Rqll5N0/vfpqlP9I9EXqAoGacuAbX6c9Ulg/Cjqglp5VkK6UQ==", + "version": "1.38.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.38.1.tgz", + "integrity": "sha512-NqRp8XMwj3AK+zKLbZShl0r/9wKgzqI/527bkptKXomtuo+dOjU9NdMASQ8DNC9z9zLOMbG53T4eihYr3XR+BQ==", "dev": true, "requires": { - "playwright": "1.39.0" + "playwright": "1.38.1" } }, "@radix-ui/primitive": { @@ -31485,19 +31485,19 @@ } }, "playwright": { - "version": "1.39.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.39.0.tgz", - "integrity": "sha512-naE5QT11uC/Oiq0BwZ50gDmy8c8WLPRTEWuSSFVG2egBka/1qMoSqYQcROMT9zLwJ86oPofcTH2jBY/5wWOgIw==", + "version": "1.38.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.38.1.tgz", + "integrity": "sha512-oRMSJmZrOu1FP5iu3UrCx8JEFRIMxLDM0c/3o4bpzU5Tz97BypefWf7TuTNPWeCe279TPal5RtPPZ+9lW/Qkow==", "dev": true, "requires": { "fsevents": "2.3.2", - "playwright-core": "1.39.0" + "playwright-core": "1.38.1" } }, "playwright-core": { - "version": "1.39.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.39.0.tgz", - "integrity": "sha512-+k4pdZgs1qiM+OUkSjx96YiKsXsmb59evFoqv8SKO067qBA+Z2s/dCzJij/ZhdQcs2zlTAgRKfeiiLm8PQ2qvw==", + "version": "1.38.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.38.1.tgz", + "integrity": "sha512-tQqNFUKa3OfMf4b2jQ7aGLB8o9bS3bOY0yMEtldtC2+spf8QXG9zvXLTXUeRsoNuxEYMgLYR+NXfAa1rjKRcrg==", "dev": true }, "postcss": { diff --git a/package.json b/package.json index be144ff3f7..1f058def5c 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "devDependencies": { "@ladle/react": "^3.2.1", "@mswjs/http-middleware": "^0.8.0", - "@playwright/test": "^1.39.0", + "@playwright/test": "^1.38.1", "@testing-library/dom": "^9.3.1", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^14.0.0",