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
9 changes: 9 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# The project gate fails on any drop by default. The frontend suite is shedding jest cases whose
# behaviour the browser suite asserts instead, and codecov measures only the jest half, so every
# such removal reads as a drop. Half a percent is enough to pass a deliberate removal and still
# fail a change that stops covering something.
coverage:
status:
project:
default:
threshold: 0.5%
81 changes: 81 additions & 0 deletions docs/backlog/jest-cases-the-browser-suite-could-take.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
---
worth: later
where: frontend/apps/remark42/app, e2e/
added: 2026-08-30
---
# What keeps jest cases out of the browser suite, and what would let them go

Removing a jest case is only safe when a named e2e case asserts the same behaviour, assertion for
assertion. Working through the suites on that rule, the cases that stay divide into a few recurring
causes, and most of them are a gap in the browser suite rather than something it cannot reach.

This is the list to work from. It is deliberately not acted on in the same change as the deletions:
each item is an e2e case to write, and the jest cases it releases can then go with it.

## Selectors the browser cannot reach

`tasks/babel-plugin-remove-test-id.js` removes `data-testid` from anything but a test build, so a
browser case cannot select by it. Only one of the elements these jest cases assert through is
actually stuck behind that:

- `comments-counter` in `profile.spec.tsx`, asserted in four cases, is
`<div className={styles.container} data-testid="comments-counter">`. Its only class is hashed by
the CSS modules, so nothing outside the bundle can name it. Giving it a stable class, the way
`.auth-button`, `.auth-submit`, `.comment-actions` and `.sort-picker` are kept outside the
modules for this reason, is what makes the counter assertable in a browser

Three others are already reachable and simply have no browser case written yet, which is a smaller
job than it looked:

- `spinner` renders `clsx('spinner', styles.root, …)` with `role="presentation"`
- `preloader` renders `clsx('preloader', className)` with `aria-label="Loading..."`
- `comment-actions-additional` renders `clsx('comment-actions-additional', …)`, so the order of the
admin actions can be asserted from a browser case today

## Transient states nothing waits on

- buttons disabled while a vote request is in flight (`comment-votes.spec.tsx`, three cases)
- the loading indicator in the telegram subscription panel
- the spinner between pages of the profile list
- the preloader that must not reappear after a load-more click

Each needs a browser case that holds the request open, which the suite already knows how to do:
`TestVote_FailureShowsAnErrorAndRestoresTheScore` blocks a route and asserts the optimistic state
before releasing it.

## Absence with no positive control

The browser suite asserts what appears far more readily than what does not. Cases kept for this:

- the Reply action gone in a read-only thread. `TestComment_ReadOnlyThreadTakesTheFormAway`
asserts the comment form is gone and says nothing about the action
- Hide absent on a reader's own comment, Delete absent on another reader's
- the verification icon absent on an unverified user
- the auth dropdown starting closed

## Configurations no instance runs

- `email_notifications` and `telegram_notifications` off. The stack covers
`show_rss_subscription` and `show_email_subscription`, which are different settings
- upvote-only voting, and voting hidden altogether
- the controversy tooltip, which needs a comment with controversy in it

## Values inside an element, where the browser case only waits for the element

- the edit countdown's remaining seconds. `TestComment_EditWithinTheDeadline` waits for the timer
and `TestComment_EditExpiresAfterTheDeadline` waits for it to go, so a blank timer passes both
- the telegram link's full `https://t.me/<bot>/?start=<token>`, where the browser helper parses out
the `start` parameter and never looks at the host or the bot name

## Error branches that are not the one the browser drives

- the generic fallback message for an unrecognised code. The browser suite fulfils a 409 and
asserts the catalogued string, which never enters that branch
- a failed check or unsubscribe being cleared by a later success, in the telegram panel

## What is not worth moving

