fix web review validation and clipboard tests
This commit is contained in:
12
.superpowers/sdd/final-fix-4-report.md
Normal file
12
.superpowers/sdd/final-fix-4-report.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Final Fix 4 Report
|
||||||
|
|
||||||
|
- Rejected raw server values containing backslashes and malformed HTTP-like prefixes before protocol-less normalization.
|
||||||
|
- Added unit and Playwright coverage for malformed slash and backslash server inputs.
|
||||||
|
- Added CustomLinkChannel tests for deferred clipboard success, rejection feedback, and unavailable clipboard feedback.
|
||||||
|
|
||||||
|
Verification passed:
|
||||||
|
|
||||||
|
- `npm test -- --run` (8 files, 48 tests)
|
||||||
|
- `npm run build`
|
||||||
|
- `npx playwright test` (11 tests)
|
||||||
|
- `git diff --check`
|
||||||
@@ -25,7 +25,17 @@ test('login and project channel workbench flow', async ({ page }) => {
|
|||||||
await expect(page.getByText('Open external channel')).toBeVisible();
|
await expect(page.getByText('Open external channel')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const server of ['https://user:secret@example.com', 'http:////example.com', 'http://example..com', 'http://-bad.com']) {
|
for (const server of [
|
||||||
|
'https://user:secret@example.com',
|
||||||
|
'http:////example.com',
|
||||||
|
'http//example.com',
|
||||||
|
'https//example.com',
|
||||||
|
'http:/example.com',
|
||||||
|
'https:/example.com',
|
||||||
|
'http://example.com\\path',
|
||||||
|
'http://example..com',
|
||||||
|
'http://-bad.com',
|
||||||
|
]) {
|
||||||
test(`rejects malformed server address: ${server}`, async ({ page }) => {
|
test(`rejects malformed server address: ${server}`, async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
return { error: 'Enter a server address.' };
|
return { error: 'Enter a server address.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasWhitespace(value) || hasUnsupportedProtocol(value)) {
|
if (hasWhitespace(value) || value.includes('\\') || hasMalformedHttpProtocol(value) || hasUnsupportedProtocol(value)) {
|
||||||
return { error: 'Enter a valid HTTP or HTTPS server address.' };
|
return { error: 'Enter a valid HTTP or HTTPS server address.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +58,10 @@
|
|||||||
return /^[a-z][a-z\d+.-]*:(?!\d)/i.test(value);
|
return /^[a-z][a-z\d+.-]*:(?!\d)/i.test(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasMalformedHttpProtocol(value: string): boolean {
|
||||||
|
return /^https?:\/(?!\/)/i.test(value) || /^https?\/{2}(?!\/)/i.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
function hasCredentials(value: string): boolean {
|
function hasCredentials(value: string): boolean {
|
||||||
const authority = value.replace(/^https?:\/\//i, '').split(/[/?#]/, 1)[0];
|
const authority = value.replace(/^https?:\/\//i, '').split(/[/?#]/, 1)[0];
|
||||||
return authority.includes('@');
|
return authority.includes('@');
|
||||||
|
|||||||
@@ -76,6 +76,12 @@ describe('ServerLogin', () => {
|
|||||||
['http://', 'Enter a valid HTTP or HTTPS server address.'],
|
['http://', 'Enter a valid HTTP or HTTPS server address.'],
|
||||||
['ftp://example.com', 'Enter a valid HTTP or HTTPS server address.'],
|
['ftp://example.com', 'Enter a valid HTTP or HTTPS server address.'],
|
||||||
['http:////example.com', 'Enter a valid HTTP or HTTPS server address.'],
|
['http:////example.com', 'Enter a valid HTTP or HTTPS server address.'],
|
||||||
|
['http//example.com', 'Enter a valid HTTP or HTTPS server address.'],
|
||||||
|
['https//example.com', 'Enter a valid HTTP or HTTPS server address.'],
|
||||||
|
['http:/example.com', 'Enter a valid HTTP or HTTPS server address.'],
|
||||||
|
['https:/example.com', 'Enter a valid HTTP or HTTPS server address.'],
|
||||||
|
['http://example.com\\path', 'Enter a valid HTTP or HTTPS server address.'],
|
||||||
|
['example.com\\path', 'Enter a valid HTTP or HTTPS server address.'],
|
||||||
['http://example..com', 'Enter a valid HTTP or HTTPS server address.'],
|
['http://example..com', 'Enter a valid HTTP or HTTPS server address.'],
|
||||||
['http://-bad.com', 'Enter a valid HTTP or HTTPS server address.'],
|
['http://-bad.com', 'Enter a valid HTTP or HTTPS server address.'],
|
||||||
['http://bad-.com', 'Enter a valid HTTP or HTTPS server address.'],
|
['http://bad-.com', 'Enter a valid HTTP or HTTPS server address.'],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import '@testing-library/jest-dom/vitest';
|
import '@testing-library/jest-dom/vitest';
|
||||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte';
|
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte';
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import CustomLinkChannel from './CustomLinkChannel.svelte';
|
import CustomLinkChannel from './CustomLinkChannel.svelte';
|
||||||
|
|
||||||
const channel = {
|
const channel = {
|
||||||
@@ -14,6 +14,13 @@ const channel = {
|
|||||||
};
|
};
|
||||||
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
|
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
|
||||||
|
|
||||||
|
function setClipboard(writeText: Clipboard['writeText']) {
|
||||||
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
|
configurable: true,
|
||||||
|
value: { writeText } as Clipboard,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
if (originalClipboard) {
|
if (originalClipboard) {
|
||||||
@@ -24,6 +31,33 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('CustomLinkChannel', () => {
|
describe('CustomLinkChannel', () => {
|
||||||
|
it('shows copy success only after clipboard writing resolves', async () => {
|
||||||
|
let resolveWrite: (() => void) | undefined;
|
||||||
|
const writeText = vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
resolveWrite = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setClipboard(writeText);
|
||||||
|
render(CustomLinkChannel, { props: { channel } });
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: 'Copy URL' }));
|
||||||
|
|
||||||
|
expect(screen.queryByText('URL copied.')).not.toBeInTheDocument();
|
||||||
|
resolveWrite?.();
|
||||||
|
await waitFor(() => expect(screen.getByText('URL copied.')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a copy failure when clipboard writing rejects', async () => {
|
||||||
|
setClipboard(vi.fn().mockRejectedValue(new Error('Permission denied')));
|
||||||
|
render(CustomLinkChannel, { props: { channel } });
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: 'Copy URL' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Unable to copy URL.')).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
it('reports unavailable clipboard access without showing URL copied', async () => {
|
it('reports unavailable clipboard access without showing URL copied', async () => {
|
||||||
Reflect.deleteProperty(navigator, 'clipboard');
|
Reflect.deleteProperty(navigator, 'clipboard');
|
||||||
render(CustomLinkChannel, { props: { channel } });
|
render(CustomLinkChannel, { props: { channel } });
|
||||||
|
|||||||
Reference in New Issue
Block a user