From de417987db10f4c67e2341d59afee273e62085ad Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 25 Jun 2025 08:21:10 -0700 Subject: [PATCH 1/7] chore: initialize api keys component test --- .../next-app-router/src/app/api-keys/page.tsx | 5 +++ .../tests/machine-auth/component.test.ts | 33 +++++++++++++++++++ package.json | 1 + .../unstable/page-objects/apiKeys.ts | 31 +++++++++++++++++ .../playwright/unstable/page-objects/index.ts | 2 ++ 5 files changed, 72 insertions(+) create mode 100644 integration/templates/next-app-router/src/app/api-keys/page.tsx create mode 100644 integration/tests/machine-auth/component.test.ts create mode 100644 packages/testing/src/playwright/unstable/page-objects/apiKeys.ts diff --git a/integration/templates/next-app-router/src/app/api-keys/page.tsx b/integration/templates/next-app-router/src/app/api-keys/page.tsx new file mode 100644 index 00000000000..2ac16d90307 --- /dev/null +++ b/integration/templates/next-app-router/src/app/api-keys/page.tsx @@ -0,0 +1,5 @@ +import { APIKeys } from '@clerk/nextjs'; + +export default function Page() { + return ; +} diff --git a/integration/tests/machine-auth/component.test.ts b/integration/tests/machine-auth/component.test.ts new file mode 100644 index 00000000000..6ed1a1abed3 --- /dev/null +++ b/integration/tests/machine-auth/component.test.ts @@ -0,0 +1,33 @@ +import { test } from '@playwright/test'; + +import { appConfigs } from '../../presets'; +import type { FakeUser } from '../../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../../testUtils'; + +testAgainstRunningApps({ withEnv: [appConfigs.envs.withAPIKeys] })('api keys @xgeneric', ({ app }) => { + test.describe.configure({ mode: 'serial' }); + + let fakeUser: FakeUser; + + test.beforeAll(async () => { + const u = createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser(); + await u.services.users.createBapiUser(fakeUser); + }); + + test.afterAll(async () => { + await fakeUser.deleteIfExists(); + await app.teardown(); + }); + + test('renders api keys', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.po.signIn.goTo(); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + await u.po.page.goToRelative('/api-keys'); + await u.po.apiKeys.waitForMounted(); + }); +}); diff --git a/package.json b/package.json index d0ac1cadb53..4b734ed36da 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "test:integration:tanstack-react-router": "E2E_APP_ID=tanstack.react-router pnpm test:integration:base --grep @tanstack-react-router", "test:integration:tanstack-react-start": "E2E_APP_ID=tanstack.react-start pnpm test:integration:base --grep @tanstack-react-start", "test:integration:vue": "E2E_APP_ID=vue.vite pnpm test:integration:base --grep @vue", + "test:integration:xgeneric": "E2E_APP_ID=next.appRouter.* pnpm test:integration:base --grep @xgeneric", "test:typedoc": "pnpm typedoc:generate && cd ./.typedoc && vitest run", "turbo:clean": "turbo daemon clean", "typedoc:generate": "pnpm build:declarations && typedoc --tsconfig tsconfig.typedoc.json", diff --git a/packages/testing/src/playwright/unstable/page-objects/apiKeys.ts b/packages/testing/src/playwright/unstable/page-objects/apiKeys.ts new file mode 100644 index 00000000000..2e730dbbda4 --- /dev/null +++ b/packages/testing/src/playwright/unstable/page-objects/apiKeys.ts @@ -0,0 +1,31 @@ +import type { EnhancedPage } from './app'; +import { common } from './common'; + +export const createAPIKeysComponentPageObject = (testArgs: { page: EnhancedPage }) => { + const { page } = testArgs; + const self = { + ...common(testArgs), + waitForMounted: () => { + return page.waitForSelector('.cl-apiKeys-root', { state: 'attached' }); + }, + clickAddButton: () => { + return page.getByText(/Add new key/i).click(); + }, + waitForFormOpened: () => { + return page.waitForSelector('.cl-apiKeysCreateForm', { state: 'attached' }); + }, + typeName: (value: string) => { + return page.getByLabel(/Secret key name/i).fill(value); + }, + typeDescription: (value: string) => { + return page.getByLabel(/Description/i).fill(value); + }, + selectExpiration: (value: string) => { + return page.getByLabel(/Expiration/i).selectOption(value); + }, + clickSaveButton: () => { + return page.getByText(/Create key/i).click(); + }, + }; + return self; +}; diff --git a/packages/testing/src/playwright/unstable/page-objects/index.ts b/packages/testing/src/playwright/unstable/page-objects/index.ts index bd2b7be3ac7..9f38a003798 100644 --- a/packages/testing/src/playwright/unstable/page-objects/index.ts +++ b/packages/testing/src/playwright/unstable/page-objects/index.ts @@ -1,5 +1,6 @@ import type { Page } from '@playwright/test'; +import { createAPIKeysComponentPageObject } from './apiKeys'; import { createAppPageObject } from './app'; import { createCheckoutPageObject } from './checkout'; import { createClerkPageObject } from './clerk'; @@ -46,5 +47,6 @@ export const createPageObjects = ({ userProfile: createUserProfileComponentPageObject(testArgs), userVerification: createUserVerificationComponentPageObject(testArgs), waitlist: createWaitlistComponentPageObject(testArgs), + apiKeys: createAPIKeysComponentPageObject(testArgs), }; }; From b07a3646849ddab50b89084a5666ae2df759eacc Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 25 Jun 2025 12:44:28 -0700 Subject: [PATCH 2/7] chore: introduce more tests --- integration/playwright.config.ts | 1 + .../tests/machine-auth/component.test.ts | 102 +++++++++++++++++- package.json | 1 - .../unstable/page-objects/apiKeys.ts | 32 +++++- 4 files changed, 130 insertions(+), 6 deletions(-) diff --git a/integration/playwright.config.ts b/integration/playwright.config.ts index 20b6c4aef76..c13c32a8ca8 100644 --- a/integration/playwright.config.ts +++ b/integration/playwright.config.ts @@ -20,6 +20,7 @@ export const common: PlaywrightTestConfig = { ignoreHTTPSErrors: true, trace: 'retain-on-failure', bypassCSP: true, // We probably need to limit this to specific tests + permissions: ['clipboard-read'], }, } as const; diff --git a/integration/tests/machine-auth/component.test.ts b/integration/tests/machine-auth/component.test.ts index 6ed1a1abed3..b91bf658e1a 100644 --- a/integration/tests/machine-auth/component.test.ts +++ b/integration/tests/machine-auth/component.test.ts @@ -1,10 +1,10 @@ -import { test } from '@playwright/test'; +import { expect, test } from '@playwright/test'; import { appConfigs } from '../../presets'; import type { FakeUser } from '../../testUtils'; import { createTestUtils, testAgainstRunningApps } from '../../testUtils'; -testAgainstRunningApps({ withEnv: [appConfigs.envs.withAPIKeys] })('api keys @xgeneric', ({ app }) => { +testAgainstRunningApps({ withEnv: [appConfigs.envs.withAPIKeys] })('api keys @generic', ({ app }) => { test.describe.configure({ mode: 'serial' }); let fakeUser: FakeUser; @@ -20,7 +20,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withAPIKeys] })('api keys @xg await app.teardown(); }); - test('renders api keys', async ({ page, context }) => { + test('can create api keys', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); await u.po.signIn.goTo(); await u.po.signIn.waitForMounted(); @@ -29,5 +29,101 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withAPIKeys] })('api keys @xg await u.po.page.goToRelative('/api-keys'); await u.po.apiKeys.waitForMounted(); + + // Create API key 1 + await u.po.apiKeys.clickAddButton(); + await u.po.apiKeys.waitForFormOpened(); + await u.po.apiKeys.typeName(`${fakeUser.firstName}-api-key-1`); + await u.po.apiKeys.selectExpiration('1d'); + await u.po.apiKeys.clickSaveButton(); + + await u.po.apiKeys.waitForFormClosed(); + + // Create API key 2 + await u.po.apiKeys.clickAddButton(); + await u.po.apiKeys.waitForFormOpened(); + await u.po.apiKeys.typeName(`${fakeUser.firstName}-api-key-2`); + await u.po.apiKeys.selectExpiration('7d'); + await u.po.apiKeys.clickSaveButton(); + + // Check if both API keys are created + await expect(u.page.locator('.cl-apiKeysTable .cl-tableRow')).toHaveCount(2); + }); + + test('can revoke api keys', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.po.signIn.goTo(); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + await u.po.page.goToRelative('/api-keys'); + await u.po.apiKeys.waitForMounted(); + + const apiKeyName = `${fakeUser.firstName}-${Date.now()}`; + + // Create API key + await u.po.apiKeys.clickAddButton(); + await u.po.apiKeys.waitForFormOpened(); + await u.po.apiKeys.typeName(apiKeyName); + await u.po.apiKeys.selectExpiration('1d'); + await u.po.apiKeys.clickSaveButton(); + await u.po.apiKeys.waitForFormClosed(); + + // Retrieve API key + const table = u.page.locator('.cl-apiKeysTable'); + const row = table.locator('.cl-tableRow', { hasText: apiKeyName }); + await row.waitFor({ state: 'attached' }); + + // Revoke API key + await row.locator('.cl-tableBodyCell').nth(3).locator('.cl-menuButton').click(); + const revokeButton = u.page.getByRole('menuitem', { name: 'Revoke key' }); + await revokeButton.waitFor({ state: 'attached' }); + await revokeButton.click(); + + // Wait for revoke modal and confirm revocation + await u.po.apiKeys.waitForRevokeModalOpened(); + await u.po.apiKeys.typeRevokeConfirmation('Revoke'); + await u.po.apiKeys.clickConfirmRevokeButton(); + await u.po.apiKeys.waitForRevokeModalClosed(); + + // Check if record is removed from the table + await expect(table.locator('.cl-apiKeysTable .cl-tableRow', { hasText: apiKeyName })).toHaveCount(0); + }); + + test('can copy api keys', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.po.signIn.goTo(); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + await u.po.page.goToRelative('/api-keys'); + await u.po.apiKeys.waitForMounted(); + + const apiKeyName = `${fakeUser.firstName}-${Date.now()}`; + + // Create API key + await u.po.apiKeys.clickAddButton(); + await u.po.apiKeys.waitForFormOpened(); + await u.po.apiKeys.typeName(apiKeyName); + await u.po.apiKeys.selectExpiration('1d'); + await u.po.apiKeys.clickSaveButton(); + await u.po.apiKeys.waitForFormClosed(); + + const responsePromise = page.waitForResponse( + response => response.url().includes('/secret') && response.request().method() === 'GET', + ); + + // Copy API key + const table = u.page.locator('.cl-apiKeysTable'); + const row = table.locator('.cl-tableRow', { hasText: apiKeyName }); + await row.waitFor({ state: 'attached' }); + await row.locator('.cl-tableBodyCell').nth(2).locator('.cl-apiKeysCopyButton').click(); + + // Read clipboard contents + const data = await (await responsePromise).json(); + const clipboardText = await page.evaluate('navigator.clipboard.readText()'); + expect(clipboardText).toBe(data.secret); }); }); diff --git a/package.json b/package.json index 4b734ed36da..d0ac1cadb53 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,6 @@ "test:integration:tanstack-react-router": "E2E_APP_ID=tanstack.react-router pnpm test:integration:base --grep @tanstack-react-router", "test:integration:tanstack-react-start": "E2E_APP_ID=tanstack.react-start pnpm test:integration:base --grep @tanstack-react-start", "test:integration:vue": "E2E_APP_ID=vue.vite pnpm test:integration:base --grep @vue", - "test:integration:xgeneric": "E2E_APP_ID=next.appRouter.* pnpm test:integration:base --grep @xgeneric", "test:typedoc": "pnpm typedoc:generate && cd ./.typedoc && vitest run", "turbo:clean": "turbo daemon clean", "typedoc:generate": "pnpm build:declarations && typedoc --tsconfig tsconfig.typedoc.json", diff --git a/packages/testing/src/playwright/unstable/page-objects/apiKeys.ts b/packages/testing/src/playwright/unstable/page-objects/apiKeys.ts index 2e730dbbda4..ae55673d343 100644 --- a/packages/testing/src/playwright/unstable/page-objects/apiKeys.ts +++ b/packages/testing/src/playwright/unstable/page-objects/apiKeys.ts @@ -3,6 +3,18 @@ import { common } from './common'; export const createAPIKeysComponentPageObject = (testArgs: { page: EnhancedPage }) => { const { page } = testArgs; + + const expirationOptions = { + never: 'Never', + '1d': '1 Day', + '7d': '7 Days', + '30d': '30 Days', + '60d': '60 Days', + '90d': '90 Days', + '180d': '180 Days', + '1y': '1 Year', + } as const; + const self = { ...common(testArgs), waitForMounted: () => { @@ -14,18 +26,34 @@ export const createAPIKeysComponentPageObject = (testArgs: { page: EnhancedPage waitForFormOpened: () => { return page.waitForSelector('.cl-apiKeysCreateForm', { state: 'attached' }); }, + waitForFormClosed: () => { + return page.waitForSelector('.cl-apiKeysCreateForm', { state: 'detached' }); + }, + waitForRevokeModalOpened: () => { + return page.waitForSelector('.cl-apiKeysRevokeModal', { state: 'attached' }); + }, + waitForRevokeModalClosed: () => { + return page.waitForSelector('.cl-apiKeysRevokeModal', { state: 'detached' }); + }, typeName: (value: string) => { return page.getByLabel(/Secret key name/i).fill(value); }, typeDescription: (value: string) => { return page.getByLabel(/Description/i).fill(value); }, - selectExpiration: (value: string) => { - return page.getByLabel(/Expiration/i).selectOption(value); + selectExpiration: async (value?: keyof typeof expirationOptions) => { + await page.getByRole('button', { name: /Select date/i }).click(); + return page.getByText(expirationOptions[value ?? 'never'], { exact: true }).click(); }, clickSaveButton: () => { return page.getByText(/Create key/i).click(); }, + typeRevokeConfirmation: (value: string) => { + return page.getByLabel(/Type "Revoke" to confirm/i).fill(value); + }, + clickConfirmRevokeButton: () => { + return page.getByText(/Revoke key/i).click(); + }, }; return self; }; From 0abde17f08f4b9733331f776dd13599e109fdfb2 Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Wed, 25 Jun 2025 12:55:04 -0700 Subject: [PATCH 3/7] Update integration/tests/machine-auth/component.test.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- integration/tests/machine-auth/component.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration/tests/machine-auth/component.test.ts b/integration/tests/machine-auth/component.test.ts index b91bf658e1a..4fd37e6d9ca 100644 --- a/integration/tests/machine-auth/component.test.ts +++ b/integration/tests/machine-auth/component.test.ts @@ -88,7 +88,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withAPIKeys] })('api keys @ge await u.po.apiKeys.waitForRevokeModalClosed(); // Check if record is removed from the table - await expect(table.locator('.cl-apiKeysTable .cl-tableRow', { hasText: apiKeyName })).toHaveCount(0); + await expect(table.locator('.cl-tableRow', { hasText: apiKeyName })).toHaveCount(0); }); test('can copy api keys', async ({ page, context }) => { From 45262f11cb2e81dbea014398263d5a75d25c457b Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Wed, 25 Jun 2025 12:56:11 -0700 Subject: [PATCH 4/7] chore: add changeset --- .changeset/shiny-candles-sneeze.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/shiny-candles-sneeze.md diff --git a/.changeset/shiny-candles-sneeze.md b/.changeset/shiny-candles-sneeze.md new file mode 100644 index 00000000000..170288c675c --- /dev/null +++ b/.changeset/shiny-candles-sneeze.md @@ -0,0 +1,5 @@ +--- +"@clerk/testing": minor +--- + +Add API keys component testing helpers From d13bf48dd8bf2f287abe970798e07851a990a045 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 25 Jun 2025 12:58:52 -0700 Subject: [PATCH 5/7] chore: simplify menu button click --- integration/tests/machine-auth/component.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration/tests/machine-auth/component.test.ts b/integration/tests/machine-auth/component.test.ts index 4fd37e6d9ca..b87db3ca054 100644 --- a/integration/tests/machine-auth/component.test.ts +++ b/integration/tests/machine-auth/component.test.ts @@ -76,7 +76,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withAPIKeys] })('api keys @ge await row.waitFor({ state: 'attached' }); // Revoke API key - await row.locator('.cl-tableBodyCell').nth(3).locator('.cl-menuButton').click(); + await row.locator('.cl-menuButton').click(); const revokeButton = u.page.getByRole('menuitem', { name: 'Revoke key' }); await revokeButton.waitFor({ state: 'attached' }); await revokeButton.click(); From be7ca1bfa906baf24bd3bc5c319f30968178bd40 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 25 Jun 2025 13:07:48 -0700 Subject: [PATCH 6/7] chore: remove global permission --- integration/playwright.config.ts | 1 - integration/tests/machine-auth/component.test.ts | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/integration/playwright.config.ts b/integration/playwright.config.ts index c13c32a8ca8..20b6c4aef76 100644 --- a/integration/playwright.config.ts +++ b/integration/playwright.config.ts @@ -20,7 +20,6 @@ export const common: PlaywrightTestConfig = { ignoreHTTPSErrors: true, trace: 'retain-on-failure', bypassCSP: true, // We probably need to limit this to specific tests - permissions: ['clipboard-read'], }, } as const; diff --git a/integration/tests/machine-auth/component.test.ts b/integration/tests/machine-auth/component.test.ts index b87db3ca054..d0997815adf 100644 --- a/integration/tests/machine-auth/component.test.ts +++ b/integration/tests/machine-auth/component.test.ts @@ -123,7 +123,9 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withAPIKeys] })('api keys @ge // Read clipboard contents const data = await (await responsePromise).json(); + await context.grantPermissions(['clipboard-read']); const clipboardText = await page.evaluate('navigator.clipboard.readText()'); + await context.clearPermissions(); expect(clipboardText).toBe(data.secret); }); }); From 45f8084eb7ee60d770d2f606703a9a75b80038fb Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 25 Jun 2025 14:01:52 -0700 Subject: [PATCH 7/7] chore: add api key secret visibility toggling test --- .../tests/machine-auth/component.test.ts | 45 ++++++++++++++++++- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/integration/tests/machine-auth/component.test.ts b/integration/tests/machine-auth/component.test.ts index d0997815adf..e2d2c439dd2 100644 --- a/integration/tests/machine-auth/component.test.ts +++ b/integration/tests/machine-auth/component.test.ts @@ -91,7 +91,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withAPIKeys] })('api keys @ge await expect(table.locator('.cl-tableRow', { hasText: apiKeyName })).toHaveCount(0); }); - test('can copy api keys', async ({ page, context }) => { + test('can copy api key secret', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); await u.po.signIn.goTo(); await u.po.signIn.waitForMounted(); @@ -119,7 +119,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withAPIKeys] })('api keys @ge const table = u.page.locator('.cl-apiKeysTable'); const row = table.locator('.cl-tableRow', { hasText: apiKeyName }); await row.waitFor({ state: 'attached' }); - await row.locator('.cl-tableBodyCell').nth(2).locator('.cl-apiKeysCopyButton').click(); + await row.locator('.cl-apiKeysCopyButton').click(); // Read clipboard contents const data = await (await responsePromise).json(); @@ -128,4 +128,45 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withAPIKeys] })('api keys @ge await context.clearPermissions(); expect(clipboardText).toBe(data.secret); }); + + test('can toggle api key secret visibility', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.po.signIn.goTo(); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + await u.po.page.goToRelative('/api-keys'); + await u.po.apiKeys.waitForMounted(); + + const apiKeyName = `${fakeUser.firstName}-${Date.now()}`; + + // Create API key + await u.po.apiKeys.clickAddButton(); + await u.po.apiKeys.waitForFormOpened(); + await u.po.apiKeys.typeName(apiKeyName); + await u.po.apiKeys.selectExpiration('1d'); + await u.po.apiKeys.clickSaveButton(); + await u.po.apiKeys.waitForFormClosed(); + + const responsePromise = page.waitForResponse( + response => response.url().includes('/secret') && response.request().method() === 'GET', + ); + + // Toggle API key secret visibility + const table = u.page.locator('.cl-apiKeysTable'); + const row = table.locator('.cl-tableRow', { hasText: apiKeyName }); + await row.waitFor({ state: 'attached' }); + await expect(row.locator('input')).toHaveAttribute('type', 'password'); + await row.locator('.cl-apiKeysRevealButton').click(); + + // Verify if secret matches the input value + const data = await (await responsePromise).json(); + await expect(row.locator('input')).toHaveAttribute('type', 'text'); + await expect(row.locator('input')).toHaveValue(data.secret); + + // Toggle visibility off + await row.locator('.cl-apiKeysRevealButton').click(); + await expect(row.locator('input')).toHaveAttribute('type', 'password'); + }); });