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
8 changes: 6 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ GA_MEASUREMENT_ID='example'
SMOKE_BASE_URL=http://localhost:3000

# Credentials for a dedicated smoke-test account. Keep real values in
# .env.development, which is gitignored - never commit them here.
# .env.development
SMOKE_USER_EMAIL=
SMOKE_USER_PASSWORD=
SMOKE_USER_PASSWORD=

# Content the specs navigate to. Ids only - /grant/<id> and /proposal/<id>
SMOKE_GRANT_ID=33
SMOKE_PROPOSAL_ID=5
8 changes: 7 additions & 1 deletion components/Auth/screens/ForgotPassword.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,16 @@ export default function ForgotPassword({
autoComplete="email"
className="w-full p-3 border rounded"
ref={emailInputRef}
data-testid="auth-forgot-email-input"
/>
</div>

<Button type="submit" disabled={isLoading} className="w-full mb-4">
<Button
type="submit"
disabled={isLoading}
className="w-full mb-4"
data-testid="auth-forgot-submit"
>
{isLoading ? 'Sending...' : 'Send reset link'}
</Button>
</form>
Expand Down
1 change: 1 addition & 0 deletions components/Auth/screens/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ export default function Login({
onClick={onForgotPassword}
disabled={isLoading}
className="text-rhBlue-500 hover:text-rhBlue-600 text-sm"
data-testid="auth-forgot-password"
>
Forgot your password?
</Button>
Expand Down
3 changes: 3 additions & 0 deletions components/Auth/screens/Signup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export default function Signup({
placeholder="Full name (e.g. John Smith)"
className="w-full p-3 border rounded mb-4"
ref={fullNameInputRef}
data-testid="auth-signup-name-input"
/>

<div className="relative mb-4">
Expand All @@ -101,6 +102,7 @@ export default function Signup({
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
className="w-full p-3 border rounded pr-12"
data-testid="auth-signup-password-input"
/>
<button
type="button"
Expand All @@ -115,6 +117,7 @@ export default function Signup({
type="submit"
disabled={isLoading}
className="w-full bg-indigo-600 text-white p-3 rounded mb-4 hover:bg-indigo-700 disabled:opacity-50"
data-testid="auth-signup-submit"
>
{isLoading ? 'Creating account...' : 'Create account'}
</button>
Expand Down
1 change: 1 addition & 0 deletions components/Feed/items/FeedItemGrantWithApplicants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ export const FeedItemGrantWithApplicants: FC<FeedItemGrantWithApplicantsProps> =
'bg-white rounded-[14px] overflow-hidden border border-gray-200 shadow-[0_1px_3px_rgba(0,0,0,0.04)]',
className
)}
data-testid="grant-card"
>
{/* Frosted header */}
<Link href={href} className="group block relative h-[160px] overflow-hidden bg-gray-900">
Expand Down
1 change: 1 addition & 0 deletions components/Funding/GrantSortAndFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ function SortDropdown({
aria-haspopup="listbox"
aria-expanded={isOpen}
className="inline-flex items-center gap-1.5 -mr-2 min-h-[44px] px-2 rounded-lg text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-50 active:bg-gray-100 transition-colors cursor-pointer touch-manipulation"
data-testid="feed-sort-trigger"
>
<span className="font-medium text-gray-700">{selectedLabel}</span>
{isOpen ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
Expand Down
1 change: 1 addition & 0 deletions components/Funding/ProposalSortAndFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ function SortDropdown() {
aria-haspopup="listbox"
aria-expanded={isOpen}
className="inline-flex items-center gap-1.5 -mr-2 min-h-[44px] px-2 rounded-lg text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-50 active:bg-gray-100 transition-colors cursor-pointer touch-manipulation"
data-testid="feed-sort-trigger"
>
<span className="font-medium text-gray-700">{selectedLabel}</span>
{isOpen ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
Expand Down
2 changes: 1 addition & 1 deletion components/Funding/ProposalWorkCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export const ProposalWorkCard: FC<ProposalWorkCardProps> = ({ entry, onNavigate
};

return (
<article data-entry-id={entryId}>
<article data-entry-id={entryId} data-testid="proposal-card">
<WorkPreviewCard work={work} brand={presentation.brand} onNavigate={handleNavigate}>
<WorkPreviewCard.Overlay position="top-left">
<FeedItemFundingBadges
Expand Down
1 change: 1 addition & 0 deletions components/Notebook/NoteEditorLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ export function NoteEditorLayout({ onAgentChatDockedChange }: NoteEditorLayoutPr
<div className="flex items-center gap-2">
{activeTab === 'document' && (
<Button
data-testid="notebook-add-details"
variant="outlined"
size="sm"
onClick={() => setActiveTab('details')}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ export function FundingSection({ note }: Readonly<FundingSectionProps>) {
<>
<div>
<Input
data-testid="funding-goal-input"
{...register('budget')}
label="Funding Goal"
required
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export function TopicsSection() {
}, []);

return (
<div className="py-3 px-6">
<div className="py-3 px-6" data-testid="topics-section">
<SectionHeader icon={Tag}>Topics</SectionHeader>
<SearchableMultiSelect
value={topics}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ function CoverImageControl({
)}

<input
data-testid="cover-image-input"
ref={inputRef}
type="file"
accept={ACCEPT.join(',')}
Expand Down
1 change: 1 addition & 0 deletions components/Notebook/PublishingForm/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -766,6 +766,7 @@ export function PublishingForm({
<p className="text-sm text-red-600">{CHANGELOG_PUBLISH_ERROR_MESSAGE}</p>
)}
<Button
data-testid="publishing-form-submit"
variant="default"
onClick={handlePublishClick}
className="w-full disabled:opacity-50 disabled:cursor-not-allowed"
Expand Down
2 changes: 2 additions & 0 deletions components/menus/UserMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ export default function UserMenu({
tabIndex={0}
role="button"
aria-label="Sign Out"
data-testid="user-menu-signout"
>
<div className="flex items-center">
<LogOut className="h-5 w-5 mr-3 text-gray-500" />
Expand Down Expand Up @@ -446,6 +447,7 @@ export default function UserMenu({
<BaseMenuItem
onClick={() => AuthSharingService.signOutFromBothApps()}
className="w-full px-4 py-2"
data-testid="user-menu-signout"
>
<div className="flex items-center">
<LogOut className="h-5 w-5 mr-3 text-gray-500" />
Expand Down
2 changes: 2 additions & 0 deletions components/modals/ApplyToGrantModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,7 @@ export const ApplyToGrantModal: React.FC<ApplyToGrantModalProps> = ({
)}
>
<div
data-testid="apply-draft-new"
onClick={handleSelectDraftNew}
className={cn(
'px-3 py-2 rounded-lg border cursor-pointer transition-all duration-200',
Expand Down Expand Up @@ -383,6 +384,7 @@ export const ApplyToGrantModal: React.FC<ApplyToGrantModalProps> = ({
</div>
)}
<Button
data-testid="apply-continue"
variant={draftNewSelected ? 'dark' : 'default'}
onClick={handleFooterAction}
disabled={!hasSelection || isSavingSelection}
Expand Down
2 changes: 2 additions & 0 deletions components/modals/ConfirmPublishModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export function ConfirmPublishModal({
{resolvedDocumentLabel}:
</p>
<input
data-testid="confirm-publish-title"
type="text"
value={title}
onChange={handleTitleChange}
Expand Down Expand Up @@ -168,6 +169,7 @@ export function ConfirmPublishModal({
Cancel
</Button>
<Button
data-testid="confirm-publish-submit"
variant="default"
onClick={() => onConfirm(title)}
disabled={!isPublishEnabled || isPublishing}
Expand Down
1 change: 1 addition & 0 deletions components/work/WorkHeader/WorkHeaderGrant.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export function WorkHeaderGrant({
<>
<SubmitProposalTooltip isPrivate={requiresPrivateApplications}>
<Button
data-testid="grant-submit-proposal"
variant="default"
size="lg"
onClick={() => setIsApplyModalOpen(true)}
Expand Down
84 changes: 83 additions & 1 deletion smoke/auth.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { expect, test } from '@playwright/test';
import { submitCredentials } from './helpers/auth';
import { mockApiPost } from './helpers/api';
import { logIn, smokeCredentials, submitCredentials, uniqueEmail } from './helpers/auth';

test('a user can log in from the sign-in page', async ({ page }) => {
await page.goto('/auth/signin');
Expand Down Expand Up @@ -36,3 +37,84 @@ test('a user can log in through the auth modal', async ({ page }) => {
await expect(page.getByTestId('user-menu-button').first()).toBeVisible();
await expect(loginButton).toHaveCount(0);
});

test('a user can request a password reset from the auth modal', async ({ page }) => {
// Mocked so that a run cannot mail a real inbox or trip rate limiting on the
// endpoint. The request is still asserted below, so what the form sends stays
// covered; only Django's own handling of it is out of scope.
const resetRequests = await mockApiPost(page, '/api/auth/password-reset/');

await page.goto('/');
await page.getByTestId('topbar-login-button').click();

// The reset link lives on the password step, which check_account only opens
// for an account that exists and is verified, so this has to be driven as the
// smoke user rather than an arbitrary address.
const { email } = smokeCredentials();
await page.getByTestId('auth-email-input').fill(email);
await page.getByTestId('auth-email-continue').click();
await page.getByTestId('auth-forgot-password').click();

// The screen carries the address over from the email step, so a user who got
// here through the modal never retypes it.
await expect(page.getByTestId('auth-forgot-email-input')).toHaveValue(email);
await page.getByTestId('auth-forgot-submit').click();

await expect(page.getByRole('heading', { name: 'Request submitted' })).toBeVisible();
await expect(page.getByText(`password reset link to ${email}`)).toBeVisible();
expect(resetRequests).toEqual([{ body: { email } }]);
});

test('a user can register a new account with an email address', async ({ page }) => {
// Mocked: a real registration would leave an account behind on every pull
// request that no one can ever verify, since CI has no inbox to confirm from.
const registrations = await mockApiPost(page, '/api/auth/register/', {
status: 201,
body: { id: 1 },
});

const email = uniqueEmail();
await page.goto('/');
await page.getByTestId('topbar-login-button').click();

// check_account is deliberately left unmocked. A freshly generated address
// genuinely has no account, and it is that real response which decides
// between the login and signup screens.
await page.getByTestId('auth-email-input').fill(email);
await page.getByTestId('auth-email-continue').click();
await expect(page.getByRole('heading', { name: 'Create your account' })).toBeVisible();

await page.getByTestId('auth-signup-name-input').fill('Smoke Tester');
await page.getByTestId('auth-signup-password-input').fill('SmokeTest!2468');
await page.getByTestId('auth-signup-submit').click();

await expect(page.getByRole('heading', { name: 'Check your email' })).toBeVisible();
await expect(page.getByText(`verification link to ${email}`)).toBeVisible();

// With the response faked, the request is the only real evidence the form
// works: in particular that the single name field was split into the two
// Django expects.
expect(registrations).toHaveLength(1);
expect(registrations[0].body).toMatchObject({
email,
password1: 'SmokeTest!2468',
password2: 'SmokeTest!2468',
first_name: 'Smoke',
last_name: 'Tester',
});
});

test('a user can log out from the user menu', async ({ page }) => {
await logIn(page);
await page.goto('/');

await page.getByTestId('user-menu-button').first().click();
await page.getByTestId('user-menu-signout').click();

// Signing out also clears the token shared with the other app and redirects
// home, but the observable result is the top bar reverting to its
// logged-out controls. Asserting that rather than the URL keeps this
// independent of the redirect flakiness noted above.
await expect(page.getByTestId('topbar-login-button')).toBeVisible();
await expect(page.getByTestId('user-menu-button')).toHaveCount(0);
});
86 changes: 86 additions & 0 deletions smoke/fund.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { expect, test, type Page, type Request } from '@playwright/test';

const GRANT_FEED = '/api/grant_feed/';
const FUNDING_FEED = '/api/funding_feed/';

/**
* A sort change refetches immediately, unlike a first page load, so these waits
* are capped well below the test timeout. A broken control then reports in
* seconds instead of consuming the whole budget, twice over once CI retries.
*/
const SORT_CHANGE_TIMEOUT = 15_000;

/**
* Neither feed writes its sort to the URL, and neither card renders a date in
* any machine-readable form, so the request the frontend builds is the only
* place the chosen order is observable. Matching on pathname keeps this working
* whichever origin NEXT_PUBLIC_API_URL points at.
*/
function feedRequest(pathname: string, ordering: string) {
return (request: Request) => {
const url = new URL(request.url());
return url.pathname === pathname && url.searchParams.get('ordering') === ordering;
};
}

/** Opens a feed's sort dropdown and picks an option by its visible label. */
async function selectSort(page: Page, label: string) {
await page.getByTestId('feed-sort-trigger').click();
await page.getByRole('option', { name: label }).click();
}

test('the RFP feed renders results from the API', async ({ page }) => {
await page.goto('/fund');

// As with the activity feed, a failed fetch is swallowed in favour of the
// empty state rather than an error page, so assert that state is absent too.
await expect(page.getByTestId('grant-card').first()).toBeVisible();
await expect(page.getByText('No open awards right now')).toHaveCount(0);
});

test('the RFP feed can be sorted by newest', async ({ page }) => {
const initialNewest = page.waitForRequest(feedRequest(GRANT_FEED, 'newest'));
await page.goto('/fund');
await initialNewest;
await expect(page.getByTestId('grant-card').first()).toBeVisible();

// This feed already defaults to newest, so selecting it on a fresh page would
// pass even with the dropdown wired to nothing. Sorting away and back is what
// makes the assertion depend on the control actually driving the query.
const byAmount = page.waitForRequest(feedRequest(GRANT_FEED, 'amount_raised'), {
timeout: SORT_CHANGE_TIMEOUT,
});
await selectSort(page, 'Highest amount');
await byAmount;

const backToNewest = page.waitForRequest(feedRequest(GRANT_FEED, 'newest'), {
timeout: SORT_CHANGE_TIMEOUT,
});
await selectSort(page, 'Newest');
await backToNewest;

await expect(page.getByTestId('grant-card').first()).toBeVisible();
});

test('the proposals feed renders results from the API', async ({ page }) => {
await page.goto('/fund/proposals');

await expect(page.getByTestId('proposal-card').first()).toBeVisible();
await expect(page.getByText('No proposals submitted yet')).toHaveCount(0);
});

test('the proposals feed can be sorted by newest', async ({ page }) => {
// Unlike the RFP feed this one defaults to 'best', so choosing Newest is
// already a real change of order and needs no round trip to mean something.
const initialBest = page.waitForRequest(feedRequest(FUNDING_FEED, 'best'));
await page.goto('/fund/proposals');
await initialBest;

const byNewest = page.waitForRequest(feedRequest(FUNDING_FEED, 'newest'), {
timeout: SORT_CHANGE_TIMEOUT,
});
await selectSort(page, 'Newest');
await byNewest;

await expect(page.getByTestId('proposal-card').first()).toBeVisible();
});
Loading
Loading