Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/purple-balloons-join.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@clerk/clerk-js': patch
'@clerk/clerk-react': patch
'@clerk/types': patch
---

Introduce `__experimental_nextTask` method for navigating to next tasks on a after-auth flow
9 changes: 9 additions & 0 deletions integration/testUtils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import { createAppPageObject } from './appPageObject';
import { createEmailService } from './emailService';
import { createInvitationService } from './invitationsService';
import { createKeylessPopoverPageObject } from './keylessPopoverPageObject';
import { createOrganizationsService } from './organizationsService';
import { createOrganizationSwitcherComponentPageObject } from './organizationSwitcherPageObject';
import { createSessionTaskComponentPageObject } from './sessionTaskPageObject';
import type { EnchancedPage, TestArgs } from './signInPageObject';
import { createSignInComponentPageObject } from './signInPageObject';
import { createSignUpComponentPageObject } from './signUpPageObject';
Expand Down Expand Up @@ -50,6 +52,11 @@ const createExpectPageObject = ({ page }: TestArgs) => {
return !!window.Clerk?.user;
});
},
toHaveResolvedTask: async () => {
return page.waitForFunction(() => {
return !window.Clerk?.session?.currentTask;
});
},
};
};

Expand Down Expand Up @@ -87,6 +94,7 @@ export const createTestUtils = <
email: createEmailService(),
users: createUserService(clerkClient),
invitations: createInvitationService(clerkClient),
organizations: createOrganizationsService(clerkClient),
clerk: clerkClient,
};

Expand All @@ -106,6 +114,7 @@ export const createTestUtils = <
userButton: createUserButtonPageObject(testArgs),
userVerification: createUserVerificationComponentPageObject(testArgs),
waitlist: createWaitlistComponentPageObject(testArgs),
sessionTask: createSessionTaskComponentPageObject(testArgs),
expect: createExpectPageObject(testArgs),
clerk: createClerkUtils(testArgs),
};
Expand Down
25 changes: 25 additions & 0 deletions integration/testUtils/organizationsService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { ClerkClient, Organization } from '@clerk/backend';
import { faker } from '@faker-js/faker';

export type FakeOrganization = Pick<Organization, 'slug' | 'name'>;

export type OrganizationService = {
deleteAll: () => Promise<void>;
createFakeOrganization: () => FakeOrganization;
};

export const createOrganizationsService = (clerkClient: ClerkClient) => {
const self: OrganizationService = {
createFakeOrganization: () => ({
slug: faker.helpers.slugify(faker.commerce.department()).toLowerCase(),
name: faker.commerce.department(),
}),
deleteAll: async () => {
const organizations = await clerkClient.organizations.getOrganizationList();
const bulkDeletionPromises = organizations.data.map(({ id }) => clerkClient.organizations.deleteOrganization(id));
await Promise.all(bulkDeletionPromises);
},
};

return self;
};
26 changes: 26 additions & 0 deletions integration/testUtils/sessionTaskPageObject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { expect } from '@playwright/test';

import { common } from './commonPageObject';
import type { FakeOrganization } from './organizationsService';
import type { TestArgs } from './signInPageObject';

export const createSessionTaskComponentPageObject = (testArgs: TestArgs) => {
const { page } = testArgs;

const self = {
...common(testArgs),
resolveForceOrganizationSelectionTask: async (fakeOrganization: FakeOrganization) => {
const createOrganizationButton = page.getByRole('button', { name: /create organization/i });

await expect(createOrganizationButton).toBeVisible();
expect(page.url()).toContain('add-organization');

await page.locator('input[name=name]').fill(fakeOrganization.name);
await page.locator('input[name=slug]').fill(fakeOrganization.slug);

await createOrganizationButton.click();
},
};

return self;
};
17 changes: 14 additions & 3 deletions integration/tests/session-tasks-sign-in.test.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,49 @@
import { expect, test } from '@playwright/test';
import { test } from '@playwright/test';

import { appConfigs } from '../presets';
import type { FakeUser } from '../testUtils';
import { createTestUtils, testAgainstRunningApps } from '../testUtils';
import type { FakeOrganization } from '../testUtils/organizationsService';

testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })(
'session tasks after sign-in flow @nextjs',
({ app }) => {
test.describe.configure({ mode: 'serial' });

let fakeUser: FakeUser;
let fakeOrganization: FakeOrganization;

test.beforeAll(async () => {
const u = createTestUtils({ app });
fakeUser = u.services.users.createFakeUser();
fakeOrganization = u.services.organizations.createFakeOrganization();
await u.services.users.createBapiUser(fakeUser);
});

test.afterAll(async () => {
const u = createTestUtils({ app });
await fakeUser.deleteIfExists();
await u.services.organizations.deleteAll();
await app.teardown();
});

test('navigate to task on after sign-in', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });

// Performs sign-in
await u.po.signIn.goTo();
await u.po.signIn.setIdentifier(fakeUser.email);
await u.po.signIn.continue();
await u.po.signIn.setPassword(fakeUser.password);
await u.po.signIn.continue();
await u.po.expect.toBeSignedIn();

await expect(u.page.getByRole('button', { name: /create organization/i })).toBeVisible();
expect(page.url()).toContain('add-organization');
// Resolves task
await u.po.sessionTask.resolveForceOrganizationSelectionTask(fakeOrganization);
await u.po.expect.toHaveResolvedTask();

// Navigates to after sign-in
await u.page.waitForAppUrl('/');
});
},
);
34 changes: 25 additions & 9 deletions integration/tests/session-tasks-sign-up.test.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,50 @@
import { expect, test } from '@playwright/test';
import { test } from '@playwright/test';

import { appConfigs } from '../presets';
import type { FakeUser } from '../testUtils';
import { createTestUtils, testAgainstRunningApps } from '../testUtils';
import type { FakeOrganization } from '../testUtils/organizationsService';

testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasks] })(
'session tasks after sign-up flow @nextjs',
({ app }) => {
test.describe.configure({ mode: 'serial' });

let fakeUser: FakeUser;
let fakeOrganization: FakeOrganization;

test.beforeAll(() => {
const u = createTestUtils({ app });
fakeUser = u.services.users.createFakeUser({
fictionalEmail: true,
withPhoneNumber: true,
withUsername: true,
});
fakeOrganization = u.services.organizations.createFakeOrganization();
});

test.afterAll(async () => {
const u = createTestUtils({ app });
await u.services.organizations.deleteAll();
await fakeUser.deleteIfExists();
await app.teardown();
});

test('navigate to task on after sign-up', async ({ page, context }) => {
// Performs sign-up
const u = createTestUtils({ app, page, context });
const fakeUser = u.services.users.createFakeUser({
fictionalEmail: true,
withPhoneNumber: true,
withUsername: true,
});
await u.po.signUp.goTo();
await u.po.signUp.signUpWithEmailAndPassword({
email: fakeUser.email,
password: fakeUser.password,
});

await expect(u.page.getByRole('button', { name: /create organization/i })).toBeVisible();
expect(page.url()).toContain('add-organization');
// Resolves task
await u.po.sessionTask.resolveForceOrganizationSelectionTask(fakeOrganization);
await u.po.expect.toHaveResolvedTask();

await fakeUser.deleteIfExists();
// Navigates to after sign-up
await u.page.waitForAppUrl('/');
});
},
);
4 changes: 2 additions & 2 deletions packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"files": [
{ "path": "./dist/clerk.js", "maxSize": "577.51kB" },
{ "path": "./dist/clerk.js", "maxSize": "580kB" },
{ "path": "./dist/clerk.browser.js", "maxSize": "78.5kB" },
{ "path": "./dist/clerk.headless.js", "maxSize": "51KB" },
{ "path": "./dist/clerk.headless.js", "maxSize": "55KB" },
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm bumping this to +4 to not break too soon on the next PRs - let me know if you have any concerns!

{ "path": "./dist/ui-common*.js", "maxSize": "94KB" },
{ "path": "./dist/vendors*.js", "maxSize": "30KB" },
{ "path": "./dist/coinbase*.js", "maxSize": "35.5KB" },
Expand Down
97 changes: 97 additions & 0 deletions packages/clerk-js/src/core/__tests__/clerk.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
ActiveSessionResource,
PendingSessionResource,
SignedInSessionResource,
SignInJSON,
SignUpJSON,
Expand Down Expand Up @@ -486,6 +487,15 @@ describe('Clerk singleton', () => {
lastActiveToken: { getRawString: () => 'mocked-token' },
tasks: [{ key: 'org' }],
currentTask: { key: 'org', __internal_getUrl: () => 'https://foocorp.com/add-organization' },
reload: jest.fn(() =>
Promise.resolve({
id: '1',
status: 'pending',
user: {},
tasks: [{ key: 'org' }],
currentTask: { key: 'org', __internal_getUrl: () => 'https://foocorp.com/add-organization' },
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For posterity: I'm always just a little nervous of including random routable URLs in tests.

Truly unlikely security threat potential: If we actually GET this URL, and someone knows we're getting it, they could register the domain and have it serve some exploit that gets us to leak our CI secrets.

I'm not even proposing you change this - it's the pattern laid down elsewhere in the test - but when in doubt, I like example.com or cnames of it. It's not routable. https://foocorp.example.com/add-organization is safer in my book.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense! Also just to note that our tests only run if the triggering actor (owner of the PR) has the GitHub org permissions to access secrets, otherwise it'll fail

For URLs like those that are used to trigger navigation rather than HTTP requests, then I think it's fine!

}),
),
};
let eventBusSpy;

Expand Down Expand Up @@ -2258,4 +2268,91 @@ describe('Clerk singleton', () => {
});
});
});

