Add playwright E2E tests (#2174)

This commit is contained in:
Sam Freund
2025-12-04 22:25:48 -06:00
committed by GitHub
parent f821657d2b
commit 017b074eae
18 changed files with 421 additions and 46 deletions

View File

@@ -59,6 +59,49 @@ jobs:
- name: Build C++ examples
working-directory: photonlib-cpp-examples
run: ./gradlew build
playwright-tests:
name: "Playwright E2E tests"
runs-on: ubuntu-22.04
needs: [validation]
steps:
# Checkout code.
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Fetch tags
run: git fetch --tags --force
- name: Install Java 17
uses: actions/setup-java@v4
with:
java-version: 17
distribution: temurin
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install mrcal deps
run: sudo apt-get update && sudo apt-get install -y libcholmod3 liblapack3 libsuitesparseconfig5
- name: Setup tests
working-directory: photon-client
run: |
pnpm install
pnpm test-setup
- name: Prebuild Gradle
run: ./gradlew photon-targeting:build photon-core:build photon-server:build -x check
- name: Run Playwright tests
working-directory: photon-client
run: pnpm test
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: photon-client/playwright-report/
retention-days: 30
build-gradle:
name: "Gradle Build"
runs-on: ubuntu-22.04

8
.gitignore vendored
View File

@@ -146,5 +146,13 @@ networktables.json
photon-server/src/main/resources/web/*
node_modules
dist
dist-ssr
components.d.ts
photon-server/src/main/resources/web/index.html
# Playwright
photon-client/test-results/
photon-client/playwright-report/
photon-client/blob-report/
photon-client/playwright/.cache/
photon-client/playwright/.auth/

View File

@@ -1,8 +0,0 @@
node_modules
.DS_Store
dist
dist-ssr
# Editor directories and files
.idea
components.d.ts

View File

@@ -8,7 +8,7 @@ export default defineConfigWithVueTs(
vueTsConfigs.recommended,
skipFormattingConfig,
{
ignores: ["**/dist/**"]
ignores: ["**/dist/**", "playwright-report"]
},
{
//extends: ["js/recommended"],

View File

@@ -9,9 +9,13 @@
"build": "vite build",
"build-demo": "vite build --mode demo",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
"format": "prettier --write src/",
"format": "prettier --write src/ tests/",
"lint-ci": "eslint . --max-warnings 0 --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts",
"format-ci": "prettier --check src/"
"format-ci": "prettier --check src/",
"test": "playwright test",
"test-ui": "playwright test --ui",
"test-setup": "playwright install --with-deps"
},
"dependencies": {
"@fontsource/prompt": "^5.2.6",
@@ -28,6 +32,7 @@
},
"devDependencies": {
"@eslint/js": "^9.31.0",
"@playwright/test": "^1.56.1",
"@types/node": "^22.15.14",
"@types/three": "^0.178.0",
"@vitejs/plugin-vue": "^6.0.0",

View File

@@ -0,0 +1,83 @@
import { defineConfig, devices } from "@playwright/test";
import path from "path";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// import dotenv from 'dotenv';
// import path from 'path';
// dotenv.config({ path: path.resolve(__dirname, '.env') });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
globalSetup: "./tests/global-setup",
testDir: "./tests",
/* Run tests in files in parallel */
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: 1,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('')`. */
baseURL: "http://localhost:5800",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry"
},
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] }
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] }
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] }
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
{
name: "Microsoft Edge",
use: { ...devices["Desktop Edge"], channel: "msedge" }
}
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
webServer: {
command: process.platform == "win32" ? "" : "./" + "gradlew run",
url: "http://localhost:5800",
timeout: 300 * 1000,
reuseExistingServer: !process.env.CI,
cwd: path.normalize("../")
}
});

View File

@@ -45,6 +45,9 @@ importers:
'@eslint/js':
specifier: ^9.31.0
version: 9.31.0
'@playwright/test':
specifier: ^1.56.1
version: 1.56.1
'@types/node':
specifier: ^22.15.14
version: 22.15.14
@@ -430,6 +433,11 @@ packages:
resolution: {integrity: sha512-ROFF39F6ZrnzSUEmQQZUar0Jt4xVoP9WnDRdWwF4NNcXs3xBTLgBUDoOwW141y1jP+S8nahIbdxbFC7IShw9Iw==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
'@playwright/test@1.56.1':
resolution: {integrity: sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==}
engines: {node: '>=18'}
hasBin: true
'@rolldown/pluginutils@1.0.0-beta.19':
resolution: {integrity: sha512-3FL3mnMbPu0muGOCaKAhhFEYmqv9eTfPSJRJmANrCwtgK8VuxpsZDGK+m0LYAGoyO8+0j5uRe4PeyPDK1yA/hA==}
@@ -1019,6 +1027,11 @@ packages:
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
engines: {node: '>= 6'}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1247,6 +1260,16 @@ packages:
typescript:
optional: true
playwright-core@1.56.1:
resolution: {integrity: sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==}
engines: {node: '>=18'}
hasBin: true
playwright@1.56.1:
resolution: {integrity: sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==}
engines: {node: '>=18'}
hasBin: true
postcss-selector-parser@6.1.2:
resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
engines: {node: '>=4'}
@@ -1759,6 +1782,10 @@ snapshots:
'@pkgr/core@0.2.4': {}
'@playwright/test@1.56.1':
dependencies:
playwright: 1.56.1
'@rolldown/pluginutils@1.0.0-beta.19': {}
'@rollup/rollup-android-arm-eabi@4.40.2':
@@ -2399,6 +2426,9 @@ snapshots:
hasown: 2.0.2
mime-types: 2.1.35
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
@@ -2605,6 +2635,14 @@ snapshots:
optionalDependencies:
typescript: 5.8.3
playwright-core@1.56.1: {}
playwright@1.56.1:
dependencies:
playwright-core: 1.56.1
optionalDependencies:
fsevents: 2.3.2
postcss-selector-parser@6.1.2:
dependencies:
cssesc: 3.0.0

View File

@@ -224,6 +224,7 @@ const handleBulkImport = () => {
v-model="importVersion"
variant="underlined"
label="Model Version"
data-testid="import-version-select"
:items="
useSettingsStore().general.supportedBackends?.includes('RKNN')
? ['YOLOv5', 'YOLOv8', 'YOLO11']
@@ -324,7 +325,7 @@ const handleBulkImport = () => {
<th>Info</th>
</tr>
</thead>
<tbody>
<tbody data-testid="model-table">
<tr v-for="model in supportedModels" :key="model.modelPath">
<td>{{ model.nickname }}</td>
<td>{{ model.labels.join(", ") }}</td>

View File

@@ -0,0 +1,16 @@
import { test as base } from "@playwright/test";
import axios from "axios";
export const test = base.extend({
page: async ({ page }, use) => {
// Use the page in the test (no per-test backend reset here)
axios.defaults.baseURL = "http://localhost:5800/api/test";
await use(page);
}
});
test.beforeAll(async () => {
console.log("Running before all tests: Resetting backend state...");
await axios.post("http://localhost:5800/api/test/resetBackend");
await axios.post("http://localhost:5800/api/test/activateTestMode");
});

View File

@@ -0,0 +1,7 @@
async function globalSetup() {
// You can perform global setup tasks here, such as starting a server or setting environment variables
const path = await import("path");
process.env.TESTS_DIR = path.resolve(process.cwd());
}
export default globalSetup;

View File

@@ -0,0 +1,79 @@
import { expect } from "@playwright/test";
import { test } from "../fixtures";
import axios from "axios";
import path from "path";
const fakeModelName = "FAKE-MODEL";
const fakeLabels = "test, 1, woof";
const newModelName = "foo-bar";
const platforms = ["LINUX_RK3588_64", "LINUX_QCS6490"];
for (const platform of platforms) {
test.describe(`Platform: ${platform}`, () => {
test.beforeEach(async ({ page }) => {
await page.goto("/#/settings");
await axios.post("/override/platform", { platform: platform });
await page.reload();
});
test("testSettingsPage", async ({ page }) => {
if (platform.endsWith("RK3588_64")) {
await expect(page.getByRole("main")).toContainText("Linux AARCH 64-bit with RK3588");
} else if (platform.endsWith("QCS6490")) {
await expect(page.getByRole("main")).toContainText("Linux AARCH 64-bit with QCS6490");
}
await expect(page.getByText("Object Detection")).toBeVisible();
});
test("Upload model", async ({ page }) => {
const testsDir = process.env.TESTS_DIR;
if (!testsDir) {
throw new Error("TESTS_DIR is not set");
}
await page.getByRole("button", { name: "Import Model" }).click();
await page.getByRole("textbox", { name: "Labels" }).fill(fakeLabels);
await page.getByRole("spinbutton", { name: "Width" }).fill("640");
await page.getByRole("spinbutton", { name: "Height" }).fill("640");
await page.getByTestId("import-version-select").click();
await page.getByRole("option", { name: "YOLOv8" }).click();
const modelFile = platform.endsWith("RK3588_64") ? `${fakeModelName}.rknn` : `${fakeModelName}.tflite`;
await page
.getByRole("button", { name: "Model File Model File" })
.setInputFiles(path.join(testsDir, "tests/resources", modelFile));
await page.getByRole("button", { name: "Import Object Detection Model" }).click();
await page.goto("/#/settings");
const tableRow = page.getByTestId("model-table").locator("tr", { hasText: fakeModelName });
await expect(tableRow).toBeVisible();
await expect(tableRow).toContainText(fakeLabels);
});
test("Rename model", async ({ page }) => {
const tableRow = page.getByTestId("model-table").locator("tr", { hasText: fakeModelName });
await tableRow.getByRole("button", { name: "Rename Model" }).click();
await page.getByRole("textbox", { name: "New Name New Name" }).fill(newModelName);
await page.getByRole("button", { name: "Rename", exact: true }).click();
await page.reload();
const renamedRow = page.getByTestId("model-table").locator("tr", { hasText: newModelName });
await expect(renamedRow).toContainText(fakeLabels);
});
test("Delete model", async ({ page }) => {
const tableRow = page.getByTestId("model-table").locator("tr", { hasText: newModelName });
await tableRow.getByRole("button", { name: "Delete Model" }).click();
await page.getByRole("button", { name: "Delete model", exact: true }).click();
await page.reload();
const deletedRow = page.getByTestId("model-table").locator("tr", { hasText: newModelName });
await expect(deletedRow).toHaveCount(0);
});
});
}

View File

@@ -212,12 +212,23 @@ public class NeuralNetworkModelManager {
}
/**
* Returns the singleton instance of the NeuralNetworkModelManager
* Returns the singleton instance of the NeuralNetworkModelManager. Call getInstance() to use the
* default (no reset), or getInstance(true) to reset.
*
* @return The singleton instance
*/
public static NeuralNetworkModelManager getInstance() {
if (INSTANCE == null) {
return getInstance(false);
}
/**
* Returns the singleton instance of the NeuralNetworkModelManager, optionally resetting it.
*
* @param reset If true, resets the instance
* @return The singleton instance
*/
public static NeuralNetworkModelManager getInstance(boolean reset) {
if (INSTANCE == null || reset) {
INSTANCE = new NeuralNetworkModelManager();
}
return INSTANCE;

View File

@@ -74,6 +74,12 @@ public class RequestHandler {
private static final ObjectMapper kObjectMapper = new ObjectMapper();
private static boolean testMode = false;
public static void setTestMode(boolean isTestMode) {
testMode = isTestMode;
}
private record CommonCameraUniqueName(String cameraUniqueName) {}
public static void onSettingsImportRequest(Context ctx) {
@@ -662,35 +668,36 @@ public class RequestHandler {
ModelProperties modelProperties =
new ModelProperties(modelPath, nickname, labels, width, height, family, version);
ObjectDetector objDetector = null;
try {
objDetector =
switch (family) {
case RUBIK -> new RubikModel(modelProperties).load();
case RKNN -> new RknnModel(modelProperties).load();
};
} catch (RuntimeException e) {
ctx.status(400);
ctx.result("Failed to load object detection model: " + e.getMessage());
if (!testMode) {
ObjectDetector objDetector = null;
try {
Files.deleteIfExists(modelPath);
} catch (IOException ex) {
e.addSuppressed(ex);
}
objDetector =
switch (family) {
case RUBIK -> new RubikModel(modelProperties).load();
case RKNN -> new RknnModel(modelProperties).load();
};
} catch (RuntimeException e) {
ctx.status(400);
ctx.result("Failed to load object detection model: " + e.getMessage());
logger.error("Failed to load object detection model", e);
return;
} finally {
// this finally block will run regardless of what happens in try/catch
// please see https://docs.oracle.com/javase/tutorial/essential/exceptions/finally.html
// for a summary on how finally works
if (objDetector != null) {
objDetector.release();
try {
Files.deleteIfExists(modelPath);
} catch (IOException ex) {
e.addSuppressed(ex);
}
logger.error("Failed to load object detection model", e);
return;
} finally {
// this finally block will run regardless of what happens in try/catch
// please see https://docs.oracle.com/javase/tutorial/essential/exceptions/finally.html
// for a summary on how finally works
if (objDetector != null) {
objDetector.release();
}
}
}
ConfigManager.getInstance()
.getConfig()
.neuralNetworkPropertyManager()

View File

@@ -162,6 +162,13 @@ public class Server {
app.post("/api/objectdetection/rename", RequestHandler::onRenameObjectDetectionModelRequest);
app.post("/api/objectdetection/nuke", RequestHandler::onNukeObjectDetectionModelsRequest);
/* Testing API Events */
app.post("/api/test/resetBackend", TestRequestHandler::handleResetRequest);
app.post("/api/test/activateTestMode", TestRequestHandler::testMode);
app.post("/api/test/override/platform", TestRequestHandler::handlePlatformOverrideRequest);
app.start(port);
}

View File

@@ -0,0 +1,67 @@
/*
* Copyright (C) Photon Vision.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.photonvision.server;
import io.javalin.http.Context;
import org.photonvision.common.configuration.ConfigManager;
import org.photonvision.common.configuration.NeuralNetworkModelManager;
import org.photonvision.common.hardware.Platform;
import org.photonvision.common.logging.LogGroup;
import org.photonvision.common.logging.Logger;
import org.photonvision.common.util.file.JacksonUtils;
public class TestRequestHandler {
// Treat all 2XX calls as "INFO"
// Treat all 4XX calls as "ERROR"
// Treat all 5XX calls as "ERROR"
static Logger logger = new Logger(TestRequestHandler.class, LogGroup.WebServer);
public static void handleResetRequest(Context ctx) {
logger.info("Resetting Backend");
// Reset backend
ConfigManager.nukeConfigDirectory();
ConfigManager.getInstance().load();
}
private record PlatformOverrideRequest(Platform platform) {}
public static void handlePlatformOverrideRequest(Context ctx) {
try {
PlatformOverrideRequest request =
JacksonUtils.deserialize(ctx.body(), PlatformOverrideRequest.class);
Platform platform = request.platform();
logger.info("Overriding platform to: " + platform);
Platform.overridePlatform(platform);
NeuralNetworkModelManager.getInstance(true).extractModels();
NeuralNetworkModelManager.getInstance().discoverModels();
ctx.status(200);
} catch (Exception e) {
logger.error("Failed to parse platform override request: " + e.getMessage());
ctx.status(400).result("Invalid request");
}
}
public static void testMode(Context ctx) {
logger.info("Test mode activated");
RequestHandler.setTestMode(true);
ctx.status(200).result("Test mode activated");
}
}

View File

@@ -102,7 +102,8 @@ public enum Platform {
public final boolean isSupported;
// Set once at init, shouldn't be needed after.
private static final Platform currentPlatform = getCurrentPlatform();
private static Platform currentPlatform = getCurrentPlatform();
private static boolean override = false;
Platform(
String description,
@@ -119,6 +120,11 @@ public enum Platform {
this.nativeLibraryFolderName = nativeLibFolderName;
}
public static void overridePlatform(Platform platform) {
currentPlatform = platform;
override = true;
}
//////////////////////////////////////////////////////
// Public API
@@ -128,14 +134,15 @@ public enum Platform {
}
public static boolean isRK3588() {
return Platform.isOrangePi()
return currentPlatform == LINUX_RK3588_64
|| Platform.isOrangePi()
|| Platform.isCoolPi4b()
|| Platform.isRock5C()
|| fileHasText("/proc/device-tree/compatible", "rk3588");
}
public static boolean isQCS6490() {
return isRubik();
return currentPlatform == LINUX_QCS6490 || Platform.isRubik();
}
public static boolean isRaspberryPi() {
@@ -167,6 +174,11 @@ public enum Platform {
return runRobotFile.exists();
}
public static boolean isWindows() {
var p = getCurrentPlatform();
return (p == WINDOWS_32 || p == WINDOWS_64);
}
//////////////////////////////////////////////////////
// Debug info related to unknown platforms for debug help
@@ -177,6 +189,10 @@ public enum Platform {
private static final String UnknownDeviceModelString = "Unknown";
public static Platform getCurrentPlatform() {
if (override) {
return currentPlatform;
}
String OS_NAME;
if (Platform.OS_NAME != null) {
OS_NAME = Platform.OS_NAME;
@@ -319,9 +335,4 @@ public enum Platform {
return false;
}
}
public static boolean isWindows() {
var p = getCurrentPlatform();
return (p == WINDOWS_32 || p == WINDOWS_64);
}
}