Add translations and testing framework.

This commit is contained in:
Patrick Fic
2025-03-12 14:53:02 -07:00
parent e0cec62e13
commit 776d152d88
23 changed files with 1334 additions and 33 deletions

8
.gitignore vendored
View File

@@ -2,4 +2,10 @@ node_modules
dist
out
.DS_Store
*.log*
*.log*
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

381
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,23 +23,28 @@
"dependencies": {
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^4.0.0",
"antd": "^5.24.3",
"chokidar": "^4.0.3",
"dbffile": "^1.12.0",
"electron-log": "^5.3.2",
"electron-store": "^10.0.1",
"electron-updater": "^6.3.9",
"firebase": "^11.4.0",
"lodash": "^4.17.21"
"i18next": "^24.2.2",
"lodash": "^4.17.21",
"playwright": "^1.51.0",
"react-error-boundary": "^5.0.0",
"react-i18next": "^15.4.1"
},
"devDependencies": {
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.0.0",
"@electron-toolkit/tsconfig": "^1.0.1",
"@playwright/test": "^1.51.0",
"@types/node": "^22.13.10",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"antd": "^5.24.3",
"electron": "^35.0.1",
"electron-builder": "^25.1.8",
"electron-vite": "^3.0.0",
@@ -47,9 +52,11 @@
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"firebase": "^11.4.0",
"prettier": "^3.5.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router": "^7.3.0",
"typescript": "^5.8.2",
"vite": "^6.2.1"
}

80
playwright.config.ts Normal file
View File

