Explorar o código

Allow frontend Docker mode to use `SERVER_LIST_URL` without a mounted `/servers.json` (#849)

* Initial plan

* Fix SERVER_LIST_URL frontend startup

Co-authored-by: sstidl <12804296+sstidl@users.noreply.github.com>

* test(e2e): serve remote server-list fixture with CORS

* test(e2e): reject malformed static-server paths

* fix: support SERVER_LIST_URL in classic frontend

* test: preserve existing mode test formatting

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sstidl <12804296+sstidl@users.noreply.github.com>
Co-authored-by: Stefan Stidl <sti-github@stidl.com>
Copilot hai 1 semana
pai
achega
bd8501ad13

+ 7 - 2
docker/entrypoint.sh

@@ -56,7 +56,7 @@ fi
 
 
 # Copy servers.json for stability page (frontend/dual modes)
-if [[ "$MODE" == "frontend" || "$MODE" == "dual" ]]; then
+if [[ "$MODE" == "frontend" || "$MODE" == "dual" ]] && [ -f /servers.json ]; then
   cp /servers.json /var/www/html/servers.json
 fi
 
@@ -91,6 +91,11 @@ if [[ "$MODE" == "frontend" || "$MODE" == "dual" ||  "$MODE" == "standalone" ]];
   if [ -f /servers.json ]; then
     echo "using mounted /servers.json for server-list.json"
     cp /servers.json /var/www/html/server-list.json
+  elif [ -n "$SERVER_LIST_URL" ]; then
+    echo "no /servers.json found, relying on SERVER_LIST_URL"
+  elif [ "$MODE" == "frontend" ]; then
+    echo "ERROR: /servers.json not found and SERVER_LIST_URL is not set" >&2
+    exit 1
   else
     echo "no /servers.json found, create one for local host"
     # generate config for just the local server
@@ -100,7 +105,7 @@ if [[ "$MODE" == "frontend" || "$MODE" == "dual" ||  "$MODE" == "standalone" ]];
     echo "using SERVER_LIST_URL for frontend server list"
     SERVER_LIST_URL_ESCAPED=$(printf '%s\n' "$SERVER_LIST_URL" | sed 's/[&/\\]/\\&/g; s/\$/\\$/g')
     sed -i "s/var SPEEDTEST_SERVERS = \"server-list.json\";/var SPEEDTEST_SERVERS = \"$SERVER_LIST_URL_ESCAPED\";/" /var/www/html/index-modern.html
-    sed -i "s/var SPEEDTEST_SERVERS = \\[/var SPEEDTEST_SERVERS = \"$SERVER_LIST_URL_ESCAPED\";\\n\\t\\t\\/\\*/" /var/www/html/index-classic.html
+    sed -i "/var SPEEDTEST_SERVERS = \\[/,/^[[:space:]]*];/c\\\t\tvar SPEEDTEST_SERVERS = \"$SERVER_LIST_URL_ESCAPED\";" /var/www/html/index-classic.html
     sed -i "s/var SPEEDTEST_SERVERS = \"server-list.json\";/var SPEEDTEST_SERVERS = \"$SERVER_LIST_URL_ESCAPED\";/" /var/www/html/stability.html
   fi
 

+ 1 - 1
playwright.config.js

@@ -19,7 +19,7 @@ module.exports = defineConfig({
   globalSetup: require.resolve('./tests/e2e/global-setup.js'),
   globalTeardown: require.resolve('./tests/e2e/global-teardown.js'),
   webServer: {
-    command: 'python3 -m http.server 18184 --bind 127.0.0.1 --directory .',
+    command: 'node tests/e2e/static-server.js',
     url: 'http://127.0.0.1:18184',
     reuseExistingServer: !process.env.CI,
   },

+ 13 - 0
tests/docker-compose-playwright.yml

@@ -78,6 +78,19 @@ services:
     ports:
       - "18182:8080"
 
+  frontend-remote:
+    build:
+      context: ..
+      dockerfile: Dockerfile
+    depends_on:
+      - backend-testpoint
+    environment:
+      - MODE=frontend
+      - WEBPORT=8080
+      - SERVER_LIST_URL=http://127.0.0.1:18184/tests/e2e/fixtures/servers-frontend-remote.json
+    ports:
+      - "18188:8080"
+
   dual:
     build:
       context: ..

+ 11 - 0
tests/e2e/fixtures/servers-frontend-remote.json

@@ -0,0 +1,11 @@
+[
+  {
+    "name": "Remote frontend backend",
+    "server": "http://127.0.0.1:18181/",
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "id": 1
+  }
+]

+ 1 - 0
tests/e2e/global-setup.js

@@ -71,5 +71,6 @@ module.exports = async () => {
   await waitForReady('standalone-alpine', 'http://127.0.0.1:18187/index.html', timeoutMs);
   await waitForReady('backend', 'http://127.0.0.1:18181/empty.php', timeoutMs);
   await waitForReady('frontend', 'http://127.0.0.1:18182/index-modern.html', timeoutMs);
+  await waitForReady('frontend-remote', 'http://127.0.0.1:18188/index-modern.html', timeoutMs);
   await waitForReady('dual', 'http://127.0.0.1:18183/index-modern.html', timeoutMs);
 };

+ 1 - 0
tests/e2e/helpers/env.js

@@ -3,6 +3,7 @@ const baseUrls = {
   backend: 'http://127.0.0.1:18181',
   frontend: 'http://127.0.0.1:18182',
   dual: 'http://127.0.0.1:18183',
+  frontendRemote: 'http://127.0.0.1:18188',
   standaloneNew: 'http://127.0.0.1:18185',
   standaloneApostrophe: 'http://127.0.0.1:18186',
   standaloneAlpine: 'http://127.0.0.1:18187',

+ 30 - 1
tests/e2e/modes.spec.js

@@ -1,8 +1,9 @@
 const { test, expect } = require('@playwright/test');
 const { baseUrls } = require('./helpers/env');
-const { modernStartButton } = require('./helpers/ui');
+const { modernStartButton, classicStartButton } = require('./helpers/ui');
 
 const defaultTagline = 'No Flash, No Java, No Websockets, No Bullsh*t';
+const frontendRemoteServerListUrl = 'http://127.0.0.1:18184/tests/e2e/fixtures/servers-frontend-remote.json';
 
 test.describe('Runtime mode smoke coverage', () => {
   test('standalone exposes UI and local backend endpoints', async ({ page, request }) => {
@@ -52,6 +53,34 @@ test.describe('Runtime mode smoke coverage', () => {
     await expect(page.locator('#selected-server')).not.toHaveText(/searching nearest server/i);
   });
 
+  test('frontend starts with SERVER_LIST_URL without requiring /servers.json', async ({ page, request }) => {
+    const index = await request.get(`${baseUrls.frontendRemote}/index-modern.html`);
+    expect(index.ok()).toBeTruthy();
+    await expect(await index.text()).toContain(frontendRemoteServerListUrl);
+
+    const stability = await request.get(`${baseUrls.frontendRemote}/stability.html`);
+    expect(stability.ok()).toBeTruthy();
+    await expect(await stability.text()).toContain(frontendRemoteServerListUrl);
+
+    const localBackendEndpoint = await request.get(`${baseUrls.frontendRemote}/backend/empty.php`);
+    expect(localBackendEndpoint.status()).toBe(404);
+
+    await page.goto(`${baseUrls.frontendRemote}/index-modern.html`);
+    await expect(modernStartButton(page)).toBeVisible();
+    await expect(page.locator('#selected-server')).toContainText('Remote frontend backend', { timeout: 10_000 });
+  });
+
+  test('default entrypoint loads the classic frontend with SERVER_LIST_URL', async ({ page }) => {
+    const pageErrors = [];
+    page.on('pageerror', (error) => pageErrors.push(error.message));
+
+    await page.goto(`${baseUrls.frontendRemote}/index.html`);
+    await page.waitForURL(/index-classic\.html/);
+    await expect(classicStartButton(page)).toBeVisible();
+    await expect(page.locator('#server')).toContainText('Remote frontend backend', { timeout: 10_000 });
+    expect(pageErrors).toEqual([]);
+  });
+
   test('dual combines frontend and local backend availability', async ({ page, request }) => {
     const serverList = await request.get(`${baseUrls.dual}/server-list.json`);
     expect(serverList.ok()).toBeTruthy();

+ 8 - 0
tests/e2e/static-repository-assets.spec.js

@@ -32,4 +32,12 @@ test.describe("Unmodified repository static assets", () => {
     const missing = await request.get(`${staticRepositoryUrl}/does-not-exist`);
     expect(missing.status()).toBe(404);
   });
+
+  test("returns 400 for malformed percent-encoded paths without stopping the server", async ({ request }) => {
+    const malformed = await request.get(`${staticRepositoryUrl}/%E0%A4%A`);
+    expect(malformed.status()).toBe(400);
+
+    const followingRequest = await request.get(`${staticRepositoryUrl}/settings.json`);
+    expect(followingRequest.status()).toBe(200);
+  });
 });

+ 57 - 0
tests/e2e/static-server.js

@@ -0,0 +1,57 @@
+const fs = require("node:fs");
+const http = require("node:http");
+const path = require("node:path");
+
+const root = process.cwd();
+const contentTypes = {
+  ".css": "text/css; charset=utf-8",
+  ".html": "text/html; charset=utf-8",
+  ".js": "text/javascript; charset=utf-8",
+  ".json": "application/json; charset=utf-8",
+  ".svg": "image/svg+xml",
+  ".woff2": "font/woff2"
+};
+
+http
+  .createServer((request, response) => {
+    response.setHeader("Access-Control-Allow-Origin", "*");
+
+    if (request.method === "OPTIONS") {
+      response.writeHead(204);
+      response.end();
+      return;
+    }
+
+    let pathname;
+    try {
+      const url = new URL(request.url, "http://127.0.0.1");
+      pathname = url.pathname === "/" ? "/index.html" : url.pathname;
+      pathname = decodeURIComponent(pathname);
+    } catch {
+      response.writeHead(400);
+      response.end();
+      return;
+    }
+
+    const file = path.resolve(root, `.${pathname}`);
+
+    if (!file.startsWith(`${root}${path.sep}`)) {
+      response.writeHead(403);
+      response.end();
+      return;
+    }
+
+    fs.readFile(file, (error, content) => {
+      if (error) {
+        response.writeHead(error.code === "ENOENT" ? 404 : 500);
+        response.end();
+        return;
+      }
+
+      response.writeHead(200, {
+        "Content-Type": contentTypes[path.extname(file)] || "application/octet-stream"
+      });
+      response.end(content);
+    });
+  })
+  .listen(18184, "127.0.0.1");