Skip to content
Draft
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
58 changes: 58 additions & 0 deletions src/screens/SettingsScreen.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,61 @@ export const ClearDataDialogOpen: Story = {
).toBeInTheDocument();
},
};

/** Generated invite results visible with long invite code (issue #155 wrapping verification) */
export const InviteGenerated: Story = {
play: async () => {
const canvas = getCanvas();
const urlInput = await canvas.findByLabelText(
'Remote Archive URL',
undefined,
{ timeout: PLAY_TIMEOUT },
);
const tokenInput = await canvas.findByLabelText('Bearer Token', undefined, {
timeout: PLAY_TIMEOUT,
});

await userEvent.type(
urlInput,
'https://archive.example-very-long-subdomain-name-that-takes-many-chars.com',
);
await userEvent.type(
tokenInput,
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkphbmUgRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c',
);

const submitButton = await canvas.findByRole(
'button',
{ name: 'Generate Invite' },
{ timeout: PLAY_TIMEOUT },
);
await userEvent.click(submitButton);

// Verify the results section renders with generated invite data
await canvas.findByText('Results', undefined, { timeout: PLAY_TIMEOUT });
await canvas.findByText('Invite URL', undefined, {
timeout: PLAY_TIMEOUT,
});
await canvas.findByText('Invite Code', undefined, {
timeout: PLAY_TIMEOUT,
});

// Verify the generated invite URL contains the code in the <code> element
const inviteUrlCode = await canvas.findByText(/invite\?code=/, undefined, {
timeout: PLAY_TIMEOUT,
});
await expect(inviteUrlCode).toBeVisible();

// Verify the generated invite code is displayed (long value wraps)
// Scoped to the invite-code row to avoid matching the URL code element
const inviteCodeLabel = await canvas.findByText('Invite Code', undefined, {
timeout: PLAY_TIMEOUT,
});
const inviteCodeRow = inviteCodeLabel.closest('div')!;
const inviteCodeEl = inviteCodeRow.querySelector('code')!;
await expect(inviteCodeEl).toBeVisible();
await expect(inviteCodeEl.textContent).toMatch(
/v1\.mock-encrypted-invite-code-/,
);
},
};
4 changes: 2 additions & 2 deletions src/screens/SettingsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ export function SettingsScreen() {
{intl.formatMessage(_messages.inviteUrlLabel)}
</p>
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
<code className="flex-1 bg-surface-card border border-border rounded-input px-3 py-2 text-sm text-text truncate min-w-0">
<code className="flex-1 bg-surface-card border border-border rounded-input px-3 py-2 text-sm text-text break-words wrap-anywhere min-w-0">
{inviteUrl}
</code>
<Button
Expand All @@ -380,7 +380,7 @@ export function SettingsScreen() {
{intl.formatMessage(_messages.inviteCodeLabel)}
</p>
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
<code className="flex-1 bg-surface-card border border-border rounded-input px-3 py-2 text-sm text-text min-w-0">
<code className="flex-1 bg-surface-card border border-border rounded-input px-3 py-2 text-sm text-text break-words wrap-anywhere min-w-0">
{inviteCode}
</code>
<Button
Expand Down
11 changes: 10 additions & 1 deletion src/screens/stories/__mocks__/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,20 @@ export class InviteApiError extends Error {
}
}

const LONG_MOCK_INVITE_CODE =
'v1.mock-encrypted-invite-code-aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789-abcdefghijklmnopqrstuvwxyz0123456789-abcdefghijklmnopqrstuvwxyz0123456789-abcdefghijklmnopqrstuvwxyz0123456789';

export async function createEncryptedInvite(
_url: string,
_token: string,
length?: 'long' | 'short',
): Promise<{ code: string }> {
return { code: 'v1.mock-invite-code-for-storybook-testing' };
return {
code:
length === 'short'
? 'v1.mock-invite-code-for-storybook-testing'
: LONG_MOCK_INVITE_CODE,
};
}

export async function redeemEncryptedInvite(
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
68 changes: 68 additions & 0 deletions tests/unit/screens/SettingsScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,74 @@ describe('SettingsScreen', () => {
expect(screen.getByText('Copied!')).toBeInTheDocument();
});

describe('Long value wrapping (issue #155)', () => {
async function generateInvite(user: ReturnType<typeof userEvent.setup>) {
await user.type(
screen.getByLabelText('Remote Archive URL'),
'https://archive.example.com',
);
await user.type(screen.getByLabelText('Bearer Token'), 'my-secret-token');
await user.click(screen.getByRole('button', { name: 'Generate Invite' }));
await screen.findByText('Results');
}

it('Invite URL code wraps long values instead of truncating', async () => {
const user = userEvent.setup();
render(<SettingsScreen />);
await generateInvite(user);

const inviteUrlCode = await screen.findByText(
/\/invite\?code=mock-encrypted-code-/,
{ selector: 'code' },
);

// Must NOT use truncate (which hides the value behind an ellipsis)
expect(inviteUrlCode).not.toHaveClass('truncate');
// Must wrap long unbreakable tokens (base64url, URLs)
expect(inviteUrlCode).toHaveClass('break-words');
expect(inviteUrlCode).toHaveClass('wrap-anywhere');
// Flex child must be able to shrink below content size
expect(inviteUrlCode).toHaveClass('min-w-0');
// Full value remains visible and selectable (not hidden)
expect(inviteUrlCode.textContent).toMatch(
/^https?:\/\/[^/]+\/invite\?code=mock-encrypted-code-/,
);
});

it('Invite Code code wraps long unbreakable tokens', async () => {
const user = userEvent.setup();
render(<SettingsScreen />);
await generateInvite(user);

const inviteCode = await screen.findByText(/^mock-encrypted-code-/, {
selector: 'code',
});

expect(inviteCode).toHaveClass('break-words');
expect(inviteCode).toHaveClass('wrap-anywhere');
expect(inviteCode).toHaveClass('min-w-0');
// Full value remains visible (not hidden by truncation)
expect(inviteCode.textContent).toMatch(/^mock-encrypted-code-/);
});

it('Copy button still copies the full invite code value', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
navigator.clipboard.writeText = writeText;

const user = userEvent.setup();
render(<SettingsScreen />);
await generateInvite(user);

const copyButtons = screen.getAllByRole('button', { name: 'Copy' });
await user.click(copyButtons[1]!);

expect(writeText).toHaveBeenCalledTimes(1);
expect(writeText).toHaveBeenCalledWith(
expect.stringMatching(/^mock-encrypted-code-/),
);
});
});

describe('Backup & Restore', () => {
it('renders Backup & Restore section heading', () => {
render(<SettingsScreen />);
Expand Down
Loading