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
52 changes: 52 additions & 0 deletions packages/playwright/tests/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { expect, type Page } from '@playwright/test';

export const TRACKING_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365 * 10;

export const extractRootDomain = (hostname: string): string => {
Expand All @@ -11,3 +13,53 @@ export const extractRootDomain = (hostname: string): string => {
}
return parts.join('.');
};

export const getRequiredEnv = (name: 'USER_NAME' | 'PASSWORD'): string => {
const value = process.env[name];

if (!value) {
throw new Error(`${name} environment variable is required`);
}

return value;
};

export const acceptCookieBanner = async (page: Page): Promise<void> => {
await page
.getByRole('button', { name: 'Accept all' })
.or(page.getByRole('button', { name: 'I understand' }))
.click({ timeout: 5000 })
.catch(() => undefined);
};

export const login = async (page: Page): Promise<void> => {
await page.goto('/');
await acceptCookieBanner(page);

const loginButton = page.getByRole('button', { name: 'Log in' });

if (!(await loginButton.isVisible({ timeout: 5000 }).catch(() => false))) {
return;
}

await loginButton.click();

// Scope the submit to the auth form, the page header keeps its own "Log in".
const loginForm = page.locator('form').filter({
has: page.getByRole('textbox', { name: 'Password' }),
});

await loginForm
.getByRole('textbox', { name: 'Email' })
.fill(getRequiredEnv('USER_NAME'));
await loginForm
.getByRole('textbox', { name: 'Password' })
.fill(getRequiredEnv('PASSWORD'));
await loginForm.getByRole('button', { name: 'Log in' }).click();

await expect(
page
.getByRole('link', { name: /profile/i })
.or(page.getByRole('button', { name: 'Profile settings' })),
).toBeVisible({ timeout: 20000 });
};
11 changes: 1 addition & 10 deletions packages/playwright/tests/login.spec.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,5 @@
import { test, expect } from '@playwright/test';

const getRequiredEnv = (name: 'USER_NAME' | 'PASSWORD'): string => {
const value = process.env[name];

if (!value) {
throw new Error(`${name} environment variable is required`);
}

return value;
};
import { getRequiredEnv } from './helpers';

