Add translations and testing framework.
This commit is contained in:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -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
381
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -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
80
playwright.config.ts
Normal 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
21
src/main/index.test.ts
Normal 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();
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
14
src/main/ipc/ipcMainHandler.user.ts
Normal file
14
src/main/ipc/ipcMainHandler.user.ts
Normal 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 };
|
||||
@@ -8,6 +8,7 @@ const store = new Store({
|
||||
enabled: false,
|
||||
pollingInterval: 30000,
|
||||
},
|
||||
user: null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
93
src/renderer/src/App.test.tsx
Normal file
93
src/renderer/src/App.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
18
src/renderer/src/util/i18n.ts
Normal file
18
src/renderer/src/util/i18n.ts
Normal 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;
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"toMain": {
|
||||
"test": "toMain_test"
|
||||
"test": "toMain_test",
|
||||
"authStateChanged": "toMain_authStateChanged"
|
||||
},
|
||||
"toRenderer": {
|
||||
"test": "toRenderer_test"
|
||||
|
||||
5
src/util/translations/en-US/main.json
Normal file
5
src/util/translations/en-US/main.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"toolbar": {
|
||||
"help": "Help"
|
||||
}
|
||||
}
|
||||
8
src/util/translations/en-US/renderer.json
Normal file
8
src/util/translations/en-US/renderer.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"translation": {
|
||||
"navigation": {
|
||||
"home": "Home",
|
||||
"settings": "Settings"
|
||||
}
|
||||
}
|
||||
}
|
||||
437
tests-examples/demo-todo-app.spec.ts
Normal file
437
tests-examples/demo-todo-app.spec.ts
Normal 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
18
tests/example.spec.ts
Normal 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
112
translations.babel
Normal 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>
|
||||
@@ -5,7 +5,8 @@
|
||||
"src/main/**/*",
|
||||
"src/preload/**/*",
|
||||
"src/util/**/*",
|
||||
"src/interfaces/**/*"
|
||||
"src/interfaces/**/*",
|
||||
"tests/index.spec.ts"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
|
||||
@@ -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/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user