diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 7758195314..e0091ec6c3 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -96,15 +96,28 @@ jobs: E2E_NEXUS_PREMIUM_USER_PASSWORD: ${{ secrets.E2E_NEXUS_PREMIUM_USER_PASSWORD }} run: xvfb-run pnpm e2e + # The test steps run with continue-on-error so flaky tests never block + # PRs, but that also masks hard failures (e.g. the app build aborting + # before Playwright starts). A missing report directory is the signature + # of such a failure - surface it explicitly instead of letting the + # encrypt step below trip over the missing directory. + - name: Verify the test run produced a report + shell: bash + run: | + if [ ! -d ./packages/e2e/playwright-report ]; then + echo "::error::No Playwright report was produced - the build or test runner failed before executing any tests. Check the 'Run E2E tests' step log for the real error." + exit 1 + fi + - name: Encrypt test report - if: always() + if: always() && hashFiles('packages/e2e/playwright-report/**') != '' shell: bash env: E2E_ARTIFACT_PASSWORD: ${{ secrets.E2E_ARTIFACT_PASSWORD }} run: 7z a -p"$E2E_ARTIFACT_PASSWORD" -mhe=on playwright-report.7z ./packages/e2e/playwright-report/ - name: Upload test report - if: always() + if: always() && hashFiles('packages/e2e/playwright-report/**') != '' uses: actions/upload-artifact@v7 with: name: e2e-report-${{ matrix.os-name }} @@ -113,14 +126,14 @@ jobs: if-no-files-found: error - name: Encrypt test results - if: failure() + if: failure() && hashFiles('packages/e2e/test-results/**') != '' shell: bash env: E2E_ARTIFACT_PASSWORD: ${{ secrets.E2E_ARTIFACT_PASSWORD }} run: 7z a -p"$E2E_ARTIFACT_PASSWORD" -mhe=on test-results.7z ./packages/e2e/test-results/ - name: Upload test results - if: failure() + if: failure() && hashFiles('packages/e2e/test-results/**') != '' uses: actions/upload-artifact@v7 with: name: e2e-results-${{ matrix.os-name }} diff --git a/packages/e2e/src/helpers/dialogs.ts b/packages/e2e/src/helpers/dialogs.ts new file mode 100644 index 0000000000..633b265231 --- /dev/null +++ b/packages/e2e/src/helpers/dialogs.ts @@ -0,0 +1,10 @@ +import type { ElectronApplication } from "@playwright/test"; + +export async function stubOpenDialog( + vortexApp: ElectronApplication, + filePath: string, +): Promise { + await vortexApp.evaluate(({ dialog }, path) => { + dialog.showOpenDialog = () => Promise.resolve({ canceled: false, filePaths: [path] }); + }, filePath); +} diff --git a/packages/e2e/src/helpers/games.ts b/packages/e2e/src/helpers/games.ts index c16ae1f089..83a51ea574 100644 --- a/packages/e2e/src/helpers/games.ts +++ b/packages/e2e/src/helpers/games.ts @@ -4,6 +4,7 @@ import { setupFakeGame, GAME_CONFIGS } from "../fixtures/game-setup/fake-game"; import { test } from "../fixtures/vortex-app"; import { GamesPage } from "../selectors/games"; import { NavBar } from "../selectors/navbar"; +import { stubOpenDialog } from "./dialogs"; import { Timeouts } from "./timeouts"; // VORTEX_E2E=1 disables automatic discovery, so all games go through the @@ -30,9 +31,7 @@ export async function manageGame( await expect(navbar.gamesLink).toBeVisible(); await navbar.gamesLink.click(); - await electronApp.evaluate(({ dialog }, gamePath) => { - dialog.showOpenDialog = () => Promise.resolve({ canceled: false, filePaths: [gamePath] }); - }, fakeGame.gamePath); + await stubOpenDialog(electronApp, fakeGame.gamePath); const row = gamesPage.gameRow(gameName); await expect(row).toBeVisible({ timeout: Timeouts.NETWORK }); @@ -43,9 +42,12 @@ export async function manageGame( await expect(manageButton).toBeVisible(); await manageButton.click(); - const continueButton = vortexWindow.getByRole("button", { name: "Continue" }); - await expect(continueButton).toBeVisible(); - await continueButton.click(); + await expect(gamesPage.notDiscoveredDialog).toBeVisible(); + await expect(gamesPage.notDiscoveredDialog).toContainText( + "hasn't been automatically discovered", + ); + await gamesPage.continueButton.click(); + await expect(gamesPage.notDiscoveredDialog).toBeHidden(); await expect(navbar.modsLink).toBeVisible({ timeout: Timeouts.NETWORK }); }); diff --git a/packages/e2e/src/selectors/games.ts b/packages/e2e/src/selectors/games.ts index 81631bb028..0f43345dc0 100644 --- a/packages/e2e/src/selectors/games.ts +++ b/packages/e2e/src/selectors/games.ts @@ -2,9 +2,20 @@ import type { Locator, Page } from "@playwright/test"; export class GamesPage { readonly page: Page; + readonly managedSection: Locator; + readonly unmanagedSection: Locator; + readonly notDiscoveredDialog: Locator; + readonly continueButton: Locator; constructor(page: Page) { this.page = page; + this.managedSection = page.locator(".panel").filter({ hasText: /Managed \(/ }); + this.unmanagedSection = page.locator(".panel").filter({ hasText: /Unmanaged \(/ }); + this.notDiscoveredDialog = page + .getByRole("dialog") + .filter({ hasText: "Game not discovered" }) + .last(); + this.continueButton = this.notDiscoveredDialog.getByRole("button", { name: "Continue" }); } gameRow(gameName: string): Locator { @@ -14,6 +25,13 @@ export class GamesPage { .first(); } + gameRowInSection(section: Locator, gameName: string): Locator { + return section + .locator(".game-list-item, .game-thumbnail") + .filter({ hasText: gameName }) + .first(); + } + manageButton(gameName: string): Locator { return this.gameRow(gameName).getByRole("button", { name: "Manage", exact: true }).first(); } diff --git a/packages/e2e/src/tests/game-management.spec.ts b/packages/e2e/src/tests/game-management.spec.ts index e70271168d..425ced13e3 100644 --- a/packages/e2e/src/tests/game-management.spec.ts +++ b/packages/e2e/src/tests/game-management.spec.ts @@ -5,8 +5,15 @@ import { setupFakeGame, cleanupFakeGame, GAME_CONFIGS } from "../fixtures/game-s * Covers test cases: #8.1A, #8.8A */ import { test, expect } from "../fixtures/vortex-app"; +import { manageGame } from "../helpers/games"; +import { downloadModViaModManager } from "../helpers/modDownload"; +import { SMAPI_MOD_URL, SMAPI_NAME } from "../helpers/mods"; import { navigateToGames } from "../helpers/navigation"; +import { Timeouts } from "../helpers/timeouts"; +import { freeUser } from "../helpers/users"; +import { GamesPage } from "../selectors/games"; import { LoginPage } from "../selectors/loginPage"; +import { ModsPage } from "../selectors/modsPage"; import { NavBar } from "../selectors/navbar"; test.describe("Game Management", () => { @@ -55,3 +62,76 @@ test.describe("Game Management", () => { }); }); }); + +test.describe("Game Management - Manually set game location", () => { + test.use({ nexusUser: freeUser }); + + let fakeGame: { basePath: string; gamePath: string } | undefined; + + test.afterEach(() => { + if (fakeGame !== undefined) { + cleanupFakeGame(fakeGame.basePath); + fakeGame = undefined; + } + }); + + test("[QA-103] user can manually set a game location to manage it", async ({ + vortexApp, + vortexWindow, + nexusPage, + }) => { + const gamesPage = new GamesPage(vortexWindow); + const navbar = new NavBar(vortexWindow); + + await test.step("Navigate to the Games page", async () => { + await navigateToGames(vortexWindow); + await expect(gamesPage.unmanagedSection).toBeVisible({ timeout: Timeouts.NETWORK }); + }); + + await test.step("Stardew Valley is listed under Unmanaged", async () => { + await expect( + gamesPage.gameRowInSection(gamesPage.unmanagedSection, "Stardew Valley"), + ).toBeVisible({ timeout: Timeouts.NETWORK }); + }); + + fakeGame = await manageGame(vortexWindow, vortexApp, "stardewvalley"); + + await test.step("No error is shown", async () => { + await expect(vortexWindow.getByText("Failed to manage game")).toBeHidden(); + }); + + await test.step("Stardew Valley is the active game", async () => { + await expect( + vortexWindow.getByRole("button", { name: "Stardew Valley", exact: true }).first(), + ).toBeVisible(); + }); + + await test.step("Return to Home", async () => { + await navbar.homeButton.click(); + await expect(navbar.gamesLink).toBeVisible({ timeout: Timeouts.NETWORK }); + }); + + await test.step("Stardew Valley is now listed under Managed", async () => { + await navbar.gamesLink.click(); + await expect( + gamesPage.gameRowInSection(gamesPage.managedSection, "Stardew Valley"), + ).toBeVisible({ timeout: Timeouts.NETWORK }); + }); + + await downloadModViaModManager(nexusPage, vortexApp, SMAPI_MOD_URL); + + await test.step("Open the Stardew Valley workspace", async () => { + await vortexWindow + .getByRole("button", { name: "Stardew Valley", exact: true }) + .first() + .click(); + await expect(navbar.modsLink).toBeVisible({ timeout: Timeouts.NETWORK }); + }); + + await test.step("SMAPI is installed for the game", async () => { + await navbar.modsLink.click(); + const modsPage = new ModsPage(vortexWindow); + await expect(modsPage.row(SMAPI_NAME)).toBeVisible({ timeout: Timeouts.NETWORK }); + }); + }); +}); diff --git a/packages/e2e/src/tests/mods-manual.spec.ts b/packages/e2e/src/tests/mods-manual.spec.ts index 935fce4516..9da81a9785 100644 --- a/packages/e2e/src/tests/mods-manual.spec.ts +++ b/packages/e2e/src/tests/mods-manual.spec.ts @@ -9,6 +9,7 @@ import path from "node:path"; */ import { test, expect, type NexusUser } from "../fixtures/vortex-app"; import { acceptConsent } from "../helpers/consent"; +import { stubOpenDialog } from "../helpers/dialogs"; import { Timeouts } from "../helpers/timeouts"; import { freeUser, premiumUser } from "../helpers/users"; import { ModsPage } from "../selectors/modsPage"; @@ -78,13 +79,7 @@ test.describe("Mods - Manual Downloads", () => { if (downloadedFilePath === null) { throw new Error("File path was not captured"); } - await vortexApp.evaluate(({ dialog }, filePath) => { - dialog.showOpenDialog = () => - Promise.resolve({ - canceled: false, - filePaths: [filePath], - }); - }, downloadedFilePath); + await stubOpenDialog(vortexApp, downloadedFilePath); }); await test.step("Click Install From File in Vortex", async () => { diff --git a/tools/dotnetprobe/build.mjs b/tools/dotnetprobe/build.mjs index 51ba33a8b5..8671055a6a 100644 --- a/tools/dotnetprobe/build.mjs +++ b/tools/dotnetprobe/build.mjs @@ -21,5 +21,8 @@ try { stdio: "inherit", }); } catch (err) { + // Fail loudly: a silently-missing dotnetprobe binary makes the Vortex + // renderer crash at runtime, which surfaces as random e2e test failures. console.error("Error building dotnetprobe:", err.message); + process.exit(1); } diff --git a/tools/dotnetprobe/nuget.config b/tools/dotnetprobe/nuget.config new file mode 100644 index 0000000000..cd1e66cd03 --- /dev/null +++ b/tools/dotnetprobe/nuget.config @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/tools/dotnetprobe/project.json b/tools/dotnetprobe/project.json index 2e275bc343..4a27bd3334 100644 --- a/tools/dotnetprobe/project.json +++ b/tools/dotnetprobe/project.json @@ -12,7 +12,9 @@ "inputs": [ "{projectRoot}/dotnetprobe.csproj", "{projectRoot}/Program.cs", - { "runtime": "dotnet --version" } + "{projectRoot}/nuget.config", + { "runtime": "dotnet --version" }, + { "runtime": "node -p process.platform" } ], "outputs": ["{projectRoot}/dist"] }, @@ -26,6 +28,7 @@ "inputs": [ "{projectRoot}/dotnetprobe.csproj", "{projectRoot}/Program.cs", + "{projectRoot}/nuget.config", { "runtime": "dotnet --version" } ], "outputs": ["{projectRoot}/temp/dotnetprobe.exe"]