+
+
+
+
+
+
-
+
-
+
+
diff --git a/nginx.conf b/nginx.conf
new file mode 100644
index 0000000..d3a9a7d
--- /dev/null
+++ b/nginx.conf
@@ -0,0 +1,31 @@
+server {
+ listen 8080;
+ listen [::]:8080;
+ server_name _;
+
+ root /usr/share/nginx/html;
+ index index.html;
+
+ access_log off;
+ client_max_body_size 40m;
+ client_body_temp_path /tmp/client_temp;
+ error_page 405 =200 $uri;
+
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+
+ location = /upload {
+ add_header Cache-Control "no-store, no-cache, max-age=0, no-transform" always;
+ add_header X-Content-Type-Options "nosniff" always;
+ try_files /upload =404;
+ }
+
+ location = /downloading {
+ add_header Cache-Control "no-store, no-cache, max-age=0, no-transform" always;
+ try_files /downloading =404;
+ }
+
+ location / {
+ try_files $uri $uri/ =404;
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..9b13a9c
--- /dev/null
+++ b/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "openspeedtest-i18n",
+ "private": true,
+ "version": "2.5.4-i18n",
+ "description": "Static checks for the OpenSpeedTest internationalization layer",
+ "scripts": {
+ "test": "node tests/i18n.test.js && node tests/check-i18n.js"
+ },
+ "license": "MIT"
+}
diff --git a/tests/check-i18n.js b/tests/check-i18n.js
new file mode 100644
index 0000000..15de933
--- /dev/null
+++ b/tests/check-i18n.js
@@ -0,0 +1,113 @@
+"use strict";
+
+var assert = require("node:assert");
+var fs = require("node:fs");
+var path = require("node:path");
+var vm = require("node:vm");
+
+var root = path.join(__dirname, "..");
+var i18nSource = fs.readFileSync(path.join(root, "assets/js/i18n.js"), "utf8");
+var windowMock = {
+ navigator: { languages: ["en"] },
+ localStorage: { getItem: function () { return null; } },
+ location: { reload: function () {} }
+};
+var documentMock = {
+ documentElement: { setAttribute: function () {} },
+ getElementById: function () { return null; },
+ querySelectorAll: function () { return []; }
+};
+
+vm.runInNewContext(i18nSource, {
+ window: windowMock,
+ document: documentMock
+});
+
+var messages = windowMock.OpenSpeedTestI18n.messages.en;
+var files = [
+ path.join(root, "index.html"),
+ path.join(root, "assets/images/app.svg"),
+ path.join(root, "assets/js/app-2.5.4.js"),
+ path.join(root, "assets/js/i18n.js")
+];
+var usedKeys = {};
+var keyPatterns = [
+ /data-i18n(?:-title|-aria-label|-content)?="([^"]+)"/g,
+ /\.t\("([^"]+)"\)/g,
+ /translate\("([^"]+)"\)/g
+];
+
+files.forEach(function (file) {
+ var contents = fs.readFileSync(file, "utf8");
+ keyPatterns.forEach(function (pattern) {
+ var match;
+ while ((match = pattern.exec(contents)) !== null) {
+ usedKeys[match[1]] = true;
+ }
+ });
+});
+
+Object.keys(usedKeys).forEach(function (key) {
+ assert.ok(messages[key], "missing English dictionary key: " + key);
+ assert.ok(
+ windowMock.OpenSpeedTestI18n.messages["zh-CN"][key],
+ "missing Simplified Chinese dictionary key: " + key
+ );
+});
+
+var appSource = fs.readFileSync(path.join(root, "assets/js/app-2.5.4.js"), "utf8");
+assert.ok(!/Show\.showStatus\("[A-Za-z]/.test(appSource), "runtime status bypasses i18n");
+assert.ok(
+ !/oDoLiveSpeed\.el\.textContent = "Network Error"/.test(appSource),
+ "network error bypasses i18n"
+);
+
+var indexSource = fs.readFileSync(path.join(root, "index.html"), "utf8");
+assert.ok(
+ indexSource.indexOf("assets/js/app-2.5.4.js") !== -1,
+ "index.html must load the maintainable, unminified application source"
+);
+assert.ok(
+ indexSource.indexOf("app-2.5.4.min.js") === -1,
+ "index.html must not load the untranslated upstream minified bundle"
+);
+
+var svgSource = fs.readFileSync(path.join(root, "assets/images/app.svg"), "utf8");
+var untranslatedTitles = svgSource.match(/
]*data-i18n)[^>]*>[\s\S]*?<\/title>/g) || [];
+assert.deepStrictEqual(
+ untranslatedTitles,
+ ["OpenSpeedTest™️"],
+ "only the OpenSpeedTest brand title may bypass translation"
+);
+
+var untranslatedSvgText = (svgSource.match(/]*data-i18n)[^>]*>[\s\S]*?<\/text>/g) || [])
+ .filter(function (node) {
+ var textContent = node.replace(/<[^>]+>/g, "");
+ return /[A-Za-z]/.test(textContent) && node.indexOf('id="YourIP"') === -1;
+ });
+assert.deepStrictEqual(
+ untranslatedSvgText,
+ [],
+ "static alphabetic SVG labels must reference the language dictionary"
+);
+
+var nginxSource = fs.readFileSync(path.join(root, "nginx.conf"), "utf8");
+assert.ok(
+ /error_page\s+405\s+=200\s+\$uri;/.test(nginxSource),
+ "upload POST must be consumed before the static 405 response becomes 200"
+);
+assert.ok(
+ !/location\s*=\s*\/upload[\s\S]*?return\s+200/.test(nginxSource),
+ "upload location must not return before consuming the request body"
+);
+
+var dockerfileSource = fs.readFileSync(path.join(root, "Dockerfile"), "utf8");
+assert.ok(
+ /FROM nginxinc\/nginx-unprivileged:stable-alpine/.test(dockerfileSource),
+ "container must derive directly from the unprivileged Nginx image"
+);
+assert.ok(/RUN nginx -t/.test(dockerfileSource), "container build must validate Nginx");
+
+console.log(
+ "i18n static checks passed (" + Object.keys(usedKeys).length + " referenced keys)"
+);
diff --git a/tests/i18n.test.js b/tests/i18n.test.js
new file mode 100644
index 0000000..086585f
--- /dev/null
+++ b/tests/i18n.test.js
@@ -0,0 +1,95 @@
+"use strict";
+
+var assert = require("node:assert");
+var fs = require("node:fs");
+var path = require("node:path");
+var vm = require("node:vm");
+
+var source = fs.readFileSync(
+ path.join(__dirname, "..", "assets", "js", "i18n.js"),
+ "utf8"
+);
+
+function loadI18n(options) {
+ var storage = options.storage || {};
+ var reloadCount = 0;
+ var documentMock = {
+ documentElement: {
+ setAttribute: function () {}
+ },
+ getElementById: function () {
+ return null;
+ },
+ querySelectorAll: function () {
+ return [];
+ }
+ };
+ var windowMock = {
+ navigator: {
+ languages: options.languages || [],
+ language: options.language || ""
+ },
+ localStorage: {
+ getItem: function (key) {
+ return storage[key] || null;
+ },
+ setItem: function (key, value) {
+ storage[key] = value;
+ }
+ },
+ location: {
+ reload: function () {
+ reloadCount += 1;
+ }
+ }
+ };
+
+ vm.runInNewContext(source, {
+ window: windowMock,
+ document: documentMock
+ });
+
+ return {
+ api: windowMock.OpenSpeedTestI18n,
+ storage: storage,
+ reloadCount: function () {
+ return reloadCount;
+ }
+ };
+}
+
+var english = loadI18n({ languages: ["fr-FR"] });
+assert.strictEqual(english.api.getLocale(), "en");
+assert.strictEqual(english.api.t("action.start"), "Start");
+
+var simplifiedChinese = loadI18n({ languages: ["zh-CN", "en-US"] });
+assert.strictEqual(simplifiedChinese.api.getLocale(), "zh-CN");
+assert.strictEqual(simplifiedChinese.api.t("action.start"), "开始");
+
+var genericChinese = loadI18n({ languages: ["zh-Hans"] });
+assert.strictEqual(genericChinese.api.getLocale(), "zh-CN");
+
+var browserPreferenceOrder = loadI18n({ languages: ["en-US", "zh-CN"] });
+assert.strictEqual(browserPreferenceOrder.api.getLocale(), "en");
+
+var storedEnglish = loadI18n({
+ languages: ["zh-CN"],
+ storage: { "openspeedtest.locale": "en" }
+});
+assert.strictEqual(storedEnglish.api.getLocale(), "en");
+
+var manualSwitch = loadI18n({ languages: ["en-US"] });
+manualSwitch.api.setLocale("zh-CN");
+assert.strictEqual(manualSwitch.storage["openspeedtest.locale"], "zh-CN");
+assert.strictEqual(manualSwitch.reloadCount(), 1);
+
+assert.strictEqual(
+ simplifiedChinese.api.t("missing.translation.key"),
+ "missing.translation.key"
+);
+
+var englishKeys = Object.keys(english.api.messages.en).sort();
+var chineseKeys = Object.keys(english.api.messages["zh-CN"]).sort();
+assert.deepStrictEqual(chineseKeys, englishKeys);
+
+console.log("i18n behavior tests passed (" + englishKeys.length + " keys)");
diff --git a/tests/nginx-test.conf b/tests/nginx-test.conf
new file mode 100644
index 0000000..363bc4e
--- /dev/null
+++ b/tests/nginx-test.conf
@@ -0,0 +1,9 @@
+pid /tmp/openspeedtest-i18n-nginx-test.pid;
+error_log stderr notice;
+
+events {}
+
+http {
+ default_type application/octet-stream;
+ include nginx.conf;
+}