fix: Test the playwright tests in Github actions (#26799)

* Add an API page for run-lints endpoint.

* Fix the playwright test.

* Make the check mandatory in the github action.

* Fix a console.log.

* Use a more stable way of waiting for buttons before clicking them.

* Add helper method for dismissing toasts.

* Wait for the other toast.

* Try and fix the flakiness of the toasts.

* Minor fix.

* Upload artifacts at the end of the github action run.

* Fix yaml stuff.

* Use the newer action for uploading. Shorten the retention period to 7 days.

* Add waitForResponse for the entity types API call.

* snapshot.

* Wait for the page to be loaded.

* Make the timeout bigger when waiting for response.

* Try to fix the timeouts.

* Minor restructuring of the test.

* Add another test and few minor todo tests.

* Fix the table names.

* Don't render hcaptcha in test mode.

* Expand the test to include sorting and filtering.

* Add a test for checking if the other schemas are can be listed.

* Make the sort popover button more stable.

* Fix a unused forward ref in the NavMenuItem component.

* Don't render the Toaster container when in test mode.

* Simplify starting the dev server when testing.

* Fix all inputs to have proper test ids.

* Fix the command for testing.
This commit is contained in:
Ivan Vasilov authored and GitHub committed 2024-06-07 17:44:28 +02:00
1 parent 7940e50d93
commit 5847ad54cf
13 files changed
+205 -45

No files matched your search

+7 -7
View File
@@ -36,10 +36,10 @@ jobs:
- name: Run Playwright tests
run: npm run test:playwright
# mark the action as succeeded even if the tests failed. This is temporarily until we make the tests more stable.
continue-on-error: true
# - uses: actions/upload-artifact@v3
# if: always()
# with:
# name: playwright-report
# path: playwright-report/
# retention-days: 30
# continue-on-error: true
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-tests/playwright-report/
retention-days: 7
@@ -143,11 +143,13 @@ const SortOverlay = ({ table, sorts: sortsFromUrl, onApplySorts }: SortOverlayPr
align="start"
>
<Button
asChild
type="text"
iconRight={<ChevronDown size="14" className="text-foreground-light" />}
className="sb-grid-dropdown__item-trigger"
data-testid="table-editor-pick-column-to-sort-button"
>
Pick {sorts.length > 1 ? 'another' : 'a'} column to sort by
<span>Pick {sorts.length > 1 ? 'another' : 'a'} column to sort by</span>
</Button>
</DropdownControl>
) : (
@@ -38,6 +38,7 @@ interface InputWithSuggestionsProps {
suggestionsHeader?: string
onChange: (event: any) => void
onSelectSuggestion: (suggestion: Suggestion) => void
'data-testid'?: string
}
const InputWithSuggestions = ({
@@ -54,6 +55,7 @@ const InputWithSuggestions = ({
suggestionsHeader,
onChange = noop,
onSelectSuggestion = noop,
'data-testid': dataTestId,
}: InputWithSuggestionsProps) => {
const ref = useRef(null)
const [filteredSuggestions, setFilteredSuggestions] = useState<Suggestion[]>(suggestions)
@@ -92,6 +94,7 @@ const InputWithSuggestions = ({
type="text"
value={value}
onChange={onInputChange}
data-testid={dataTestId}
actions={
showSuggestions && (
<DropdownMenu>
@@ -10,11 +10,11 @@ import {
Select,
} from 'ui'
import { MAX_CHARACTERS } from 'data/table-rows/table-rows-query'
import { Edit, Edit2, Link } from 'lucide-react'
import { DATETIME_TYPES, JSON_TYPES, TEXT_TYPES } from '../SidePanelEditor.constants'
import { DateTimeInput } from './DateTimeInput'
import type { RowField } from './RowEditor.types'
import { MAX_CHARACTERS } from 'data/table-rows/table-rows-query'
export interface InputFieldProps {
field: RowField
@@ -41,6 +41,7 @@ const InputField = ({
return (
<div className="text-area-text-sm">
<Input.TextArea
data-testid={`${field.name}-input`}
layout="horizontal"
label={field.name}
className="text-sm"
@@ -88,6 +89,7 @@ const InputField = ({
if (field.foreignKey !== undefined) {
return (
<Input
data-testid={`${field.name}-input`}
layout="horizontal"
label={field.name}
value={field.value ?? ''}
@@ -131,6 +133,7 @@ const InputField = ({
return (
<div className="text-area-text-sm">
<Input.TextArea
data-testid={`${field.name}-input`}
layout="horizontal"
label={field.name}
className="text-sm"
@@ -187,6 +190,7 @@ const InputField = ({
return (
<Input
data-testid={`${field.name}-input`}
layout="horizontal"
value={field.value ?? ''}
label={field.name}
@@ -284,6 +288,7 @@ const InputField = ({
return (
<Input
data-testid={`${field.name}-input`}
layout="horizontal"
label={field.name}
descriptionText={field.comment}
@@ -243,6 +243,7 @@ const Column = ({
<div className={`${isNewRecord ? 'w-[25%]' : 'w-[30%]'}`}>
<div className="w-[95%]">
<InputWithSuggestions
data-testid={`${column.name}-default-value`}
placeholder={
typeof column.defaultValue === 'string' && column.defaultValue.length === 0
? 'EMPTY'
@@ -280,6 +281,7 @@ const Column = ({
className="pointer-events-auto"
align="end"
modal={true}
data-testid={`${column.name}-extra-options`}
header={
<div className="flex items-center justify-center">
<h5 className="text-sm text-foreground">Extra options</h5>
@@ -255,6 +255,7 @@ const TableEditor = ({
>
<SidePanel.Content className="space-y-10 py-6">
<Input
data-testid="table-name-input"
label="Name"
layout="horizontal"
type="text"
@@ -282,12 +283,7 @@ const TableEditor = ({
<Badge>Recommended</Badge>
</div>
}
// @ts-ignore
description={
<>
<p>Restrict access to your table by enabling RLS and writing Postgres policies.</p>
</>
}
description="Restrict access to your table by enabling RLS and writing Postgres policies."
checked={tableFields.isRLSEnabled}
onChange={() => {
// if isEnabled, show confirm modal to turn off
@@ -213,7 +213,7 @@ const TableEditorMenu = () => {
/>
)}
{(entityTypes?.length ?? 0) > 0 && (
<div className="flex flex-1">
<div className="flex flex-1" data-testid="tables-list">
<InfiniteList
items={entityTypes}
ItemComponent={EntityListItem}
+2 -2
View File
@@ -204,8 +204,8 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) {
</TooltipProvider>
</PageTelemetry>
<HCaptchaLoadedStore />
<Toaster />
{!isTestEnv && <HCaptchaLoadedStore />}
{!isTestEnv && <Toaster />}
<PortalToast />
{!isTestEnv && <ReactQueryDevtools initialIsOpen={false} position="bottom-right" />}
</FlagProvider>
@@ -0,0 +1,24 @@
import { NextApiRequest, NextApiResponse } from 'next'
import apiWrapper from 'lib/api/apiWrapper'
import { extractResponse } from 'pages/api/constants'
export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler)
async function handler(req: NextApiRequest, res: NextApiResponse) {
const { method } = req
switch (method) {
case 'GET':
return handleGet(req, res)
default:
res.setHeader('Allow', ['GET'])
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } })
}
}
type ResponseData = extractResponse<'/platform/projects/{ref}/run-lints', 'get'>
const handleGet = async (req: NextApiRequest, res: NextApiResponse<ResponseData>) => {
return res.status(200).json([])
}
+7 -8
View File
@@ -20,17 +20,16 @@ export const NavMenu = forwardRef<HTMLDivElement, NavMenuProps>(
}
)
export const NavMenuItem = forwardRef(
({
children,
className,
active,
...props
}: PropsWithChildren<{
interface NavMenuItemProps
extends PropsWithChildren<{
className?: string
active: boolean
}>) => (
}> {}
export const NavMenuItem = forwardRef<HTMLLIElement, NavMenuItemProps>(
({ children, className, active, ...props }, ref) => (
<li
ref={ref}
aria-selected={active ? 'true' : 'false'}
data-state={active ? 'active' : 'inactive'}
className={cn(
@@ -26,6 +26,7 @@ interface RootProps {
header?: React.ReactNode
footer?: React.ReactNode
size?: 'tiny' | 'small' | 'medium' | 'large' | 'xlarge' | 'content'
'data-testid'?: string
}
/**
@@ -49,6 +50,7 @@ function Popover({
footer,
size = 'content',
disabled,
'data-testid': dataTestId,
}: RootProps) {
const __styles = styleHandler('popover')
@@ -69,6 +71,7 @@ function Popover({
disabled={disabled}
className={__styles.trigger}
aria-label={ariaLabel}
data-testid={dataTestId}
>
{children}
</RadixPopover.Trigger>
+10 -2
View File
@@ -29,7 +29,7 @@ export default defineConfig({
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
// record a video for failed tests, but only for local testing. We can't store videos on CI atm.
video: process.env.CI ? 'off' : 'retain-on-failure',
video: 'retain-on-failure',
},
/* Configure projects for major browsers */
@@ -72,8 +72,16 @@ export default defineConfig({
/* Run your local dev server before starting the tests */
webServer: {
command: 'NODE_ENV=test npm --prefix ../apps/studio run dev',
// using npm run dev instead of turbo because turbo doesn't stop the server after a test (doesn't handle SIGTERM).
command: 'npm run dev',
cwd: '../apps/studio',
url: 'http://localhost:8082',
reuseExistingServer: !process.env.CI,
env: {
NODE_ENV: 'test',
},
// Show the nextjs logs in the terminal. Useful for debugging.
// stdout: 'pipe',
// stderr: 'pipe',
},
})
@@ -1,30 +1,148 @@
import { expect, test } from '@playwright/test'
import { Page, expect, test } from '@playwright/test'
import { kebabCase } from 'lodash'
const dismissToast = async (page: Page) => {
await page.locator('#toast').getByRole('button').waitFor({ state: 'visible' })
await page.locator('#toast').getByRole('button').click()
}
test.describe('Table Editor page', () => {
test('should create a column and insert a row', async ({ page }) => {
const name = 'TestTable' + Math.floor(Math.random() * 10)
test.beforeEach(async ({ page }) => {
const tableResponsePromise = page.waitForResponse(
'http://localhost:8082/api/pg-meta/default/query?key=public-entity-types',
{ timeout: 0 }
)
await page.goto('/project/default/editor')
await tableResponsePromise
})
test('should create a new table, view its definition, add new rows, sort and filter', async ({
page,
}, testInfo) => {
const tableName = `${kebabCase(testInfo.title).slice(0, 24)}-${testInfo.retry}-${Math.floor(Math.random() * 100)}`
// The page has been loaded with the table data, we can now interact with the page
await page.getByRole('button', { name: 'New table', exact: true }).click()
// need to wait for the panel animation
await page.waitForTimeout(1000)
await page.locator('.col-span-8 > div > .relative > .peer\\/input').first().click()
await page.locator('.col-span-8 > div > .relative > .peer\\/input').first().fill(name)
await page.getByTestId('table-name-input').waitFor({ state: 'visible' })
await page.getByTestId('table-name-input').click()
await page.getByTestId('table-name-input').fill(tableName)
// make the built-in created_at column nullable
await page.getByTestId('created_at-extra-options').click()
await page.getByText('Is Nullable').click()
// the force option is needed because the button is obscured by the popover but we just want to close the popover.
await page.getByTestId('created_at-extra-options').click({ force: true })
// add a new column and add default value
await page.getByRole('button', { name: 'Add column' }).click()
await page.getByRole('textbox', { name: 'column_name' }).click()
await page.getByRole('textbox', { name: 'column_name' }).fill('textColumn')
await page.getByRole('textbox', { name: 'column_name' }).fill('defaultValueColumn')
await page.getByRole('button', { name: '---' }).click()
await page.getByText('textVariable-length character').click()
await page.waitForTimeout(1000)
await page.getByText('Signed two-byte integer').click()
await page.getByTestId('defaultValueColumn-default-value').click()
await page.getByTestId('defaultValueColumn-default-value').fill('2')
await page.getByRole('button', { name: 'Save' }).waitFor({ state: 'visible' })
await page.getByRole('button', { name: 'Save' }).click()
await page.locator('#toast').getByRole('button').click()
await page.getByRole('link', { name }).click()
await dismissToast(page)
// view its definition
await page.getByText('definition').click()
await expect(page.locator('div.view-lines')).toContainText(
`CREATE TABLE public.${tableName} ( id bigint GENERATED BY DEFAULT AS IDENTITY , created_at timestamp with time zone NULL DEFAULT now(), \"defaultValueColumn\" smallint NULL DEFAULT '2'::smallint, CONSTRAINT ${tableName}_pkey PRIMARY KEY (id)) TABLESPACE pg_default;`
)
// add a new row
await page.getByRole('button', { name: tableName }).click()
await page.getByTestId('table-editor-insert-new-row').click()
await page.getByText('Insert a new row into').click()
await page.getByPlaceholder('NULL').click()
await page.getByPlaceholder('NULL').fill('some text')
await page.getByTestId('defaultValueColumn-input').click()
await page.getByTestId('defaultValueColumn-input').fill('100')
await page.getByTestId('action-bar-save-row').click()
await page.locator('#toast').getByRole('button').click()
await expect(page.getByRole('grid')).toContainText('some text')
await dismissToast(page)
// add a second row
await page.getByRole('button', { name: tableName }).click()
await page.getByTestId('table-editor-insert-new-row').click()
await page.getByText('Insert a new row into').click()
// the default value should be '100' for defaultValueColumn
await page.getByTestId('action-bar-save-row').click()
await dismissToast(page)
await expect(page.getByRole('grid')).toContainText('2')
await expect(page.getByRole('grid')).toContainText('100')
// sort by the a column
await page.getByRole('button', { name: 'Sort' }).click()
await page.getByTestId('table-editor-pick-column-to-sort-button').click()
await page.getByLabel('Pick a column to sort by').getByText('defaultValueColumn').click()
await page.getByRole('button', { name: 'Apply sorting' }).click()
// click away to close the sorting dialog
await page
.locator('div')
.filter({ hasText: /^Table Editor$/ })
.click()
// expect the row to be sorted by defaultValueColumn. They're inserted in the order 100, 2
await expect(page.locator('div.rdg-row:nth-child(2)')).toContainText('2')
await expect(page.locator('div.rdg-row:nth-child(3)')).toContainText('100')
// remove the sorting
await page.getByRole('button', { name: 'Sorted by 1 rule' }).click()
await page.getByRole('dialog').getByRole('button').nth(1).click()
// filter by a column
await page.getByRole('button', { name: 'Filter' }).click()
await page.getByRole('button', { name: 'Add filter' }).click()
await page.getByRole('button', { name: 'id' }).click()
await page.getByLabel('id').getByText('defaultValueColumn').click()
await page.getByPlaceholder('Enter a value').click()
await page.getByPlaceholder('Enter a value').fill('2')
await page.getByRole('button', { name: 'Apply filter' }).click()
// click away to close the filter dialog
await page
.locator('div')
.filter({ hasText: /^Table Editor$/ })
.click()
await expect(page.getByRole('grid')).toContainText('2')
await expect(page.getByRole('grid')).not.toContainText('100')
})
test('should check the auth schema', async ({ page }) => {
const tableResponsePromise = page.waitForResponse(
'http://localhost:8082/api/pg-meta/default/query?key=public-entity-types',
{ timeout: 0 }
)
await page.getByRole('button', { name: 'schema: public' }).click()
await page.getByRole('option', { name: 'auth' }).click()
// wait for the table data to load for the auth schema
await tableResponsePromise
// extract the tables names from the sidebar
const tables = await page
.getByTestId('tables-list')
.innerText()
.then((text) => text.split('\n'))
// expect the tables list to contain the following tables (additional tables may be present)
expect(tables).toEqual(
expect.arrayContaining([
'audit_log_entries',
'flow_state',
'identities',
'instances',
'mfa_amr_claims',
'mfa_challenges',
'mfa_factors',
'refresh_tokens',
'saml_providers',
'saml_relay_states',
'schema_migrations',
'sessions',
'sso_domains',
'sso_providers',
'users',
])
)
})
})