Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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 }}
Expand Down
10 changes: 10 additions & 0 deletions packages/e2e/src/helpers/dialogs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { ElectronApplication } from "@playwright/test";

export async function stubOpenDialog(
vortexApp: ElectronApplication,
filePath: string,
): Promise<void> {
await vortexApp.evaluate(({ dialog }, path) => {
dialog.showOpenDialog = () => Promise.resolve({ canceled: false, filePaths: [path] });
}, filePath);
}
10 changes: 4 additions & 6 deletions packages/e2e/src/helpers/games.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 });
Expand All @@ -43,9 +42,8 @@ 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.continueButton).toBeVisible();
await gamesPage.continueButton.click();

await expect(navbar.modsLink).toBeVisible({ timeout: Timeouts.NETWORK });
});
Expand Down
18 changes: 18 additions & 0 deletions packages/e2e/src/selectors/games.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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();
}
Expand Down
107 changes: 107 additions & 0 deletions packages/e2e/src/tests/game-management.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { stubOpenDialog } from "../helpers/dialogs";
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", () => {
Expand Down Expand Up @@ -55,3 +62,103 @@ 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,
}) => {
fakeGame = setupFakeGame("stardewvalley");
Comment thread
IDCs marked this conversation as resolved.
Outdated
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 });
});

await test.step("Stub the file browser with the game's location", async () => {
if (fakeGame === undefined) throw new Error("fake game was not set up");
await stubOpenDialog(vortexApp, fakeGame.gamePath);
});

await test.step("Click Manage on Stardew Valley", async () => {
const row = gamesPage.gameRowInSection(gamesPage.unmanagedSection, "Stardew Valley");
await row.scrollIntoViewIfNeeded();
await row.hover();
await gamesPage.manageButton("Stardew Valley").click();
await expect(gamesPage.notDiscoveredDialog).toBeVisible();
});

await test.step("The dialog confirms the location could not be detected", async () => {
await expect(gamesPage.notDiscoveredDialog).toContainText(
"hasn't been automatically discovered",
);
});

await test.step("Click Continue and select the game folder", async () => {
await gamesPage.continueButton.click();
await expect(gamesPage.notDiscoveredDialog).toBeHidden();
});

await test.step("The location is accepted and the game is prepared for modding", async () => {
await expect(navbar.modsLink).toBeVisible({ timeout: Timeouts.NETWORK });
});

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 });
});
});
});
9 changes: 2 additions & 7 deletions packages/e2e/src/tests/mods-manual.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 () => {
Expand Down
3 changes: 3 additions & 0 deletions tools/dotnetprobe/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
13 changes: 13 additions & 0 deletions tools/dotnetprobe/nuget.config
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Declare the package source explicitly so the build does not depend on
machine-level NuGet configuration. Machines with no configured sources
(seen on dev machines and CI runners) otherwise fail restore with NU1100,
which used to silently ship a Vortex build without dotnetprobe.
-->
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
</configuration>
Comment on lines +1 to +13

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What was the issue encountered? I don't remember having a need for a machine wide configuration, but I don't mind this

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From what I could tell, the failure was NU1100 on restore. My machine had no NuGet sources configured at all (dotnet nuget list source showed "No sources found"), so the restore failed, and because the build script swallowed the error, Vortex got built without the probe. I suspect the platform-cluster runner might have the same issue but I haven't been able to confirm that. I'm honestly not sure how my machine ended up with no sources, so this felt like the safest way to make the probe build not depend on machine config. It should only apply to dotnetprobe, so other .NET builds shouldn't be affected, but happy to change the approach if you'd rather handle it differently.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the platform-cluster runner is an intentionally minimal ubuntu runner which never provisions .NET SDK; this obviously means that workflow will always fail until the runner gets updated to include the SDK OR we update the e2e workflow to provision .NET SDK ourselves on linux environments.

My suggestion is to have it provisioned on the runner rather than us modifying this workflow and porbably others in the future whenever that runner is being used with .NET builds.

5 changes: 4 additions & 1 deletion tools/dotnetprobe/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
},
Expand All @@ -26,6 +28,7 @@
"inputs": [
"{projectRoot}/dotnetprobe.csproj",
"{projectRoot}/Program.cs",
"{projectRoot}/nuget.config",
{ "runtime": "dotnet --version" }
],
"outputs": ["{projectRoot}/temp/dotnetprobe.exe"]
Expand Down
Loading