@@ -0,0 +1,80 @@
import { defineConfig, devices } from "@playwright/test";
/**
* 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({
//testDir: './tests/**/*',
//testMatch: '**/*.test.ts',
/* 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: "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://127.0.0.1:3000',
/* 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: "npm run start",
// url: "http://127.0.0.1:3000",
// reuseExistingServer: !process.env.CI,
// },
});

21
src/main/index.test.ts Normal file
View File

@@ -0,0 +1,21 @@
import { _electron as electron } from "playwright";
import { test, expect } from "@playwright/test";
test("example test", async () => {
const electronApp = await electron.launch({ args: ["."] });
const isPackaged = await electronApp.evaluate(async ({ app }) => {
// This runs in Electron's main process, parameter here is always
// the result of the require('electron') in the main app script.
return app.isPackaged;
});
expect(isPackaged).toBe(false);
// Wait for the first BrowserWindow to open
// and return its Page object
const window = await electronApp.firstWindow();
await window.screenshot({ path: "intro.png" });
// close app
await electronApp.close();
});

View File

@@ -4,7 +4,8 @@ import log from "electron-log/main";
import { join } from "path";
import icon from "../../resources/icon.png?asset";
import ErrorTypeCheck from "../util/errorTypeCheck";
import "./store/store";
log.initialize();
function createWindow(): void {
// Create the browser window.
const mainWindow = new BrowserWindow({

View File

@@ -1,6 +1,38 @@
import { ipcMain } from "electron";
import ipcTypes from "../../util/ipcTypes.json";
import log from "electron-log/main";
import { ipcMainHandleAuthStateChanged } from "./ipcMainHandler.user";
// Log all IPC messages and their payloads
const logIpcMessages = () => {
// Get all message types from ipcTypes.toMain
Object.keys(ipcTypes.toMain).forEach((key) => {
const messageType = ipcTypes.toMain[key];
// Wrap the original handler with our logging
const originalHandler = ipcMain.listeners(messageType)[0];
if (originalHandler) {
ipcMain.removeAllListeners(messageType);
}
ipcMain.on(messageType, (event, payload) => {
log.info(
`%c[IPC Main]%c${messageType}`,
"color: red",
"color: green",
payload
);
// Call original handler if it existed
if (originalHandler) {
originalHandler(event, payload);
}
});
});
};
ipcMain.on(ipcTypes.toMain.test, (payload: any) =>
console.log("** Verify that ipcMain is loaded and working.", payload)
);
ipcMain.on(ipcTypes.toMain.authStateChanged, ipcMainHandleAuthStateChanged);
logIpcMessages();

View File

@@ -0,0 +1,14 @@
import { IpcMainEvent } from "electron";
import Store from "../store/store";
import { User } from "firebase/auth";
import log from "electron-log/main";
const ipcMainHandleAuthStateChanged = async (
event: IpcMainEvent,
user: User | null
) => {
Store.set("user", user);
log.log(Store.get("user"));
};
export { ipcMainHandleAuthStateChanged };

View File

@@ -8,6 +8,7 @@ const store = new Store({
enabled: false,
pollingInterval: 30000,
},
user: null,
},
});

View File

@@ -0,0 +1,93 @@
import { test, expect } from '@playwright/test';
import { Page } from '@playwright/test';
// src/renderer/src/App.test.tsx
// Mock data
const mockUser = {
uid: 'test123',
email: 'test@example.com',
displayName: 'Test User',
toJSON: () => ({
uid: 'test123',
email: 'test@example.com',
displayName: 'Test User'
})
};
test.describe('App Component', () => {
let page: Page;
test.beforeEach(async ({ browser }) => {
page = await browser.newPage();
// Mock Firebase Auth
await page.addInitScript(() => {
window.mockAuthState = null;
// Mock the firebase auth module
jest.mock('./util/firebase', () => ({
auth: {
onAuthStateChanged: (callback) => {
callback(window.mockAuthState);
// Return mock unsubscribe function
return () => {};
}
}
}));
// Mock electron IPC
window.electron = {
ipcRenderer: {
send: jest.fn()
}
};
});
await page.goto('/');
});
test('should show SignInForm when user is not authenticated', async () => {
await page.evaluate(() => {
window.mockAuthState = null;
});
await page.reload();
// Check if SignInForm is visible
const signInForm = await page.locator('form').filter({ hasText: 'Sign In' });
await expect(signInForm).toBeVisible();
});
test('should show routes when user is authenticated', async () => {
await page.evaluate((user) => {
window.mockAuthState = user;
}, mockUser);
await page.reload();
// Check if AuthHome is visible
const authHome = await page.locator('div:text("AuthHome")');
await expect(authHome).toBeVisible();
// Check that electron IPC was called with auth state
await expect(page.evaluate(() => {
return window.electron.ipcRenderer.send.mock.calls.length > 0;
})).resolves.toBe(true);
});
test('should navigate to settings page when authenticated', async () => {
await page.evaluate((user) => {
window.mockAuthState = user;
}, mockUser);
await page.reload();
// Navigate to settings
await page.click('a[href="/settings"]');
// Check if Settings page is visible
const settingsPage = await page.locator('div:text("Settings")');
await expect(settingsPage).toBeVisible();
});
});

View File

@@ -1,25 +1,49 @@
import { Button } from "antd";
import log from "electron-log/renderer";
import "@ant-design/v5-patch-for-react-19";
import { Layout } from "antd";
import { User } from "firebase/auth";
import { useState } from "react";
import { BrowserRouter, Route, Routes } from "react-router";
import ipcTypes from "../../util/ipcTypes.json";
import NavigationHeader from "./components/NavigationHeader/Navigationheader";
import SignInForm from "./components/SignInForm/SignInForm";
import Versions from "./components/Versions";
import { auth } from "./util/firebase";
import {} from "react-error-boundary";
import { ErrorBoundary } from "react-error-boundary";
import ErrorBoundaryFallback from "./components/ErrorBoundaryFallback/ErrorBoundaryFallback";
const App: React.FC = () => {
const [user, setUser] = useState<User | null>(null);
auth.onAuthStateChanged((user) => {
setUser(user);
//Send back to the main process so that it knows we are authenticated.
if (user) {
window.electron.ipcRenderer.send(
ipcTypes.toMain.authStateChanged,
user.toJSON()
);
}
});
function App(): JSX.Element {
const ipcHandle = (): void => window.electron.ipcRenderer.send("ping");
const ipcHandleWithType = (): void => {
log.error("Test from renderer.");
window.electron.ipcRenderer.send(ipcTypes.toMain.test, { test: "test" });
};
return (
<>
<Button onClick={ipcHandle}>Send IPC</Button>
<Button onClick={ipcHandleWithType}>Send IPC written by me.</Button>
{import.meta.env.VITE_FIREBASE_CONFIG}
<Versions></Versions>
<SignInForm />
</>
<BrowserRouter>
<ErrorBoundary FallbackComponent={ErrorBoundaryFallback}>
<Layout>
{!user ? (
<SignInForm />
) : (
<>
<NavigationHeader />
<Routes>
<Route path="/" element={<div>AuthHome</div>} />
<Route path="settings" element={<div>Settings</div>} />
</Routes>
</>
)}
</Layout>
</ErrorBoundary>
</BrowserRouter>
);
}
};
export default App;

View File

@@ -0,0 +1,20 @@
import { Button, Result } from "antd";
import { FallbackProps } from "react-error-boundary";
import { useTranslation } from "react-i18next";
const ErrorBoundaryFallback: React.FC<FallbackProps> = ({
error,
resetErrorBoundary,
}) => {
const { t } = useTranslation();
return (
<Result
status={"500"}
title={t("app.errors.errorboundary")}
subTitle={error?.message}
extra={[<Button onClick={resetErrorBoundary}>Try again</Button>]}
/>
);
};
export default ErrorBoundaryFallback;

View File

@@ -0,0 +1,29 @@
import { Layout, Menu } from "antd";
import { MenuItemType } from "antd/es/menu/interface";
import { useTranslation } from "react-i18next";
import { NavLink } from "react-router";
const NavigationHeader: React.FC = () => {
const { t } = useTranslation();
const menuItems: MenuItemType[] = [
{ label: <NavLink to="/">{t("navigation.home")}</NavLink>, key: "home" },
{
label: <NavLink to="/settings">{t("navigation.settings")}</NavLink>,
key: "settings",
},
];
return (
<Layout.Header style={{ display: "flex", alignItems: "center" }}>
<div className="demo-logo" />
<Menu
theme="dark"
mode="horizontal"
defaultSelectedKeys={["2"]}
items={menuItems}
style={{ flex: 1, minWidth: 0 }}
/>
</Layout.Header>
);
};
export default NavigationHeader;

View File

@@ -1,6 +1,7 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./util/i18n";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>

View File

@@ -0,0 +1,18 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import enTranslations from "../../../util/translations/en-US/renderer.json";
const resources = {
en: enTranslations,
};
i18n.use(initReactI18next).init({
resources,
debug: import.meta.env.DEV,
lng: "en",
interpolation: {
escapeValue: false,
},
});
export default i18n;

View File

@@ -1,6 +1,7 @@
{
"toMain": {
"test": "toMain_test"
"test": "toMain_test",
"authStateChanged": "toMain_authStateChanged"
},
"toRenderer": {
"test": "toRenderer_test"

View File

@@ -0,0 +1,5 @@
{
"toolbar": {
"help": "Help"
}
}

View File

@@ -0,0 +1,8 @@
{
"translation": {
"navigation": {
"home": "Home",
"settings": "Settings"
}
}
}

View File

@@ -0,0 +1,437 @@
import { test, expect, type Page } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.goto('https://demo.playwright.dev/todomvc');
});
const TODO_ITEMS = [
'buy some cheese',
'feed the cat',
'book a doctors appointment'
] as const;
test.describe('New Todo', () => {
test('should allow me to add todo items', async ({ page }) => {
// create a new todo locator
const newTodo = page.getByPlaceholder('What needs to be done?');
// Create 1st todo.
await newTodo.fill(TODO_ITEMS[0]);
await newTodo.press('Enter');
// Make sure the list only has one todo item.
await expect(page.getByTestId('todo-title')).toHaveText([
TODO_ITEMS[0]
]);
// Create 2nd todo.
await newTodo.fill(TODO_ITEMS[1]);
await newTodo.press('Enter');
// Make sure the list now has two todo items.
await expect(page.getByTestId('todo-title')).toHaveText([
TODO_ITEMS[0],
TODO_ITEMS[1]
]);
await checkNumberOfTodosInLocalStorage(page, 2);
});
test('should clear text input field when an item is added', async ({ page }) => {
// create a new todo locator
const newTodo = page.getByPlaceholder('What needs to be done?');
// Create one todo item.
await newTodo.fill(TODO_ITEMS[0]);
await newTodo.press('Enter');
// Check that input is empty.
await expect(newTodo).toBeEmpty();
await checkNumberOfTodosInLocalStorage(page, 1);
});
test('should append new items to the bottom of the list', async ({ page }) => {
// Create 3 items.
await createDefaultTodos(page);
// create a todo count locator
const todoCount = page.getByTestId('todo-count')
// Check test using different methods.
await expect(page.getByText('3 items left')).toBeVisible();
await expect(todoCount).toHaveText('3 items left');
await expect(todoCount).toContainText('3');
await expect(todoCount).toHaveText(/3/);
// Check all items in one call.
await expect(page.getByTestId('todo-title')).toHaveText(TODO_ITEMS);
await checkNumberOfTodosInLocalStorage(page, 3);
});
});
test.describe('Mark all as completed', () => {
test.beforeEach(async ({ page }) => {
await createDefaultTodos(page);
await checkNumberOfTodosInLocalStorage(page, 3);
});
test.afterEach(async ({ page }) => {
await checkNumberOfTodosInLocalStorage(page, 3);
});
test('should allow me to mark all items as completed', async ({ page }) => {
// Complete all todos.
await page.getByLabel('Mark all as complete').check();
// Ensure all todos have 'completed' class.
await expect(page.getByTestId('todo-item')).toHaveClass(['completed', 'completed', 'completed']);
await checkNumberOfCompletedTodosInLocalStorage(page, 3);
});
test('should allow me to clear the complete state of all items', async ({ page }) => {
const toggleAll = page.getByLabel('Mark all as complete');
// Check and then immediately uncheck.
await toggleAll.check();
await toggleAll.uncheck();
// Should be no completed classes.
await expect(page.getByTestId('todo-item')).toHaveClass(['', '', '']);
});
test('complete all checkbox should update state when items are completed / cleared', async ({ page }) => {
const toggleAll = page.getByLabel('Mark all as complete');
await toggleAll.check();
await expect(toggleAll).toBeChecked();
await checkNumberOfCompletedTodosInLocalStorage(page, 3);
// Uncheck first todo.
const firstTodo = page.getByTestId('todo-item').nth(0);
await firstTodo.getByRole('checkbox').uncheck();
// Reuse toggleAll locator and make sure its not checked.
await expect(toggleAll).not.toBeChecked();
await firstTodo.getByRole('checkbox').check();
await checkNumberOfCompletedTodosInLocalStorage(page, 3);
// Assert the toggle all is checked again.
await expect(toggleAll).toBeChecked();
});
});
test.describe('Item', () => {
test('should allow me to mark items as complete', async ({ page }) => {
// create a new todo locator
const newTodo = page.getByPlaceholder('What needs to be done?');
// Create two items.
for (const item of TODO_ITEMS.slice(0, 2)) {
await newTodo.fill(item);
await newTodo.press('Enter');
}
// Check first item.
const firstTodo = page.getByTestId('todo-item').nth(0);
await firstTodo.getByRole('checkbox').check();
await expect(firstTodo).toHaveClass('completed');
// Check second item.
const secondTodo = page.getByTestId('todo-item').nth(1);
await expect(secondTodo).not.toHaveClass('completed');
await secondTodo.getByRole('checkbox').check();
// Assert completed class.
await expect(firstTodo).toHaveClass('completed');
await expect(secondTodo).toHaveClass('completed');
});
test('should allow me to un-mark items as complete', async ({ page }) => {
// create a new todo locator
const newTodo = page.getByPlaceholder('What needs to be done?');
// Create two items.
for (const item of TODO_ITEMS.slice(0, 2)) {
await newTodo.fill(item);
await newTodo.press('Enter');
}
const firstTodo = page.getByTestId('todo-item').nth(0);
const secondTodo = page.getByTestId('todo-item').nth(1);
const firstTodoCheckbox = firstTodo.getByRole('checkbox');
await firstTodoCheckbox.check();
await expect(firstTodo).toHaveClass('completed');
await expect(secondTodo).not.toHaveClass('completed');
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
await firstTodoCheckbox.uncheck();
await expect(firstTodo).not.toHaveClass('completed');
await expect(secondTodo).not.toHaveClass('completed');
await checkNumberOfCompletedTodosInLocalStorage(page, 0);
});
test('should allow me to edit an item', async ({ page }) => {
await createDefaultTodos(page);
const todoItems = page.getByTestId('todo-item');
const secondTodo = todoItems.nth(1);
await secondTodo.dblclick();
await expect(secondTodo.getByRole('textbox', { name: 'Edit' })).toHaveValue(TODO_ITEMS[1]);
await secondTodo.getByRole('textbox', { name: 'Edit' }).fill('buy some sausages');
await secondTodo.getByRole('textbox', { name: 'Edit' }).press('Enter');
// Explicitly assert the new text value.
await expect(todoItems).toHaveText([
TODO_ITEMS[0],
'buy some sausages',
TODO_ITEMS[2]
]);
await checkTodosInLocalStorage(page, 'buy some sausages');
});
});
test.describe('Editing', () => {
test.beforeEach(async ({ page }) => {
await createDefaultTodos(page);
await checkNumberOfTodosInLocalStorage(page, 3);
});
test('should hide other controls when editing', async ({ page }) => {
const todoItem = page.getByTestId('todo-item').nth(1);
await todoItem.dblclick();
await expect(todoItem.getByRole('checkbox')).not.toBeVisible();
await expect(todoItem.locator('label', {
hasText: TODO_ITEMS[1],
})).not.toBeVisible();
await checkNumberOfTodosInLocalStorage(page, 3);
});
test('should save edits on blur', async ({ page }) => {
const todoItems = page.getByTestId('todo-item');
await todoItems.nth(1).dblclick();
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages');
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).dispatchEvent('blur');
await expect(todoItems).toHaveText([
TODO_ITEMS[0],
'buy some sausages',
TODO_ITEMS[2],
]);
await checkTodosInLocalStorage(page, 'buy some sausages');
});
test('should trim entered text', async ({ page }) => {
const todoItems = page.getByTestId('todo-item');
await todoItems.nth(1).dblclick();
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill(' buy some sausages ');
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter');
await expect(todoItems).toHaveText([
TODO_ITEMS[0],
'buy some sausages',
TODO_ITEMS[2],
]);
await checkTodosInLocalStorage(page, 'buy some sausages');
});
test('should remove the item if an empty text string was entered', async ({ page }) => {
const todoItems = page.getByTestId('todo-item');
await todoItems.nth(1).dblclick();
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('');
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter');
await expect(todoItems).toHaveText([
TODO_ITEMS[0],
TODO_ITEMS[2],
]);
});
test('should cancel edits on escape', async ({ page }) => {
const todoItems = page.getByTestId('todo-item');
await todoItems.nth(1).dblclick();
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages');
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Escape');
await expect(todoItems).toHaveText(TODO_ITEMS);
});
});
test.describe('Counter', () => {
test('should display the current number of todo items', async ({ page }) => {
// create a new todo locator
const newTodo = page.getByPlaceholder('What needs to be done?');
// create a todo count locator
const todoCount = page.getByTestId('todo-count')
await newTodo.fill(TODO_ITEMS[0]);
await newTodo.press('Enter');
await expect(todoCount).toContainText('1');
await newTodo.fill(TODO_ITEMS[1]);
await newTodo.press('Enter');
await expect(todoCount).toContainText('2');
await checkNumberOfTodosInLocalStorage(page, 2);
});
});
test.describe('Clear completed button', () => {
test.beforeEach(async ({ page }) => {
await createDefaultTodos(page);
});
test('should display the correct text', async ({ page }) => {
await page.locator('.todo-list li .toggle').first().check();
await expect(page.getByRole('button', { name: 'Clear completed' })).toBeVisible();
});
test('should remove completed items when clicked', async ({ page }) => {
const todoItems = page.getByTestId('todo-item');
await todoItems.nth(1).getByRole('checkbox').check();
await page.getByRole('button', { name: 'Clear completed' }).click();
await expect(todoItems).toHaveCount(2);
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
});
test('should be hidden when there are no items that are completed', async ({ page }) => {
await page.locator('.todo-list li .toggle').first().check();
await page.getByRole('button', { name: 'Clear completed' }).click();
await expect(page.getByRole('button', { name: 'Clear completed' })).toBeHidden();
});
});
test.describe('Persistence', () => {
test('should persist its data', async ({ page }) => {
// create a new todo locator
const newTodo = page.getByPlaceholder('What needs to be done?');
for (const item of TODO_ITEMS.slice(0, 2)) {
await newTodo.fill(item);
await newTodo.press('Enter');
}
const todoItems = page.getByTestId('todo-item');
const firstTodoCheck = todoItems.nth(0).getByRole('checkbox');
await firstTodoCheck.check();
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]);
await expect(firstTodoCheck).toBeChecked();
await expect(todoItems).toHaveClass(['completed', '']);
// Ensure there is 1 completed item.
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
// Now reload.
await page.reload();
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]);
await expect(firstTodoCheck).toBeChecked();
await expect(todoItems).toHaveClass(['completed', '']);
});
});
test.describe('Routing', () => {
test.beforeEach(async ({ page }) => {
await createDefaultTodos(page);
// make sure the app had a chance to save updated todos in storage
// before navigating to a new view, otherwise the items can get lost :(
// in some frameworks like Durandal
await checkTodosInLocalStorage(page, TODO_ITEMS[0]);
});
test('should allow me to display active items', async ({ page }) => {
const todoItem = page.getByTestId('todo-item');
await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
await page.getByRole('link', { name: 'Active' }).click();
await expect(todoItem).toHaveCount(2);
await expect(todoItem).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
});
test('should respect the back button', async ({ page }) => {
const todoItem = page.getByTestId('todo-item');
await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
await test.step('Showing all items', async () => {
await page.getByRole('link', { name: 'All' }).click();
await expect(todoItem).toHaveCount(3);
});
await test.step('Showing active items', async () => {
await page.getByRole('link', { name: 'Active' }).click();
});
await test.step('Showing completed items', async () => {
await page.getByRole('link', { name: 'Completed' }).click();
});
await expect(todoItem).toHaveCount(1);
await page.goBack();
await expect(todoItem).toHaveCount(2);
await page.goBack();
await expect(todoItem).toHaveCount(3);
});
test('should allow me to display completed items', async ({ page }) => {
await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
await page.getByRole('link', { name: 'Completed' }).click();
await expect(page.getByTestId('todo-item')).toHaveCount(1);
});
test('should allow me to display all items', async ({ page }) => {
await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
await page.getByRole('link', { name: 'Active' }).click();
await page.getByRole('link', { name: 'Completed' }).click();
await page.getByRole('link', { name: 'All' }).click();
await expect(page.getByTestId('todo-item')).toHaveCount(3);
});
test('should highlight the currently applied filter', async ({ page }) => {
await expect(page.getByRole('link', { name: 'All' })).toHaveClass('selected');
//create locators for active and completed links
const activeLink = page.getByRole('link', { name: 'Active' });
const completedLink = page.getByRole('link', { name: 'Completed' });
await activeLink.click();
// Page change - active items.
await expect(activeLink).toHaveClass('selected');
await completedLink.click();
// Page change - completed items.
await expect(completedLink).toHaveClass('selected');
});
});
async function createDefaultTodos(page: Page) {
// create a new todo locator
const newTodo = page.getByPlaceholder('What needs to be done?');
for (const item of TODO_ITEMS) {
await newTodo.fill(item);
await newTodo.press('Enter');
}
}
async function checkNumberOfTodosInLocalStorage(page: Page, expected: number) {
return await page.waitForFunction(e => {
return JSON.parse(localStorage['react-todos']).length === e;
}, expected);
}
async function checkNumberOfCompletedTodosInLocalStorage(page: Page, expected: number) {
return await page.waitForFunction(e => {
return JSON.parse(localStorage['react-todos']).filter((todo: any) => todo.completed).length === e;
}, expected);
}
async function checkTodosInLocalStorage(page: Page, title: string) {
return await page.waitForFunction(t => {
return JSON.parse(localStorage['react-todos']).map((todo: any) => todo.title).includes(t);
}, title);
}

18
tests/example.spec.ts Normal file
View File

@@ -0,0 +1,18 @@
import { test, expect } from '@playwright/test';
test('has title', async ({ page }) => {
await page.goto('https://playwright.dev/');
// Expect a title "to contain" a substring.
await expect(page).toHaveTitle(/Playwright/);
});
test('get started link', async ({ page }) => {
await page.goto('https://playwright.dev/');
// Click the get started link.
await page.getByRole('link', { name: 'Get started' }).click();
// Expects page to have a heading with the name of Installation.
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
});

112
translations.babel Normal file
View File

@@ -0,0 +1,112 @@
<babeledit_project be_version="2.7.1" version="1.2">
<!--
BabelEdit project file
https://www.codeandweb.com/babeledit
This file contains meta data for all translations, but not the translation texts itself.
They are stored in framework-specific message files (.json / .vue / .yaml / .properties)
-->
<preset_collections/>
<framework>i18next</framework>
<filename>translations.babel</filename>
<source_root_dir></source_root_dir>
<folder_node>
<name></name>
<children>
<file_node>
<name>main</name>
<children>
<folder_node>
<name>toolbar</name>
<children>
<concept_node>
<name>help</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
</children>
</folder_node>
</children>
<filename></filename>
</file_node>
<file_node>
<name>renderer</name>
<children>
<folder_node>
<name>translation</name>
<children>
<folder_node>
<name>navigation</name>
<children>
<concept_node>
<name>home</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>settings</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
</children>
</folder_node>
</children>
</folder_node>
</children>
<filename></filename>
</file_node>
</children>
</folder_node>
<isTemplateProject>false</isTemplateProject>
<languages>
<language>
<code>en-US</code>
<source_id></source_id>
<source_file>src/util/translations/en-US</source_file>
</language>
</languages>
<translation_files>
<translation_file>
<file>src/util/translations/en-US</file>
</translation_file>
</translation_files>
<editor_configuration>
<save_empty_translations>true</save_empty_translations>
<copy_templates>
<copy_template>'%1'</copy_template>
<copy_template>{ this.props.t('%1') }</copy_template>
<copy_template>{ t('%1') }</copy_template>
</copy_templates>
</editor_configuration>
<primary_language>en-US</primary_language>
<configuration>
<indent>tab</indent>
<format>namespaced-json</format>
</configuration>
</babeledit_project>

View File

@@ -5,7 +5,8 @@
"src/main/**/*",
"src/preload/**/*",
"src/util/**/*",
"src/interfaces/**/*"
"src/interfaces/**/*",
"tests/index.spec.ts"
],
"compilerOptions": {
"composite": true,

View File

@@ -4,16 +4,15 @@
"src/renderer/src/env.d.ts",
"src/renderer/src/**/*",
"src/renderer/src/**/*.tsx",
"src/preload/*.d.ts"
"src/preload/*.d.ts",
"src/util/**/*"
],
"compilerOptions": {
"composite": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@renderer/*": [
"src/renderer/src/*"
]
"@renderer/*": ["src/renderer/src/*"]
}
}
}