diff --git a/.changeset/hungry-chicken-flash.md b/.changeset/hungry-chicken-flash.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/hungry-chicken-flash.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/integration/templates/vue-vite/src/router.ts b/integration/templates/vue-vite/src/router.ts index 6fd11280ae6..59e951c3bd1 100644 --- a/integration/templates/vue-vite/src/router.ts +++ b/integration/templates/vue-vite/src/router.ts @@ -68,6 +68,48 @@ const routes = [ path: '/billing/subscription-details-btn', component: () => import('./views/billing/SubscriptionDetailsBtn.vue'), }, + // Composable state routes (public, for testing composable output) + { + name: 'AuthState', + path: '/auth-state', + component: () => import('./views/AuthState.vue'), + }, + { + name: 'UserState', + path: '/user-state', + component: () => import('./views/UserState.vue'), + }, + { + name: 'SessionState', + path: '/session-state', + component: () => import('./views/SessionState.vue'), + }, + { + name: 'OrgState', + path: '/org-state', + component: () => import('./views/OrgState.vue'), + }, + // Component test routes + { + name: 'SignOut', + path: '/sign-out', + component: () => import('./views/SignOutPage.vue'), + }, + { + name: 'OrganizationList', + path: '/org-list', + component: () => import('./views/OrganizationListPage.vue'), + }, + { + name: 'CreateOrganization', + path: '/create-org', + component: () => import('./views/CreateOrganizationPage.vue'), + }, + { + name: 'ShowComponent', + path: '/show-component', + component: () => import('./views/ShowComponent.vue'), + }, ]; const router = createRouter({ diff --git a/integration/templates/vue-vite/src/views/AuthState.vue b/integration/templates/vue-vite/src/views/AuthState.vue new file mode 100644 index 00000000000..93af9193253 --- /dev/null +++ b/integration/templates/vue-vite/src/views/AuthState.vue @@ -0,0 +1,17 @@ + + + diff --git a/integration/templates/vue-vite/src/views/CreateOrganizationPage.vue b/integration/templates/vue-vite/src/views/CreateOrganizationPage.vue new file mode 100644 index 00000000000..491feb7bbdb --- /dev/null +++ b/integration/templates/vue-vite/src/views/CreateOrganizationPage.vue @@ -0,0 +1,7 @@ + + + diff --git a/integration/templates/vue-vite/src/views/OrgState.vue b/integration/templates/vue-vite/src/views/OrgState.vue new file mode 100644 index 00000000000..c249ef0680d --- /dev/null +++ b/integration/templates/vue-vite/src/views/OrgState.vue @@ -0,0 +1,15 @@ + + + diff --git a/integration/templates/vue-vite/src/views/OrganizationListPage.vue b/integration/templates/vue-vite/src/views/OrganizationListPage.vue new file mode 100644 index 00000000000..58a4ede940b --- /dev/null +++ b/integration/templates/vue-vite/src/views/OrganizationListPage.vue @@ -0,0 +1,7 @@ + + + diff --git a/integration/templates/vue-vite/src/views/SessionState.vue b/integration/templates/vue-vite/src/views/SessionState.vue new file mode 100644 index 00000000000..77c682dd52f --- /dev/null +++ b/integration/templates/vue-vite/src/views/SessionState.vue @@ -0,0 +1,15 @@ + + + diff --git a/integration/templates/vue-vite/src/views/ShowComponent.vue b/integration/templates/vue-vite/src/views/ShowComponent.vue new file mode 100644 index 00000000000..a769f107a18 --- /dev/null +++ b/integration/templates/vue-vite/src/views/ShowComponent.vue @@ -0,0 +1,34 @@ + + + diff --git a/integration/templates/vue-vite/src/views/SignOutPage.vue b/integration/templates/vue-vite/src/views/SignOutPage.vue new file mode 100644 index 00000000000..bd9fc2c5c58 --- /dev/null +++ b/integration/templates/vue-vite/src/views/SignOutPage.vue @@ -0,0 +1,15 @@ + + + diff --git a/integration/templates/vue-vite/src/views/UserState.vue b/integration/templates/vue-vite/src/views/UserState.vue new file mode 100644 index 00000000000..cff3d62b506 --- /dev/null +++ b/integration/templates/vue-vite/src/views/UserState.vue @@ -0,0 +1,16 @@ + + + diff --git a/integration/tests/vue/components.test.ts b/integration/tests/vue/components.test.ts index c5aa518a358..c7966a53d34 100644 --- a/integration/tests/vue/components.test.ts +++ b/integration/tests/vue/components.test.ts @@ -18,8 +18,8 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withCustomRoles] })('basic te }); test.afterAll(async () => { - await fakeOrganization.delete(); - await fakeUser.deleteIfExists(); + await fakeOrganization?.delete(); + await fakeUser?.deleteIfExists(); await app.teardown(); }); @@ -252,6 +252,21 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withCustomRoles] })('basic te await u.page.waitForAppUrl('/'); }); + test(' signs the user out when clicked', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/sign-in'); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + await u.page.waitForAppUrl('/'); + + await u.page.goToRelative('/sign-out'); + await expect(u.page.locator('[data-signed-in]')).toBeVisible(); + + await u.page.getByRole('button', { name: /Sign out/i }).click(); + await u.po.expect.toBeSignedOut(); + }); + test('redirects to sign-in when unauthenticated', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); await u.page.goToRelative('/profile'); diff --git a/integration/tests/vue/composables.test.ts b/integration/tests/vue/composables.test.ts new file mode 100644 index 00000000000..3b1c46a8a64 --- /dev/null +++ b/integration/tests/vue/composables.test.ts @@ -0,0 +1,111 @@ +import { expect, test } from '@playwright/test'; + +import { appConfigs } from '../../presets'; +import type { FakeOrganization, FakeUser } from '../../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../../testUtils'; + +testAgainstRunningApps({ withEnv: [appConfigs.envs.withCustomRoles] })('composable tests for @vue', ({ app }) => { + test.describe.configure({ mode: 'parallel' }); + + let fakeUser: FakeUser; + let fakeOrganization: FakeOrganization; + + test.beforeAll(async () => { + const u = createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser(); + const user = await u.services.users.createBapiUser(fakeUser); + fakeOrganization = await u.services.users.createFakeOrganization(user.id); + }); + + test.afterAll(async () => { + await fakeOrganization?.delete(); + await fakeUser?.deleteIfExists(); + await app.teardown(); + }); + + test.afterEach(async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.signOut(); + await u.page.context().clearCookies(); + }); + + test('useAuth() returns correct values when signed in', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/sign-in'); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + await u.page.goToRelative('/auth-state'); + await expect(u.page.locator('[data-auth-is-loaded]')).toContainText('true'); + await expect(u.page.locator('[data-auth-is-signed-in]')).toContainText('true'); + await expect(u.page.locator('[data-auth-user-id]')).not.toHaveText(''); + await expect(u.page.locator('[data-auth-session-id]')).not.toHaveText(''); + }); + + test('useAuth() returns organization data when org is active', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/sign-in'); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + // Wait for org to be selected (the org switcher auto-selects) + await u.page.waitForAppUrl('/'); + await u.po.organizationSwitcher.waitForMounted(); + await u.po.organizationSwitcher.waitForAnOrganizationToSelected(); + + await u.page.goToRelative('/auth-state'); + await expect(u.page.locator('[data-auth-org-id]')).not.toHaveText(''); + await expect(u.page.locator('[data-auth-org-role]')).toContainText('org:admin'); + }); + + test('useUser() returns user data when signed in', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/sign-in'); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + await u.page.goToRelative('/user-state'); + await expect(u.page.locator('[data-user-is-loaded]')).toContainText('true'); + await expect(u.page.locator('[data-user-is-signed-in]')).toContainText('true'); + await expect(u.page.locator('[data-user-id]')).not.toHaveText(''); + await expect(u.page.locator('[data-user-email]')).toContainText(fakeUser.email); + await expect(u.page.locator('[data-user-first-name]')).toContainText(fakeUser.firstName); + await expect(u.page.locator('[data-user-last-name]')).toContainText(fakeUser.lastName); + }); + + test('useSession() returns session data when signed in', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/sign-in'); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + await u.page.goToRelative('/session-state'); + await expect(u.page.locator('[data-session-is-loaded]')).toContainText('true'); + await expect(u.page.locator('[data-session-is-signed-in]')).toContainText('true'); + await expect(u.page.locator('[data-session-id]')).not.toHaveText(''); + await expect(u.page.locator('[data-session-status]')).toContainText('active'); + }); + + test('useOrganization() returns organization data when org is active', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/sign-in'); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + // Wait for org to be selected + await u.page.waitForAppUrl('/'); + await u.po.organizationSwitcher.waitForMounted(); + await u.po.organizationSwitcher.waitForAnOrganizationToSelected(); + + await u.page.goToRelative('/org-state'); + await expect(u.page.locator('[data-org-is-loaded]')).toContainText('true'); + await expect(u.page.locator('[data-org-id]')).not.toHaveText(''); + await expect(u.page.locator('[data-org-name]')).toContainText(fakeOrganization.name); + await expect(u.page.locator('[data-org-role]')).toContainText('org:admin'); + }); +}); diff --git a/integration/tests/vue/organizations.test.ts b/integration/tests/vue/organizations.test.ts new file mode 100644 index 00000000000..8405259f7ef --- /dev/null +++ b/integration/tests/vue/organizations.test.ts @@ -0,0 +1,74 @@ +import { expect, test } from '@playwright/test'; + +import { appConfigs } from '../../presets'; +import type { FakeOrganization, FakeUser } from '../../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../../testUtils'; + +testAgainstRunningApps({ withEnv: [appConfigs.envs.withCustomRoles] })('organization tests for @vue', ({ app }) => { + test.describe.configure({ mode: 'parallel' }); + + let fakeUser: FakeUser; + let fakeOrganization: FakeOrganization; + + test.beforeAll(async () => { + const u = createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser(); + const user = await u.services.users.createBapiUser(fakeUser); + fakeOrganization = await u.services.users.createFakeOrganization(user.id); + }); + + test.afterAll(async () => { + await fakeOrganization?.delete(); + await fakeUser?.deleteIfExists(); + await app.teardown(); + }); + + test.afterEach(async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.signOut(); + await u.page.context().clearCookies(); + }); + + test(' renders and shows organizations', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/sign-in'); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + await u.page.goToRelative('/org-list'); + await u.page.waitForClerkComponentMounted(); + await expect(u.page.getByText(fakeOrganization.name)).toBeVisible(); + }); + + test(' renders', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/sign-in'); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + await u.page.goToRelative('/create-org'); + await u.page.waitForClerkComponentMounted(); + await expect(u.page.getByRole('heading', { name: /Create organization/i })).toBeVisible(); + }); + + test(' allows switching organizations', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/sign-in'); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + await u.page.waitForAppUrl('/'); + await u.po.organizationSwitcher.waitForMounted(); + await u.po.organizationSwitcher.waitForAnOrganizationToSelected(); + + // Open the switcher + await u.po.organizationSwitcher.toggleTrigger(); + await u.page.waitForSelector('.cl-organizationSwitcherPopoverCard', { state: 'visible' }); + + // Verify the org name is visible in the popover + await expect(u.page.locator('.cl-organizationSwitcherPopoverCard').getByText(fakeOrganization.name)).toBeVisible(); + }); +}); diff --git a/integration/tests/vue/show-component.test.ts b/integration/tests/vue/show-component.test.ts new file mode 100644 index 00000000000..7cacfd3c859 --- /dev/null +++ b/integration/tests/vue/show-component.test.ts @@ -0,0 +1,99 @@ +import type { OrganizationMembershipRole } from '@clerk/backend'; +import { expect, test } from '@playwright/test'; + +import { appConfigs } from '../../presets'; +import type { FakeOrganization, FakeUser } from '../../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../../testUtils'; + +testAgainstRunningApps({ withEnv: [appConfigs.envs.withCustomRoles] })('Show component tests for @vue', ({ app }) => { + test.describe.configure({ mode: 'parallel' }); + + let fakeUser: FakeUser; + let fakeOrganization: FakeOrganization; + let memberUser: FakeUser; + + test.beforeAll(async () => { + const u = createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser(); + const user = await u.services.users.createBapiUser(fakeUser); + fakeOrganization = await u.services.users.createFakeOrganization(user.id); + + // Create a member user (not admin) for fallback tests + memberUser = u.services.users.createFakeUser(); + const bapiMember = await u.services.users.createBapiUser(memberUser); + await u.services.clerk.organizations.createOrganizationMembership({ + organizationId: fakeOrganization.organization.id, + role: 'org:viewer' as OrganizationMembershipRole, + userId: bapiMember.id, + }); + }); + + test.afterAll(async () => { + await fakeOrganization?.delete(); + await memberUser?.deleteIfExists(); + await fakeUser?.deleteIfExists(); + await app.teardown(); + }); + + test.afterEach(async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.signOut(); + await u.page.context().clearCookies(); + }); + + test(' renders when not authenticated', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/show-component'); + await u.page.waitForClerkJsLoaded(); + + await expect(u.page.getByText('show-signed-out-content')).toBeVisible(); + await expect(u.page.getByText('show-signed-in-content')).toBeHidden(); + }); + + test(' renders when authenticated', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/sign-in'); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + await u.page.goToRelative('/show-component'); + await expect(u.page.getByText('show-signed-in-content')).toBeVisible(); + await expect(u.page.getByText('show-signed-out-content')).toBeHidden(); + }); + + test(' with permission condition renders for admin with manage permission', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/sign-in'); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + // Wait for org to be selected + await u.page.waitForAppUrl('/'); + await u.po.organizationSwitcher.waitForMounted(); + await u.po.organizationSwitcher.waitForAnOrganizationToSelected(); + + await u.page.goToRelative('/show-component'); + await expect(u.page.getByText('show-permission-content')).toBeVisible(); + await expect(u.page.getByText('show-permission-fallback')).toBeHidden(); + }); + + test(' with role condition renders fallback for non-admin', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await u.page.goToRelative('/sign-in'); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: memberUser.email, password: memberUser.password }); + await u.po.expect.toBeSignedIn(); + + // Wait for org to be selected + await u.page.waitForAppUrl('/'); + await u.po.organizationSwitcher.waitForMounted(); + await u.po.organizationSwitcher.waitForAnOrganizationToSelected(); + + await u.page.goToRelative('/show-component'); + await expect(u.page.getByText('show-admin-fallback')).toBeVisible(); + await expect(u.page.getByText('show-admin-content')).toBeHidden(); + }); +});