diff --git a/e2e/base-path.spec.ts b/e2e/base-path.spec.ts index 42b0baf26..517751b8e 100644 --- a/e2e/base-path.spec.ts +++ b/e2e/base-path.spec.ts @@ -29,6 +29,17 @@ test("production deployment behind a path-preserving reverse proxy", async ({ pa expect(new URL(oidcError.headers().location, baseURL).href).toBe(`${baseURL}${prefix}/login?error=oidc_config`); await page.goto(`${prefix}/login`); + await expect(page.locator('link[rel="manifest"]')).toHaveAttribute("href", `${prefix}/site.webmanifest`); + await expect(page.locator('link[rel="apple-touch-icon"]')).toHaveAttribute("href", `${prefix}/apple-touch-icon.png`); + const manifestResponse = await request.get(`${prefix}/site.webmanifest`); + expect(manifestResponse.status()).toBe(200); + const manifest = (await manifestResponse.json()) as { start_url: string; icons: { src: string }[] }; + expect(new URL(manifest.start_url, `${baseURL}${prefix}/site.webmanifest`).pathname).toBe(`${prefix}/`); + for (const icon of manifest.icons) { + expect((await request.get(new URL(icon.src, `${baseURL}${prefix}/site.webmanifest`).pathname)).status()).toBe(200); + } + expect((await request.get(`${prefix}/apple-touch-icon.png`)).status()).toBe(200); + await page.locator('input[type="email"]:visible').fill("user@libredb.org"); await page.locator('input[type="password"]:visible').fill("test-user"); await page.getByRole("button", { name: /sign in/i }).click(); diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 000000000..dddb1f0ba Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/site.webmanifest b/public/site.webmanifest new file mode 100644 index 000000000..24d2bfa47 --- /dev/null +++ b/public/site.webmanifest @@ -0,0 +1,20 @@ +{ + "name": "LibreDB Studio", + "short_name": "LibreDB", + "start_url": ".", + "display": "standalone", + "icons": [ + { + "src": "web-app-icon-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "web-app-icon-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#09090b", + "background_color": "#09090b" +} diff --git a/public/web-app-icon-192x192.png b/public/web-app-icon-192x192.png new file mode 100644 index 000000000..777d680cd Binary files /dev/null and b/public/web-app-icon-192x192.png differ diff --git a/public/web-app-icon-512x512.png b/public/web-app-icon-512x512.png new file mode 100644 index 000000000..7ec684b37 Binary files /dev/null and b/public/web-app-icon-512x512.png differ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 5b9c33afa..3d34fb732 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -39,13 +39,14 @@ export const metadata: Metadata = { description, images: [previewImage], }, + manifest: withBasePath("/site.webmanifest"), icons: { icon: [ { url: withBasePath("/favicon.ico?v=2"), sizes: "any" }, { url: withBasePath("/logo.svg?v=2"), type: "image/svg+xml" }, ], shortcut: withBasePath("/favicon.ico?v=2"), - apple: withBasePath("/favicon-32x32.png?v=2"), + apple: [{ url: withBasePath("/apple-touch-icon.png"), sizes: "180x180", type: "image/png" }], }, }; diff --git a/tests/components/RootLayout.test.tsx b/tests/components/RootLayout.test.tsx index b198eb6db..5cc38c391 100644 --- a/tests/components/RootLayout.test.tsx +++ b/tests/components/RootLayout.test.tsx @@ -71,6 +71,13 @@ describe("RootLayout", () => { expect(description).not.toMatch(/\d/); }); + test("links the web app manifest and iOS home-screen icon", () => { + expect(metadata.manifest).toBe("/site.webmanifest"); + expect(metadata.icons).toMatchObject({ + apple: [{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }], + }); + }); + test("renders children", () => { const { getByText } = render( diff --git a/tests/unit/web-app-manifest.test.ts b/tests/unit/web-app-manifest.test.ts new file mode 100644 index 000000000..5b6da418a --- /dev/null +++ b/tests/unit/web-app-manifest.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const PUBLIC_DIR = join(import.meta.dir, "../../public"); + +interface ManifestIcon { + src: string; + sizes: string; + type: string; +} + +interface WebManifest { + name: string; + short_name: string; + start_url: string; + display: string; + theme_color: string; + background_color: string; + icons: ManifestIcon[]; +} + +function pngDimensions(file: string): { width: number; height: number } { + const image = readFileSync(join(PUBLIC_DIR, file)); + expect(image.subarray(0, 8)).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); + return { width: image.readUInt32BE(16), height: image.readUInt32BE(20) }; +} + +describe("web app manifest", () => { + const manifest = JSON.parse(readFileSync(join(PUBLIC_DIR, "site.webmanifest"), "utf8")) as WebManifest; + + test("describes LibreDB Studio as an installable standalone app", () => { + expect(manifest).toMatchObject({ + name: "LibreDB Studio", + short_name: "LibreDB", + start_url: ".", + display: "standalone", + theme_color: "#09090b", + background_color: "#09090b", + }); + }); + + test("declares the two standard PNG app icon sizes", () => { + expect(manifest.icons).toEqual([ + { src: "web-app-icon-192x192.png", sizes: "192x192", type: "image/png" }, + { src: "web-app-icon-512x512.png", sizes: "512x512", type: "image/png" }, + ]); + }); + + test.each([ + ["web-app-icon-192x192.png", 192], + ["web-app-icon-512x512.png", 512], + ["apple-touch-icon.png", 180], + ])("%s is a valid square PNG with the advertised dimensions", (file, size) => { + expect(pngDimensions(file)).toEqual({ width: size, height: size }); + }); +});