Call counts and call arguments (`api.telegramSubscribe` called once, `getUserComments` called with
a page size) assert how a client method was used, not what a reader gets. The browser suite asserts
the request and the response instead, which is the better test of the same thing, so these stay in
jest or go away on their own when the code changes.
10 changes: 10 additions & 0 deletions e2e/vote_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,19 @@ func TestVote_UpvoteCountsOnce(t *testing.T) {
text := "vote target " + runID
voter, voterFrame, target := voteScenario(t, "voteauthor", text)

// visibility is asserted beside every value read below, and separately from the text: the
// score is read through innerText, which returns the text of an element that is not rendered
// at all, so a display:none on the counter would satisfy every text assertion in this file
// while the reader saw nothing
waitVisible(t, score(voterFrame, text))

require.NoError(t, target.Locator(`button[title="Vote up"]`).Click())

eventually(t, waitTimeout, "score did not reach 1", func() bool {
v, err := pollText(score(voterFrame, text))
return err == nil && v == "1"
})
waitVisible(t, score(voterFrame, text))

// the vote is stored and not only reflected in local state
voterFrame = reload(t, voter)
Expand Down Expand Up @@ -201,6 +208,9 @@ func TestVote_DownvoteAndCorrection(t *testing.T) {
v, err := pollText(score(voterFrame, text))
return err == nil && v == "-1"
})
// a negative score is styled differently from a positive one, so it is asserted visible on
// its own and not inferred from the positive case
waitVisible(t, score(voterFrame, text))

// and it is the server's, not the optimistic state the click set
voterFrame = reload(t, voter)
Expand Down
22 changes: 5 additions & 17 deletions frontend/apps/remark42/app/components/auth/auth.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,16 +50,16 @@ describe('<Auth/>', () => {
expect(container.querySelector('.auth-dropdown')).not.toBeInTheDocument();
});