test.describe.skip('Daily.dev Homepage', () => {
test('should load the homepage successfully', async ({ page }) => {
Expand Down
75 changes: 75 additions & 0 deletions packages/playwright/tests/profile-social-links.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { test, expect, type Page } from '@playwright/test';
import { login } from './helpers';

/**
* This is the only spec in the package that writes to the shared CI account,
* and it runs against live production. Cleanup is best effort: if Save itself
* fails the link was never stored, and if it stored but the redirect did not
* happen the row is still removed below. A hard failure mid-cleanup leaves one
* `dailydev-e2e-*` link behind for a maintainer to delete.
*/
Comment on lines +4 to +10

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Non-blocking: this commit removes the multi-line why-comments from useUserInfoForm.ts but adds a seven-line block of the same kind here (why cleanup is best effort, what happens on each failure mode). Per AGENTS.md the reasoning belongs in the commit message / PR description, which already carry it. The two // lines inside openProfileSettings are enough; suggest dropping this block or cutting it to one line naming the fact ("Only spec that writes to the shared CI account; cleanup is best effort").

Reviewed by AI.

const openProfileSettings = async (page: Page): Promise<void> => {
await page.goto('/settings/profile');

// The links section stays disabled until the profile query settles, so the
// list is only trustworthy once the input is enabled.
await expect(page.getByRole('textbox', { name: 'Add link' })).toBeEnabled({
timeout: 20000,
});
};

const removeSocialLink = async (page: Page, url: string): Promise<void> => {
await openProfileSettings(page);

const linkRow = page.getByTestId('social-link-row').filter({ hasText: url });

if ((await linkRow.count()) === 0) {
return;
}

await linkRow.getByRole('button', { name: 'Remove link' }).click();
await page.getByRole('button', { name: 'Save' }).click();
await page.waitForURL(
(currentUrl) => !currentUrl.pathname.startsWith('/settings/profile'),
{ timeout: 20000 },
);
};

test.skip(
!process.env.USER_NAME || !process.env.PASSWORD,
'Credentials are required',
);
test.skip(
({ browserName, isMobile }) => browserName !== 'chromium' || isMobile,
'Run the mutating profile regression once',
);

test('persists a pasted GitHub link when saving without clicking Add', async ({
page,
}) => {
const handle = `dailydev-e2e-${Date.now()}`;
const typedUrl = `github.com/${handle}`;
const savedUrl = `https://github.com/${handle}`;
const savedLink = page.locator(
`[data-testid="social-link-github"][href="${savedUrl}"]`,
);

await login(page);

try {
await openProfileSettings(page);
await page.getByRole('textbox', { name: 'Add link' }).fill(typedUrl);
await page.getByRole('button', { name: 'Save' }).click();

await page.waitForURL(
(currentUrl) => !currentUrl.pathname.startsWith('/settings/profile'),
{ timeout: 20000 },
);
await expect(savedLink).toBeVisible();

await page.reload();
await expect(savedLink).toBeVisible();
} finally {
await removeSocialLink(page, savedUrl);
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import AuthHeader from './AuthHeader';
import type { AuthFormProps } from './common';
import { providerMap, SocialProvider } from './common';
import AuthContext from '../../contexts/AuthContext';
import type { ProfileFormHint } from '../../hooks/useProfileForm';
import type { ProfileFormHint } from '../../graphql/users';
import { Checkbox } from '../fields/Checkbox';
import { useLogContext } from '../../contexts/LogContext';
import AuthForm from './AuthForm';
Expand Down
164 changes: 164 additions & 0 deletions packages/shared/src/components/profile/SocialLinksInput.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import React, { useRef } from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FormProvider, useForm } from 'react-hook-form';
import type { UserSocialLink } from '../../lib/user';
import {
SocialLinksInput,
type SocialLinksInputHandle,
} from './SocialLinksInput';

const mockDisplayToast = jest.fn();

jest.mock('../../hooks/useToastNotification', () => ({
useToastNotification: () => ({ displayToast: mockDisplayToast }),
}));

type FormValues = {
socialLinks: UserSocialLink[];
};

const TestForm = ({
defaultLinks = [],
isError = false,
isLoading = false,
onSubmit,
}: {
defaultLinks?: UserSocialLink[];
isError?: boolean;
isLoading?: boolean;
onSubmit: (values: FormValues) => void;
}) => {
const methods = useForm<FormValues>({
defaultValues: {
socialLinks: defaultLinks,
},
});
const socialLinksRef = useRef<SocialLinksInputHandle>(null);

const handleSubmit = methods.handleSubmit(() => {
if (socialLinksRef.current && !socialLinksRef.current.flushPendingUrl()) {
return;
}

onSubmit(methods.getValues());
});

return (
<FormProvider {...methods}>
<form onSubmit={handleSubmit}>
<SocialLinksInput
ref={socialLinksRef}
name="socialLinks"
isLoading={isLoading}
isError={isError}
/>
<button type="submit">Save</button>
</form>
</FormProvider>
);
};

describe('SocialLinksInput', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('commits pending text before submitting', async () => {
const onSubmit = jest.fn();
render(<TestForm onSubmit={onSubmit} />);

await userEvent.type(
screen.getByPlaceholderText('Paste a URL (e.g., github.com/username)'),
'github.com/testuser',
);
await userEvent.click(screen.getByRole('button', { name: 'Save' }));

await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith({
socialLinks: [
{
platform: 'github',
url: 'https://github.com/testuser',
},
],
}),
);
});

it('blocks submit and renders an inline error for invalid pending text', async () => {
const onSubmit = jest.fn();
render(<TestForm onSubmit={onSubmit} />);

await userEvent.type(
screen.getByPlaceholderText('Paste a URL (e.g., github.com/username)'),
'://',
);
await userEvent.click(screen.getByRole('button', { name: 'Save' }));

expect(onSubmit).not.toHaveBeenCalled();
await screen.findByText('Please enter a valid URL');
});

it('does not commit pending text on blur', async () => {
const onSubmit = jest.fn();
render(<TestForm onSubmit={onSubmit} />);

const input = screen.getByPlaceholderText(
'Paste a URL (e.g., github.com/username)',
);
await userEvent.type(input, 'github.com/testuser');
await userEvent.tab();

expect(
screen.queryByText('https://github.com/testuser'),
).not.toBeInTheDocument();
expect(input).toHaveValue('github.com/testuser');
});

it('toasts once when submitting a duplicate of an existing link', async () => {
const onSubmit = jest.fn();
render(
<TestForm
onSubmit={onSubmit}
defaultLinks={[
{ platform: 'github', url: 'https://github.com/testuser' },
]}
/>,
);

await userEvent.type(
screen.getByPlaceholderText('Paste a URL (e.g., github.com/username)'),
'github.com/testuser',
);
await userEvent.click(screen.getByRole('button', { name: 'Save' }));

await waitFor(() => expect(onSubmit).toHaveBeenCalled());
expect(mockDisplayToast).toHaveBeenCalledTimes(1);
expect(mockDisplayToast).toHaveBeenCalledWith(
'This link has already been added',
);
});

it('disables adding links while the saved links are loading', () => {
render(<TestForm onSubmit={jest.fn()} isLoading />);

expect(
screen.getByPlaceholderText('Paste a URL (e.g., github.com/username)'),
).toBeDisabled();
expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled();
});

it('explains why links are missing when they failed to load', () => {
render(<TestForm onSubmit={jest.fn()} isError />);

expect(
screen.getByText(
'We could not load your links. Refresh the page to try again.',
),
).toBeVisible();
expect(
screen.getByPlaceholderText('Paste a URL (e.g., github.com/username)'),
).toBeDisabled();
});
});
Loading
Loading