feat: add ability to search and select icons from selfh.st (#1807)

This commit is contained in:
Elias Schneider
2026-10-04 20:40:41 +02:00
committed by GitHub
parent e7d77d8a9d
commit 1be9ba9991
30 changed files with 1506 additions and 247 deletions
+281 -2
View File
@@ -7,6 +7,20 @@ import { saveUnsavedChanges } from '../utils/unsaved-changes.util';
test.beforeEach(async () => await cleanupBackend());
// ICON_LIBRARY_URL is fixed when the backend starts, so other states are simulated in the public configuration
async function stubIconLibrary(page: Page, iconLibrary: 'custom' | 'disabled') {
await page.route('**/api/application-configuration', async (route) => {
const response = await route.fetch();
const config = (await response.json()) as { key: string; value: string }[];
await route.fulfill({
response,
json: config.map((entry) =>
entry.key === 'iconLibrary' ? { ...entry, value: iconLibrary } : entry
)
});
});
}
test.describe('Create OIDC client', () => {
async function createClientTest(page: Page, clientId?: string) {
const oidcClient = oidcClients.pingvinShare;
@@ -72,9 +86,7 @@ test('Edit OIDC client', async ({ page }) => {
await page.getByLabel('Name').fill('Nextcloud updated');
await page.getByLabel('Description').fill('Updated description');
await page.getByTestId('callback-url-1').first().fill('http://nextcloud-updated/auth/callback');
await page.locator('[role="tab"][data-value="light-logo"]').first().click();
await page.setInputFiles('#oidc-client-logo-light', 'resources/images/cloud-logo.png');
await page.locator('[role="tab"][data-value="dark-logo"]').first().click();
await page.setInputFiles('#oidc-client-logo-dark', 'resources/images/cloud-logo.png');
await page.getByLabel('Client Launch URL').fill(oidcClient.launchURL);
await saveUnsavedChanges(page);
@@ -84,6 +96,273 @@ test('Edit OIDC client', async ({ page }) => {
.then((res) => expect.soft(res.status()).toBe(200));
});
test('Upload OIDC client logo from the logo picker', async ({ page }) => {
const oidcClient = oidcClients.nextcloud;
await page.route('**/api/oidc/logo-presets*', (route) => route.fulfill({ json: [] }));
await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`);
const uploadLogo = async (variant: 'light' | 'dark') => {
const fileChooser = page.waitForEvent('filechooser');
await page.getByRole('button', { name: `Upload ${variant} logo` }).click();
await (await fileChooser).setFiles('resources/images/cloud-logo.png');
};
// The picker stays open and pre-selects the current logo tile, which shows each upload in its theme
await page.getByRole('button', { name: 'Choose logo' }).click();
await uploadLogo('dark');
const customTile = page.getByRole('option', { name: 'Current logo' });
const customTileImages = customTile.locator('img');
await expect(customTile).toHaveAttribute('aria-selected', 'true');
await expect(customTileImages.nth(1)).toHaveAttribute('src', /^blob:/);
await expect(customTileImages.nth(0)).not.toHaveAttribute('src', /^blob:/);
await uploadLogo('light');
await expect(customTileImages.nth(0)).toHaveAttribute('src', /^blob:/);
// The form only previews the logo of the current theme
await page.keyboard.press('Escape');
const preview = page.getByRole('img', { name: 'Nextcloud logo' });
await expect(preview).toHaveCount(1);
await expect(preview).toHaveAttribute('src', /^blob:/);
await saveUnsavedChanges(page);
for (const light of [true, false]) {
await page.request
.get(`/api/oidc/clients/${oidcClient.id}/logo?light=${light}`)
.then((res) => expect.soft(res.status()).toBe(200));
}
});
test('A single OIDC client logo shows in both themes', async ({ page }) => {
const oidcClient = oidcClients.immich;
await page.route('**/api/oidc/logo-presets*', (route) => route.fulfill({ json: [] }));
await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`);
// Upload only a dark logo while the page is in light mode
await page.getByRole('button', { name: 'Choose logo' }).click();
const fileChooser = page.waitForEvent('filechooser');
await page.getByRole('button', { name: 'Upload dark logo' }).click();
await (await fileChooser).setFiles('resources/images/cloud-logo.png');
// The light half of the current logo tile and the light preview fall back to the dark logo
const currentTileImages = page.getByRole('option', { name: 'Current logo' }).locator('img');
await expect(currentTileImages.nth(0)).toHaveAttribute('src', /^blob:/);
await expect(currentTileImages.nth(1)).toHaveAttribute('src', /^blob:/);
await page.keyboard.press('Escape');
await expect(page.getByRole('img', { name: `${oidcClient.name} logo` })).toHaveAttribute(
'src',
/^blob:/
);
// After saving, the light logo URL serves the dark logo and the client list shows it
await saveUnsavedChanges(page);
const response = await page.request.get(`/api/oidc/clients/${oidcClient.id}/logo?light=true`);
expect(response.status()).toBe(200);
await page.goto('/settings/admin/oidc-clients');
await expect(page.getByRole('img', { name: `${oidcClient.name} logo` })).toBeVisible();
});
test('Pick OIDC client logo from the icon library', async ({ page }) => {
const oidcClient = oidcClients.nextcloud;
const cdnBaseUrl = 'https://cdn.jsdelivr.net/gh/selfhst/icons@main';
const nextcloud = {
name: 'Nextcloud',
reference: 'nextcloud',
logoUrl: `${cdnBaseUrl}/svg/nextcloud.svg`,
darkLogoUrl: `${cdnBaseUrl}/svg/nextcloud-light.svg`
};
const otherApp = {
name: '2FAuth',
reference: '2fauth',
logoUrl: `${cdnBaseUrl}/svg/2fauth.svg`,
darkLogoUrl: null
};
// Stub the icon index and the CDN so the test doesn't depend on jsDelivr
// Nextcloud is only found by searching for it, like most icons that aren't at the start of the alphabet
const searchTerms: (string | null)[] = [];
await page.route('**/api/oidc/logo-presets*', async (route) => {
const search = new URL(route.request().url()).searchParams.get('search');
searchTerms.push(search);
await route.fulfill({ json: search?.startsWith('next') ? [nextcloud] : [otherApp] });
});
await page.route(`${cdnBaseUrl}/**`, (route) =>
route.fulfill({
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"/>'
})
);
// Answer the save with the stored client so the backend doesn't download the icons
await page.route(`**/api/oidc/clients/${oidcClient.id}`, async (route) => {
if (route.request().method() !== 'PUT') return route.fallback();
await route.fulfill({ response: await route.fetch({ method: 'GET' }) });
});
await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`);
await page.getByRole('button', { name: 'Choose logo' }).click();
// The search starts empty and lists the icon library
const searchInput = page.getByRole('combobox', { name: 'Search icons or paste an image URL' });
await expect(searchInput).toHaveValue('');
await expect(page.getByRole('option', { name: otherApp.name })).toBeVisible();
expect(searchTerms).toEqual(['']);
await expect(page.getByText('Icons by selfh.st')).toBeVisible();
await searchInput.fill('nextcloud');
await page.getByRole('option', { name: nextcloud.name, exact: true }).click();
await expect(page.getByRole('img', { name: 'Nextcloud logo' })).toHaveAttribute(
'src',
nextcloud.logoUrl
);
// Before saving, the reopened picker shows the picked icon as the selected current logo even though the search no longer finds it
await page.getByRole('button', { name: 'Choose logo' }).click();
await expect(page.getByRole('option', { name: otherApp.name })).toBeVisible();
const currentTile = page.getByRole('option', { name: 'Current logo' });
await expect(currentTile).toHaveAttribute('aria-selected', 'true');
await expect(currentTile.locator('img').nth(0)).toHaveAttribute('src', nextcloud.logoUrl);
await expect(currentTile.locator('img').nth(1)).toHaveAttribute('src', nextcloud.darkLogoUrl);
await page.keyboard.press('Escape');
const updateRequest = page.waitForRequest(
(req) => req.method() === 'PUT' && req.url().endsWith(`/api/oidc/clients/${oidcClient.id}`)
);
await saveUnsavedChanges(page);
const body = (await updateRequest).postDataJSON();
expect(body.logoUrl).toBe(nextcloud.logoUrl);
expect(body.darkLogoUrl).toBe(nextcloud.darkLogoUrl);
});
test('Paste an image URL into the OIDC client logo picker', async ({ page }) => {
const oidcClient = oidcClients.nextcloud;
const darkLogoUrl = 'https://example.com/nextcloud-dark.svg';
// Fail the test if a pasted URL is ever sent as an icon search
const searchTerms: (string | null)[] = [];
await page.route('**/api/oidc/logo-presets*', async (route) => {
searchTerms.push(new URL(route.request().url()).searchParams.get('search'));
await route.fulfill({ json: [] });
});
await page.route(darkLogoUrl, (route) =>
route.fulfill({
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"/>'
})
);
// Answer the save with the stored client so the backend doesn't download the image
await page.route(`**/api/oidc/clients/${oidcClient.id}`, async (route) => {
if (route.request().method() !== 'PUT') return route.fallback();
await route.fulfill({ response: await route.fetch({ method: 'GET' }) });
});
await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`);
// Each half of the URL tile replaces only its own variant
await page.getByRole('button', { name: 'Choose logo' }).click();
await page
.getByRole('combobox', { name: 'Search icons or paste an image URL' })
.fill(darkLogoUrl);
await page.getByRole('button', { name: 'Use as dark logo' }).click();
// The search resets and the current logo tile shows the URL in the dark half
await expect(
page.getByRole('combobox', { name: 'Search icons or paste an image URL' })
).toHaveValue('');
await expect(
page.getByRole('option', { name: 'Current logo' }).locator('img').nth(1)
).toHaveAttribute('src', darkLogoUrl);
await page.keyboard.press('Escape');
const updateRequest = page.waitForRequest(
(req) => req.method() === 'PUT' && req.url().endsWith(`/api/oidc/clients/${oidcClient.id}`)
);
await saveUnsavedChanges(page);
const body = (await updateRequest).postDataJSON();
expect(body.darkLogoUrl).toBe(darkLogoUrl);
expect(body.logoUrl).toBeFalsy();
expect(searchTerms).not.toContain(darkLogoUrl);
});
test('OIDC client logo picker stays fully visible when reopened', async ({ page }) => {
const presets = Array.from({ length: 30 }, (_, i) => ({
name: `App ${i}`,
reference: `app-${i}`,
logoUrl: `https://example.com/app-${i}.svg`,
darkLogoUrl: null
}));
await page.route('**/api/oidc/logo-presets*', (route) => route.fulfill({ json: presets }));
// A short viewport leaves too little room below the button for the picker
const viewportHeight = 760;
await page.setViewportSize({ width: 1100, height: viewportHeight });
await page.goto(`/settings/admin/oidc-clients/${oidcClients.nextcloud.id}`);
const button = page.getByRole('button', { name: 'Choose logo' });
const picker = page.locator('[data-slot="popover-content"]');
const expectPickerInViewport = async () => {
await expect(page.getByRole('option', { name: 'App 0' })).toBeVisible();
await expect(async () => {
const box = await picker.boundingBox();
expect(box!.y).toBeGreaterThanOrEqual(0);
expect(box!.y + box!.height).toBeLessThanOrEqual(viewportHeight);
}).toPass();
};
await button.click();
await expectPickerInViewport();
// Reopening with results still in state used to scroll the page towards the unpositioned popover
await page.getByRole('combobox', { name: 'Search icons or paste an image URL' }).fill('');
await page.keyboard.press('Escape');
await expect(picker).toHaveCount(0);
await button.click();
await expectPickerInViewport();
});
test('Custom icon library hides the selfh.st credit', async ({ page }) => {
await stubIconLibrary(page, 'custom');
// The credit is only shown next to icons, so the custom library has to return one
const preset = {
name: 'Custom App',
reference: 'custom-app',
logoUrl: 'https://example.com/custom-app.svg',
darkLogoUrl: null
};
await page.route('**/api/oidc/logo-presets*', (route) => route.fulfill({ json: [preset] }));
await page.goto(`/settings/admin/oidc-clients/${oidcClients.nextcloud.id}`);
await page.getByRole('button', { name: 'Choose logo' }).click();
await expect(page.getByRole('option', { name: preset.name })).toBeVisible();
await expect(page.getByText('Icons by selfh.st')).toHaveCount(0);
});
test('Disabled icon library only accepts image URLs', async ({ page }) => {
await stubIconLibrary(page, 'disabled');
// Fail the test if the picker searches the disabled icon library
let searched = false;
await page.route('**/api/oidc/logo-presets*', async (route) => {
searched = true;
await route.fulfill({ json: [] });
});
await page.goto(`/settings/admin/oidc-clients/${oidcClients.nextcloud.id}`);
await page.getByRole('button', { name: 'Choose logo' }).click();
await page
.getByRole('combobox', { name: 'Paste an image URL' })
.fill('https://example.com/logo.svg');
await expect(page.getByRole('button', { name: 'Use as light logo' })).toBeVisible();
await expect(page.getByText('Icons by selfh.st')).toHaveCount(0);
expect(searched).toBe(false);
});
test('Displays OIDC client endpoints from discovery configuration', async ({ page }) => {
const oidcConfiguration = {
issuer: 'https://id.example.com',