describe('nextTask', () => {
describe('with `pending` session status', () => {
const mockSession = {
id: '1',
status: 'pending',
user: {},
tasks: [{ key: 'org' }],
currentTask: { key: 'org', __internal_getUrl: () => 'https://foocorp.com/add-organization' },
lastActiveToken: { getRawString: () => 'mocked-token' },
};

const mockResource = {
...mockSession,
remove: jest.fn(),
touch: jest.fn(() => Promise.resolve()),
getToken: jest.fn(),
reload: jest.fn(() => Promise.resolve(mockSession)),
};

beforeAll(() => {
mockResource.touch.mockReturnValueOnce(Promise.resolve());
mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockResource] }));
});

afterEach(() => {
mockResource.remove.mockReset();
mockResource.touch.mockReset();
});

it('navigates to next task', async () => {
const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);

await sut.setActive({ session: mockResource as any as PendingSessionResource });
await sut.__experimental_nextTask();

expect(mockNavigate.mock.calls[0][0]).toBe('/sign-in#/add-organization');
});
});

describe('with `active` session status', () => {
const mockSession = {
id: '1',
remove: jest.fn(),
status: 'active',
user: {},
touch: jest.fn(() => Promise.resolve()),
getToken: jest.fn(),
lastActiveToken: { getRawString: () => 'mocked-token' },
reload: jest.fn(() =>
Promise.resolve({
id: '1',
remove: jest.fn(),
status: 'active',
user: {},
touch: jest.fn(() => Promise.resolve()),
getToken: jest.fn(),
lastActiveToken: { getRawString: () => 'mocked-token' },
}),
),
};

afterEach(() => {
mockSession.remove.mockReset();
mockSession.touch.mockReset();
(window as any).__unstable__onBeforeSetActive = null;
(window as any).__unstable__onAfterSetActive = null;
});

it('navigates to redirect url on completion', async () => {
mockSession.touch.mockReturnValue(Promise.resolve());
mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] }));

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
await sut.setActive({ session: mockSession as any as ActiveSessionResource });

const redirectUrlComplete = '/welcome-to-app';
await sut.__experimental_nextTask({ redirectUrlComplete });

console.log(mockNavigate.mock.calls);

expect(mockNavigate.mock.calls[0][0]).toBe('/welcome-to-app');
});
});
});
});
Loading