diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 000000000..458ab13ad --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,28 @@ +name: Playwright Tests +on: + push: + branches: [rewrite] + pull_request: + branches: [rewrite] +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 18 + # TODO: cache deps + - name: Install dependencies + run: npm ci + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run Playwright tests + run: npx playwright test + - uses: actions/upload-artifact@v3 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index 08efdc113..08243a8c3 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ dump !/scripts/output/.gitkeep /scripts/dicts/**/*.json +/test-results/ +/playwright-report/ +/playwright/.cache/ diff --git a/app/components/Combobox.tsx b/app/components/Combobox.tsx index a2e534b8f..14c3cbd59 100644 --- a/app/components/Combobox.tsx +++ b/app/components/Combobox.tsx @@ -121,7 +121,7 @@ export function Combobox>({ // To make SSR prefill work in an uncontrolled component defaultValue={initialValue ? displayValue(initialValue) : undefined} displayValue={displayValue} - data-cy={`${inputName}-combobox-input`} + data-testid={`${inputName}-combobox-input`} id={id} required={required} /> diff --git a/app/components/Toggle.tsx b/app/components/Toggle.tsx index ac75b3b98..ac5cecde4 100644 --- a/app/components/Toggle.tsx +++ b/app/components/Toggle.tsx @@ -21,6 +21,7 @@ export function Toggle({ className={clsx("toggle", { checked, tiny })} id={id} name={name} + data-testid={id ? `toggle-${id}` : null} > diff --git a/app/root.tsx b/app/root.tsx index 04ef0c6b7..30984b21d 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -39,6 +39,7 @@ import * as gtag from "~/utils/gtags.client"; import { Theme, ThemeHead, useTheme, ThemeProvider } from "./modules/theme"; import { getThemeSession } from "./modules/theme/session.server"; import { isTheme } from "./modules/theme/provider"; +import { useIsMounted } from "./hooks/useIsMounted"; export const unstable_shouldReload: ShouldReloadFunction = ({ url }) => { // reload on language change so the selected language gets set into the cookie @@ -145,6 +146,7 @@ function Document({ + {process.env.NODE_ENV === "development" && } {data?.gtmId ? : null} @@ -235,6 +237,14 @@ export const ErrorBoundary: ErrorBoundaryComponent = ({ error }) => { ); }; +function HydrationTestIndicator() { + const isMounted = useIsMounted(); + + if (!isMounted) return null; + + return
; +} + function GTM({ id }: { id: string }) { return ( <> diff --git a/app/routes/object-damage-calculator.tsx b/app/routes/object-damage-calculator.tsx index 2736e473d..3f15a6add 100644 --- a/app/routes/object-damage-calculator.tsx +++ b/app/routes/object-damage-calculator.tsx @@ -102,6 +102,7 @@ export default function ObjectDamagePage() { ×{multiShotCount} @@ -288,7 +289,9 @@ function DamageReceiversGrid({ />
- {damageToReceiver.hitPoints} + + {damageToReceiver.hitPoints} + {t("analyzer:suffix.hp")}
{damageToReceiver.damages.map((damage) => { @@ -301,14 +304,26 @@ function DamageReceiversGrid({ > {t("analyzer:damageShort")} -
{damage.value}
+
+ {damage.value} +
{t("analyzer:hitsToDestroyShort")} -
{damage.hitsToDestroy}
+
+ {damage.hitsToDestroy} +
×{damage.multiplier} diff --git a/app/utils/playwright.ts b/app/utils/playwright.ts new file mode 100644 index 000000000..4244c5b7e --- /dev/null +++ b/app/utils/playwright.ts @@ -0,0 +1,20 @@ +import type { Page } from "@playwright/test"; + +export async function selectWeapon({ + page, + name, +}: { + page: Page; + name: string; +}) { + const weaponCombobox = page.getByTestId("weapon-combobox-input"); + await weaponCombobox.clear(); + await weaponCombobox.fill(name); + await weaponCombobox.press("Enter"); +} + +/** page.goto that waits for the page to be hydrated before proceeding */ +export async function navigate({ page, url }: { page: Page; url: string }) { + await page.goto(url); + page.getByTestId("hydrated"); +} diff --git a/e2e/object-damage-calculator.spec.ts b/e2e/object-damage-calculator.spec.ts new file mode 100644 index 000000000..5e67def3b --- /dev/null +++ b/e2e/object-damage-calculator.spec.ts @@ -0,0 +1,71 @@ +import { expect, test } from "@playwright/test"; +import { navigate, selectWeapon } from "~/utils/playwright"; +import { OBJECT_DAMAGE_CALCULATOR_URL } from "~/utils/urls"; + +test.beforeEach(async ({ page }) => { + await navigate({ page, url: OBJECT_DAMAGE_CALCULATOR_URL }); +}); + +const cellId = (id: string, damageReceiver = "Chariot") => + `${id}-${damageReceiver}`; + +test("operates damage type select, max damage > min damage", async ({ + page, +}) => { + const hp = page.getByTestId(cellId("hp")); + const dmg = page.getByTestId(cellId("dmg")); + const htd = page.getByTestId(cellId("htd")); + + const hpBefore = (await hp.textContent())!; + const dmgBefore = (await dmg.textContent())!; + const htdBefore = (await htd.textContent())!; + + // test hits to destroy calculation + expect(Number(htdBefore)).toBe( + Math.ceil(Number(hpBefore) / Number(dmgBefore)) + ); + + await page.locator("text=Damage type").selectOption("NORMAL_MIN"); + + // select did what we expect it to do + await expect(hp).toHaveText(hpBefore); + await expect(dmg).not.toHaveText(dmgBefore); + await expect(htd).not.toHaveText(htdBefore); +}); + +test("changes weapon and saves it to url", async ({ page }) => { + const dmg = page.getByTestId(cellId("dmg")); + const dmgBefore = (await dmg.textContent())!; + + await selectWeapon({ page, name: "Luna Blaster" }); + + await expect(dmg).not.toHaveText(dmgBefore); + await page.reload(); + await expect(dmg).not.toHaveText(dmgBefore); +}); + +test("multiplier switch increases damage", async ({ page }) => { + await selectWeapon({ page, name: "Tri-Stringer" }); + + const dmg = page.getByTestId(cellId("dmg")); + const dmgBefore = (await dmg.textContent())!; + await page.getByTestId("toggle-multi").click(); + + // Multiplier is on by default + await expect(dmg).not.toHaveText(dmgBefore); +}); + +test("object hp increases when ability points added", async ({ page }) => { + const crabTankHp = page.getByTestId(cellId("hp")); + const crabTankHpBefore = (await crabTankHp.textContent())!; + + const splashWallHp = page.getByTestId(cellId("hp", "Wsb_Shield")); + const splashWallHpBefore = (await splashWallHp.textContent())!; + + await page.locator("text=Amount of").selectOption("10"); + + // Crab Tank doesn't gain HP from ability points + await expect(crabTankHp).toHaveText(crabTankHpBefore); + // ... but Splash Wall does + await expect(splashWallHp).not.toHaveText(splashWallHpBefore); +}); diff --git a/package-lock.json b/package-lock.json index 74599dfef..9446cfc79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,6 +45,7 @@ "zod": "^3.19.1" }, "devDependencies": { + "@playwright/test": "^1.28.1", "@remix-run/dev": "^1.8.1", "@remix-run/eslint-config": "^1.8.1", "@types/better-sqlite3": "^7.6.2", @@ -2561,6 +2562,22 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@playwright/test": { + "version": "1.28.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.28.1.tgz", + "integrity": "sha512-xN6spdqrNlwSn9KabIhqfZR7IWjPpFK1835tFNgjrlysaSezuX8PYUwaz38V/yI8TJLG9PkAMEXoHRXYXlpTPQ==", + "dev": true, + "dependencies": { + "@types/node": "*", + "playwright-core": "1.28.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/@popperjs/core": { "version": "2.11.6", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.6.tgz", @@ -13197,6 +13214,18 @@ "node": ">=6" } }, + "node_modules/playwright-core": { + "version": "1.28.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.28.1.tgz", + "integrity": "sha512-3PixLnGPno0E8rSBJjtwqTwJe3Yw72QwBBBxNoukIj3lEeBNXwbNiKrNuB1oyQgTBw5QHUhNO3SteEtHaMK6ag==", + "dev": true, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/posix-character-classes": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", @@ -19118,6 +19147,16 @@ "tslib": "^2.4.0" } }, + "@playwright/test": { + "version": "1.28.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.28.1.tgz", + "integrity": "sha512-xN6spdqrNlwSn9KabIhqfZR7IWjPpFK1835tFNgjrlysaSezuX8PYUwaz38V/yI8TJLG9PkAMEXoHRXYXlpTPQ==", + "dev": true, + "requires": { + "@types/node": "*", + "playwright-core": "1.28.1" + } + }, "@popperjs/core": { "version": "2.11.6", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.6.tgz", @@ -26867,6 +26906,12 @@ "find-up": "^3.0.0" } }, + "playwright-core": { + "version": "1.28.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.28.1.tgz", + "integrity": "sha512-3PixLnGPno0E8rSBJjtwqTwJe3Yw72QwBBBxNoukIj3lEeBNXwbNiKrNuB1oyQgTBw5QHUhNO3SteEtHaMK6ag==", + "dev": true + }, "posix-character-classes": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", diff --git a/package.json b/package.json index c0c401e56..028f4468b 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "deploy": "npm ci && npm run build", "build": "remix build", "dev": "cross-env NODE_ENV=development remix dev", + "dev:ci": "cp .env.example .env && npm run migrate up && npm run dev", "start": "npm run migrate up && remix-serve build", "migrate": "ley", "migrate:reset": "node scripts/delete-db-files.mjs && npm run migrate && npm run seed", @@ -25,7 +26,8 @@ "prettier:check": "prettier --check . --loglevel warn", "prettier:write": "prettier --write . --loglevel warn", "typecheck": "tsc --noEmit", - "test:unit": "uvu -r tsm -r tsconfig-paths/register", + "test:unit": "uvu -r tsm -r tsconfig-paths/register -i e2e", + "test:e2e": "npx playwright test", "checks": "npm run test:unit && npm run lint:styles && npm run lint:ts && npm run prettier:check && npm run typecheck", "cf": "npm run test:unit && npm run check-translation-jsons && npm run lint:styles -- --fix && npm run lint:ts -- --fix && npm run prettier:write && npm run typecheck" }, @@ -69,6 +71,7 @@ "zod": "^3.19.1" }, "devDependencies": { + "@playwright/test": "^1.28.1", "@remix-run/dev": "^1.8.1", "@remix-run/eslint-config": "^1.8.1", "@types/better-sqlite3": "^7.6.2", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 000000000..635c0f80a --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,108 @@ +import type { PlaywrightTestConfig } from "@playwright/test"; +import { devices } from "@playwright/test"; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +const config: PlaywrightTestConfig = { + testDir: "./e2e", + /* Maximum time one test can run for. */ + timeout: 30 * 1000, + expect: { + /** + * Maximum time expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 5000, + }, + /* Run tests in files in parallel */ + fullyParallel: true, + /* 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: process.env["CI"] ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: "list", + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ + actionTimeout: 0, + /* 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: "retain-on-failure", + }, + + /* 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: { + // channel: 'msedge', + // }, + // }, + // { + // name: 'Google Chrome', + // use: { + // channel: 'chrome', + // }, + // }, + ], + + /* Folder for test artifacts such as screenshots, videos, traces, etc. */ + // outputDir: 'test-results/', + + /* Run your local dev server before starting the tests */ + webServer: { + command: "npm run dev:ci", + port: 5800, + reuseExistingServer: !process.env["CI"], + }, +}; + +export default config;