it('should close dropdown by click outside of it', () => {
it('should not close dropdown by clickOutside message from a foreign source', async () => {
const { container } = render(<Auth />);

expect(container.querySelector('.auth-dropdown')).not.toBeInTheDocument();

fireEvent.click(screen.getByText('Sign In'));
expect(container.querySelector('.auth-dropdown')).toBeInTheDocument();

fireEvent.click(document);
expect(container.querySelector('.auth-dropdown')).not.toBeInTheDocument();
window.dispatchEvent(new MessageEvent('message', { data: { clickOutside: true }, source: null }));
await new Promise((resolve) => setTimeout(resolve, 0));

expect(container.querySelector('.auth-dropdown')).toBeInTheDocument();
});

it('should close dropdown by clickOutside message from parent', async () => {
Expand Down Expand Up @@ -109,18 +109,6 @@ describe('<Auth/>', () => {
// with no element, so the height is the document's own and not the panel that just went
await waitFor(() => expect(updateIframeHeight).toHaveBeenCalledWith());
});

it('should not close dropdown by clickOutside message from a foreign source', async () => {
const { container } = render(<Auth />);

fireEvent.click(screen.getByText('Sign In'));
expect(container.querySelector('.auth-dropdown')).toBeInTheDocument();

window.dispatchEvent(new MessageEvent('message', { data: { clickOutside: true }, source: null }));
await new Promise((resolve) => setTimeout(resolve, 0));

expect(container.querySelector('.auth-dropdown')).toBeInTheDocument();
});
});

it.each([
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,6 @@ describe('<SubscribeByTelegram />', () => {
expect(screen.getByTitle('Available only for registered users')).toBeDisabled();
});

it('should be rendered with enabled email button when user is logged in', () => {
createWrapper();

expect(screen.getByTitle('Subscribe by Telegram')).not.toBeDisabled();
});

it('should show correct telegram link', async () => {
createWrapper();
const button = screen.getByTitle('Subscribe by Telegram');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,22 +46,6 @@ describe('<CommentForm />', () => {
});

describe('with initial comment value', () => {
it('should has empty value', () => {
const value = 'text';

updatePersistedComments('1', value);
setup();
expect(screen.getByTestId('textarea_1')).toHaveValue(value);
});

it('should get initial value from localStorage', () => {
const value = 'text';

updatePersistedComments('1', value);
setup();
expect(screen.getByTestId('textarea_1')).toHaveValue(value);
});

it('should get initial value from props instead localStorage', () => {
const value = 'text from props';

Expand Down Expand Up @@ -95,12 +79,6 @@ describe('<CommentForm />', () => {
});
});

it(`doesn't render preview button and markdown toolbar in simple mode`, () => {
setup({ user }, { simple_view: true });
expect(screen.queryByTestId('markdown-toolbar')).not.toBeInTheDocument();
expect(screen.queryByText('Preview')).not.toBeInTheDocument();
});

it.each`
expected | value
${'99'} | ${'That was Wintermute, manipulating the lock the way it had manipulated the drone micro and the chassis of a gutted game console. It was chambered for .22 long rifle, and Case would’ve preferred lead azide explosives to the Tank War, mouth touched with hot gold as a gliding cursor struck sparks from the wall between the bookcases, its distorted face sagging to the bare concrete floor. Splayed in his elastic g-web, Case watched the other passengers as he made his way down Shiga from the sushi stall he cradled it in his jacket pocket. Images formed and reformed: a flickering montage of the Sprawl’s towers and ragged Fuller domes, dim figures moving toward him in the Japanese night like live wire voodoo and he’d cry for it, cry in his jacket pocket. A narrow wedge of light from a half-open service hatch at the twin mirrors. Still it was a square of faint light. The alarm still oscillated, louder here, the rear wall dulling the roar of the arcade showed him broken lengths of damp chipboard and the robot gardener. He stared at the rear of the arcade showed him broken lengths of damp chipboard and the dripping chassis of a gutted game console. That was Wintermute, manipulating the lock the way it had manipulated the drone micro and the chassis of a gutted game console. It was chambered for .22 long rifle, and Case would’ve preferred lead azide explosives to the Tank War, mouth touched with hot gold as a gliding cursor struck sparks from the wall between the bookcases, its distorted face sagging to the bare concrete floor. Splayed in his elastic g-web, Case watched the other passengers as he made his way down Shiga from the sushi stall he cradled it in his jacket pocket. Images formed and reformed: a flickering montage of the Sprawl’s towers and ragged Fuller domes, dim figures moving toward him in the Japanese night like live wire voodoo and he’d cry for it, cry in his jacket.'}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,6 @@ describe('<CommentActions/>', () => {
jest.resetAllMocks();
});

it('should render "Reply"', () => {
render(<CommentActions {...props} />);
expect(screen.getByText('Reply')).toBeVisible();
});

it('should not render "Reply" in read only mode', () => {
props.readOnly = true;
render(<CommentActions {...props} />);
Expand All @@ -68,10 +63,11 @@ describe('<CommentActions/>', () => {
expect(screen.queryByText('Hide')).not.toBeInTheDocument();
});

it('should render "Edit" and timer when editing is available', async () => {
// the browser suite waits for the countdown element and then for it to go, so nothing there
// reads what it says: a blank or malformed timer passes both of those
it('renders the countdown with the remaining seconds in it', async () => {
Object.assign(props, { editable: true, editDeadline: Date.now() + 300 * 1000 });
render(<CommentActions {...props} />);
expect(screen.getByText('Edit')).toBeInTheDocument();
await waitFor(() => expect(['300s', '299s']).toContain(screen.getByRole('timer').textContent));
});

Expand All @@ -90,13 +86,6 @@ describe('<CommentActions/>', () => {
expect(screen.getByText('Hide')).toBeInTheDocument();
});

it('should render "Delete" for current user comments when editing is available', () => {
props.currentUser = true;
props.editDeadline = Date.now() + 300 * 1000; // set editDeadline to a future timestamp
render(<CommentActions {...props} />);
expect(screen.getByText('Delete')).toBeInTheDocument();
});

it('should not render "Delete" for current user comments when editDeadline is undefined', () => {
props.currentUser = true;
props.editDeadline = undefined; // set editDeadline to undefined
Expand All @@ -122,18 +111,6 @@ describe('<CommentActions/>', () => {
expect(screen.getByText('Copied!')).toBeInTheDocument();
});

it('should render "Pin"', () => {
props.admin = true;
render(<CommentActions {...props} />);
expect(screen.getByText('Pin')).toBeInTheDocument();
});

it('should render "Unpin" when comment is pinned', () => {
Object.assign(props, { admin: true, pinned: true });
render(<CommentActions {...props} />);
expect(screen.getByText('Unpin')).toBeInTheDocument();
});

it.each([[{ currentUser: false, admin: true }], [{ currentUser: true, admin: true }]] as Partial<Props>[][])(
'should render "Delete" on all comments for admin',
(override) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,6 @@ import { CommentVotes } from './comment-votes';
import { StaticStore } from 'common/static-store';

describe('<CommentVote />', () => {
it('should render vote component', () => {
render(<CommentVotes id="1" vote={0} votes={0} controversy={0} />);
expect(screen.getByTitle('Vote up')).toBeVisible();
expect(screen.getByTitle('Vote down')).toBeVisible();
expect(screen.getByTitle('Votes score')).toBeVisible();
});

it('should render vote component with positive score', () => {
render(<CommentVotes id="1" vote={0} votes={1} controversy={0} />);
expect(screen.getByTitle('Votes score')).toBeVisible();
});
it('should render vote component with negative score', () => {
render(<CommentVotes id="1" vote={0} votes={-1} controversy={0} />);
expect(screen.getByTitle('Votes score')).toBeVisible();
});

it('should disable buttons after upvote when request is in progress', () => {
jest.spyOn(api, 'putCommentVote').mockImplementationOnce(jest.fn(() => new Promise(() => {})));
render(<CommentVotes id="1" vote={0} votes={10} controversy={0} />);
Expand All @@ -32,11 +16,6 @@ describe('<CommentVote />', () => {
expect(screen.getByTitle('Vote up')).toBeDisabled();
});

it('should disable upvote button when upvoted', () => {
render(<CommentVotes id="1" vote={1} votes={10} controversy={0} />);
expect(screen.getByTitle('Vote up')).toBeDisabled();
});

it('should disable downvote button when downvoted', () => {
render(<CommentVotes id="1" vote={-1} votes={10} controversy={0} />);
expect(screen.getByTitle('Vote down')).toBeDisabled();
Expand Down
60 changes: 0 additions & 60 deletions frontend/apps/remark42/app/components/comment/comment.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,30 +81,11 @@ describe('<Comment />', () => {
});

describe('verification', () => {
it('should render active verification icon', () => {
props.data.user.verified = true;
render(<CommentWithIntl {...props} />);
expect(screen.getByTitle('Verified user')).toBeVisible();
});

it('should not render verification icon', () => {
const props = getProps();
render(<CommentWithIntl {...props} />);
expect(screen.queryByTitle('Verified user')).not.toBeInTheDocument();
});

it('should render verification button for admin', () => {
props.user!.admin = true;
render(<CommentWithIntl {...props} />);
expect(screen.getByTitle('Toggle verification')).toBeVisible();
});

it('should render active verification icon for admin', () => {
props.user!.admin = true;
props.data.user.verified = true;
render(<CommentWithIntl {...props} />);
expect(screen.queryByTitle('Verified user')).toBeVisible();
});
});

describe('voting', () => {
Expand All @@ -114,10 +95,6 @@ describe('<Comment />', () => {
props = getProps();
});

it('should render vote component', () => {
render(<CommentWithIntl {...props} />);
expect(screen.getByTitle('Votes score')).toBeVisible();
});
it.each([
[
'when the comment is pinned',
Expand Down Expand Up @@ -175,11 +152,6 @@ describe('<Comment />', () => {
});
});

it('should render action buttons', () => {
render(<CommentWithIntl {...props} />);
expect(screen.getByText('Reply')).toBeVisible();
});

it.each([
[
'pinned',
Expand All @@ -205,38 +177,6 @@ describe('<Comment />', () => {
expect(screen.queryByTitle('Reply')).not.toBeInTheDocument();
});

it('should be editable', async () => {
StaticStore.config.edit_duration = 300;

props.repliesCount = 0;
props.user!.id = '100';
props.data.user.id = '100';
Object.assign(props.data, {
id: '101',
vote: 1,
time: Date.now(),
delete: false,
orig: 'test',
});

render(<CommentWithIntl {...props} />);
expect(screen.getByText('Edit')).toBeVisible();
});

it('should not be editable', () => {
StaticStore.config.edit_duration = 300;
Object.assign(props.data, {
user: props.user,
id: '100',
vote: 1,
time: new Date(new Date().getDate() - 300).toString(),
orig: 'test',
});

render(<CommentWithIntl {...props} />);
expect(screen.queryByRole('timer')).not.toBeInTheDocument();
});

it('toggles edit mode', async () => {
props = getProps();
props.repliesCount = 0;
Expand Down
Loading
Loading