From e105e9ef2df3ae21d635ba96b770fe4bcb36bb07 Mon Sep 17 00:00:00 2001 From: Tobias Bleckert Date: Wed, 30 Sep 2026 12:21:55 +0200 Subject: [PATCH 1/4] Fetch and display the TLD URL in test results --- e2e/TldUrl.e2e-spec.ts | 131 ++++++++++++++++++ e2e/interceptors/mock.interceptor.ts | 4 + src/lib/client.ts | 8 ++ .../components/DomainTest/ResultInfo.svelte | 106 +++++++++----- 4 files changed, 212 insertions(+), 37 deletions(-) create mode 100644 e2e/TldUrl.e2e-spec.ts diff --git a/e2e/TldUrl.e2e-spec.ts b/e2e/TldUrl.e2e-spec.ts new file mode 100644 index 00000000..10c7ee7a --- /dev/null +++ b/e2e/TldUrl.e2e-spec.ts @@ -0,0 +1,131 @@ +import type { Page, Request, Route } from '@playwright/test'; +import { test, expect } from './global-setup'; + +const resultPath = '/en/result/226f6d4f44ae3f80/'; + +async function mockTldUrl(page: Page, handle: (route: Route) => Promise) { + await page.route(/\/api$/, async (route) => { + if (route.request().postDataJSON()?.method === 'get_tld_url') { + await handle(route); + } else { + await route.fallback(); + } + }); +} + +async function fulfillTldUrl(route: Route, result: { url?: string }) { + await route.fulfill({ + json: { jsonrpc: '2.0', id: route.request().postDataJSON().id, result }, + }); +} + +async function expectResults(page: Page) { + await expect( + page.getByRole('heading', { name: 'Test result for results.afNiC.Fr' }), + ).toBeVisible(); + await expect(page.locator('.zm-result')).toBeVisible(); +} + +test.describe('TLD URL on test results', () => { + // Clipboard contents are shared across browser contexts. + test.describe.configure({ mode: 'serial' }); + + for (const url of ['https://www.afnic.fr/', 'http://www.afnic.fr/']) { + test(`displays and copies ${url}`, async ({ page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + await mockTldUrl(page, (route) => fulfillTldUrl(route, { url })); + const requestPromise = page.waitForRequest( + (request) => request.postDataJSON()?.method === 'get_tld_url', + ); + + await page.goto(resultPath); + const request = await requestPromise; + expect(request.postDataJSON().params).toEqual({ + domain: 'results.afNiC.Fr', + }); + await expectResults(page); + + const button = page.locator('#zmTLDURLButton'); + const dialog = page.locator('#copyTLDURLDialog'); + await expect(button).toBeVisible(); + await expect(dialog).toBeHidden(); + await button.click(); + await expect(dialog).toBeVisible(); + await expect(dialog.locator('input[name="url"]')).toHaveValue(url); + await dialog.locator('.zm-copy').click(); + await expect(dialog.locator('.zm-copy')).toHaveAttribute('aria-label', 'Copied'); + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(url); + }); + } + + for (const [description, result] of [ + ['omitted', {}], + ['empty', { url: '' }], + ] as const) { + test(`hides the button when the URL is ${description}`, async ({ page }) => { + await mockTldUrl(page, (route) => fulfillTldUrl(route, result)); + const responsePromise = page.waitForResponse( + (response) => response.request().postDataJSON()?.method === 'get_tld_url', + ); + + await page.goto(resultPath); + await (await responsePromise).finished(); + await expectResults(page); + await expect(page.locator('#zmTLDURLButton')).toBeHidden(); + }); + } + + for (const failure of ['RPC error', 'network error']) { + test(`keeps results visible after a ${failure}`, async ({ page }) => { + await mockTldUrl(page, async (route) => { + if (failure === 'network error') { + await route.abort('failed'); + } else { + await route.fulfill({ + json: { + jsonrpc: '2.0', + id: route.request().postDataJSON().id, + error: { code: -32601, message: 'Method not found' }, + }, + }); + } + }); + const isTldUrlRequest = (request: Request) => + request.postDataJSON()?.method === 'get_tld_url'; + const requestPromise = + failure === 'network error' + ? page.waitForEvent('requestfailed', isTldUrlRequest) + : page.waitForEvent('requestfinished', isTldUrlRequest); + + await page.goto(resultPath); + await requestPromise; + await expectResults(page); + await expect(page.locator('#zmTLDURLButton')).toBeHidden(); + }); + } + + test('shows results while waiting for the TLD URL', async ({ page }) => { + let releaseRequest!: () => void; + const pending = new Promise((resolve) => { + releaseRequest = resolve; + }); + await mockTldUrl(page, async (route) => { + await pending; + await fulfillTldUrl(route, { url: 'https://www.afnic.fr/' }); + }); + const requestPromise = page.waitForRequest( + (request) => request.postDataJSON()?.method === 'get_tld_url', + ); + + try { + await page.goto(resultPath, { waitUntil: 'domcontentloaded' }); + await requestPromise; + await expectResults(page); + await expect(page.locator('#zmTLDURLButton')).toBeHidden(); + } finally { + releaseRequest(); + } + + await expect(page.locator('#zmTLDURLButton')).toBeVisible(); + }); +}); diff --git a/e2e/interceptors/mock.interceptor.ts b/e2e/interceptors/mock.interceptor.ts index 1d69fa1d..35c5ef10 100644 --- a/e2e/interceptors/mock.interceptor.ts +++ b/e2e/interceptors/mock.interceptor.ts @@ -957,6 +957,10 @@ const urls: MockEndpoint[] = [ * Find a matching mock response for the given request */ export function findMockResponse(url: string, method: string, body: any): any | null { + if (url.endsWith('/api') && method === 'POST' && body?.method === 'get_tld_url') { + return { jsonrpc: '2.0', id: body.id, result: {} }; + } + for (const element of urls) { // Don't compare client info const requestParams = { ...body?.params }; diff --git a/src/lib/client.ts b/src/lib/client.ts index b26dc262..1ce718ac 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -120,6 +120,14 @@ export async function getTestResults(testId: string): Promise { return rpc('get_test_results', { id: testId, language: language() }, false); } +export type TldUrlData = { + url?: string; +}; + +export async function getTldUrl(domain: string): Promise { + return rpc('get_tld_url', { domain }, false); +} + export type ParentZoneData = { ds_list: DSInfo[]; ns_list: Nameservers[]; diff --git a/src/lib/components/DomainTest/ResultInfo.svelte b/src/lib/components/DomainTest/ResultInfo.svelte index 3e93dcd9..26753c15 100644 --- a/src/lib/components/DomainTest/ResultInfo.svelte +++ b/src/lib/components/DomainTest/ResultInfo.svelte @@ -1,5 +1,9 @@
@@ -139,45 +166,50 @@ >
-
- - + {/if}
-
+
@@ -296,11 +311,13 @@ > @@ -415,3 +432,10 @@
+ + diff --git a/src/lib/components/Input/Input.svelte b/src/lib/components/Input/Input.svelte index b8d61bbe..9115d574 100644 --- a/src/lib/components/Input/Input.svelte +++ b/src/lib/components/Input/Input.svelte @@ -11,6 +11,7 @@ onInput?: (event: Event & { currentTarget: EventTarget & HTMLInputElement }) => void; class?: string; matchContentWidth?: boolean; + autoScroll?: boolean; error?: string | null | false; [x: string]: unknown; }; @@ -27,18 +28,66 @@ onInput: onInputCb, class: className = '', matchContentWidth = false, + autoScroll = false, error: defaultError = null, ...restProps }: Props = $props(); let inputWidth = $state(0); let error = $state(defaultError); + let input: HTMLInputElement; + let animationFrame: number | undefined; function onInput(event: Event & { currentTarget: EventTarget & HTMLInputElement }) { error = false; onInputCb?.(event); } + function stopAutoScroll() { + if (animationFrame !== undefined) { + cancelAnimationFrame(animationFrame); + animationFrame = undefined; + } + } + + function startAutoScroll() { + stopAutoScroll(); + + if ( + !autoScroll || + !input || + input.scrollWidth <= input.clientWidth || + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ) { + return; + } + + const maxScroll = input.scrollWidth - input.clientWidth; + const pause = 1200; + const travel = (maxScroll / 20) * 1000; + const cycle = pause * 2 + travel * 2; + const startedAt = performance.now(); + + const animate = (now: number) => { + const elapsed = (now - startedAt) % cycle; + let scrollLeft = 0; + + if (elapsed > pause && elapsed <= pause + travel) { + scrollLeft = ((elapsed - pause) / travel) * maxScroll; + } else if (elapsed > pause + travel && elapsed <= pause * 2 + travel) { + scrollLeft = maxScroll; + } else if (elapsed > pause * 2 + travel) { + scrollLeft = + maxScroll - ((elapsed - pause * 2 - travel) / travel) * maxScroll; + } + + input.scrollLeft = scrollLeft; + animationFrame = requestAnimationFrame(animate); + }; + + animationFrame = requestAnimationFrame(animate); + } + $effect(() => { error = defaultError; @@ -60,13 +109,30 @@ }, 0); } }); + + $effect(() => { + if (!autoScroll || !input) { + return; + } + + const observer = new ResizeObserver(startAutoScroll); + observer.observe(input); + startAutoScroll(); + + return () => { + observer.disconnect(); + stopAutoScroll(); + }; + }); {#if label} {/if} - + input !== document.activeElement && startAutoScroll()} onblur={startAutoScroll} + style:min-width="0" style:width={matchContentWidth ? `${inputWidth}px` : undefined} {...restProps} /> {#if error}
{error}
{/if} diff --git a/src/lib/styles/components/copy.css b/src/lib/styles/components/copy.css index 199bc669..5463a22f 100644 --- a/src/lib/styles/components/copy.css +++ b/src/lib/styles/components/copy.css @@ -11,7 +11,9 @@ transition: background-color 0.2s ease; font-size: var(--button-font-size); - &:hover { + &:hover, + &:focus { background: var(--color-palette-main-90); + color: var(--color-palette-white); } } diff --git a/src/themes/default/styles/utilities/font.css b/src/themes/default/styles/utilities/font.css new file mode 100644 index 00000000..361daddc --- /dev/null +++ b/src/themes/default/styles/utilities/font.css @@ -0,0 +1 @@ +.u-font-size-medium { font-size: var(--font-xs); } diff --git a/src/themes/default/styles/utilities/manifest.css b/src/themes/default/styles/utilities/manifest.css index d905f5b3..c6a69ee0 100644 --- a/src/themes/default/styles/utilities/manifest.css +++ b/src/themes/default/styles/utilities/manifest.css @@ -1,3 +1,4 @@ @import 'align.css'; @import 'hide.css'; @import 'margin.css'; +@import 'font.css'; diff --git a/src/themes/default/styles/utilities/margin.css b/src/themes/default/styles/utilities/margin.css index fa37492a..cdcbac5c 100644 --- a/src/themes/default/styles/utilities/margin.css +++ b/src/themes/default/styles/utilities/margin.css @@ -30,3 +30,7 @@ margin-left: auto; margin-right: auto; } + +.u-m-b-1 { + margin-bottom: var(--spacing-xs); +} From d23d7bcc1fccfd0d310c4eb49547670920e96691 Mon Sep 17 00:00:00 2001 From: Tobias Bleckert Date: Tue, 20 Oct 2026 13:04:00 +0200 Subject: [PATCH 4/4] Display TLD info on result URL button --- e2e/TldUrl.e2e-spec.ts | 11 ++++++++--- src/lib/client.ts | 1 + src/lib/components/DomainTest/ResultInfo.svelte | 13 +++++++++++-- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/e2e/TldUrl.e2e-spec.ts b/e2e/TldUrl.e2e-spec.ts index 67b19640..27b3b72e 100644 --- a/e2e/TldUrl.e2e-spec.ts +++ b/e2e/TldUrl.e2e-spec.ts @@ -18,7 +18,9 @@ async function mockTldUrl(page: Page, handle: (route: Route) => Promise) { async function fulfillRpc( route: Route, - payload: { result: { url?: string } } | { error: { code: number; message: string } }, + payload: + | { result: { tld?: string; url?: string } } + | { error: { code: number; message: string } }, ) { await route.fulfill({ json: { jsonrpc: '2.0', id: route.request().postDataJSON().id, ...payload }, @@ -36,7 +38,9 @@ test.describe('TLD URL on test results', () => { for (const url of ['https://www.afnic.fr/', 'http://www.afnic.fr/']) { test(`displays and copies ${url}`, async ({ page, context }) => { await context.grantPermissions(['clipboard-read', 'clipboard-write']); - await mockTldUrl(page, (route) => fulfillRpc(route, { result: { url } })); + await mockTldUrl(page, (route) => + fulfillRpc(route, { result: { tld: 'fr', url } }), + ); const requestPromise = page.waitForRequest(isTldUrlRequest); await page.goto(resultPath); @@ -49,6 +53,7 @@ test.describe('TLD URL on test results', () => { const button = page.locator('#zmTLDURLButton'); const dialog = page.locator('#copyTLDURLDialog'); await expect(button).toBeVisible(); + await expect(button).toHaveText('.fr Info'); await expect(dialog).toBeHidden(); await button.click(); await expect(dialog).toBeVisible(); @@ -58,7 +63,7 @@ test.describe('TLD URL on test results', () => { await expect(openLink).toHaveAttribute('href', url); await expect(openLink).toHaveAttribute('target', '_blank'); await expect(openLink).toHaveAttribute('rel', 'noopener noreferrer'); - const copyButton = dialog.getByRole('button', { name: 'Copy to clipboard' }); + const copyButton = dialog.getByRole('button'); await copyButton.click(); await expect(copyButton).toHaveAttribute('aria-label', 'Copied'); expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(url); diff --git a/src/lib/client.ts b/src/lib/client.ts index 1ce718ac..5c07db86 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -121,6 +121,7 @@ export async function getTestResults(testId: string): Promise { } export type TldUrlData = { + tld?: string; url?: string; }; diff --git a/src/lib/components/DomainTest/ResultInfo.svelte b/src/lib/components/DomainTest/ResultInfo.svelte index 4e91d779..c3771014 100644 --- a/src/lib/components/DomainTest/ResultInfo.svelte +++ b/src/lib/components/DomainTest/ResultInfo.svelte @@ -128,6 +128,8 @@ } let tldUrl = $state(''); + let tld = $state(''); + const shortTld = $derived(tld.length > 7 ? `${tld.slice(0, 7)}...` : tld); // Depend on the domain only, so a new result object for the same domain // doesn't refetch the URL. const domain = $derived(data.params.domain); @@ -135,12 +137,14 @@ $effect(() => { let active = true; tldUrl = ''; + tld = ''; showURL = false; getTldUrl(domain) - .then(({ url }) => { + .then(({ url, tld: resultTld }) => { if (active) { tldUrl = url ?? ''; + tld = resultTld ?? ''; } }) .catch(() => { @@ -176,6 +180,7 @@ type="button" aria-controls="copyTLDURLDialog" aria-expanded={showURL} + title={tld ? `.${tld} ${m.info()}` : undefined} onclick={() => { showURL = !showURL; showExport = false; @@ -184,7 +189,11 @@ id="zmTLDURLButton" > - {m.tldURL()} + {#if tld} + .{shortTld} {m.info()} + {:else} + {m.tldURL()} + {/if}