desktop: add support for running web tests via desktop app

This commit is contained in:
Abdullah Atta
2026-03-16 12:55:26 +05:00
parent bd04427e03
commit f2c08c89b0
87 changed files with 9074 additions and 2761 deletions

View File

@@ -17,155 +17,92 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { testCleanup, test } from "./test-override.js";
import { writeFile } from "fs/promises";
import { Page } from "playwright";
import { test } from "@nn/test";
import { gt, lt } from "semver";
import { describe } from "vitest";
import { AppModel } from "../../web/__e2e__/models/app.model.js";
import { expect } from "playwright/test";
test("update starts downloading if version is outdated", async ({
ctx: { page },
expect,
onTestFinished
}) => {
onTestFinished(testCleanup);
test.extend({ options: { version: "3.0.0" } })(
"update starts downloading if version is outdated",
async ({ page }) => {
await page.waitForSelector(".ProseMirror");
await page.waitForSelector("#authForm");
expect(
await page.getByRole("button", { name: "Create account" }).isVisible()
).toBe(true);
await page
.locator(".theme-scope-statusBar")
.getByRole("button", { name: /updating/i })
.waitFor({ state: "attached" });
}
);
await page
.getByRole("button", { name: "Skip & go directly to the app" })
.click();
await skipDialog(page);
await page.waitForSelector(".ProseMirror");
await page
.locator(".theme-scope-statusBar")
.getByRole("button", { name: /updating/i })
.waitFor({ state: "attached" });
});
test("update is only shown if version is outdated and auto updates are disabled", async ({
ctx,
expect,
onTestFinished
}) => {
onTestFinished(testCleanup);
await ctx.app.close();
await writeFile(
ctx.configPath,
JSON.stringify({
test.extend({
options: {
version: "3.0.0",
config: {
automaticUpdates: false
})
);
}
}
})(
"update is only shown if version is outdated and auto updates are disabled",
async ({ page }) => {
await page.waitForSelector(".ProseMirror");
await ctx.relaunch();
const app = new AppModel(page);
const settings = await app.goToSettings();
const { page } = ctx;
await settings.checkForUpdates();
await settings.close();
await page.waitForSelector("#authForm");
expect(
await page.getByRole("button", { name: "Create account" }).isVisible()
).toBe(true);
await page
.getByRole("button", { name: "Skip & go directly to the app" })
.click();
await skipDialog(page);
await page
.locator(".theme-scope-statusBar")
.getByRole("button", { name: /available/i })
.waitFor({ state: "attached" });
}
);
test.extend({
options: {
version: "3.0.0-beta.0",
config: {
automaticUpdates: false,
releaseTrack: "beta"
}
}
})("update to stable if it is newer", async ({ page }) => {
await page.waitForSelector(".ProseMirror");
await page
const app = new AppModel(page);
const settings = await app.goToSettings();
await settings.checkForUpdates();
await settings.close();
const updateButton = page
.locator(".theme-scope-statusBar")
.getByRole("button", { name: /available/i })
.waitFor({ state: "attached" });
.getByRole("button", { name: /available/i });
await updateButton.waitFor({ state: "visible" });
const content = await updateButton.textContent();
const version = content?.split(" ")?.[0] || "";
expect(gt(version, "3.0.0-beta.0")).toBe(true);
});
describe("update to stable if it is newer", () => {
test.scoped({ options: { version: "3.0.0-beta.0" } });
test("test", async ({ ctx, expect, onTestFinished }) => {
onTestFinished(testCleanup);
await ctx.app.close();
await writeFile(
ctx.configPath,
JSON.stringify({
automaticUpdates: false,
releaseTrack: "beta"
})
);
await ctx.relaunch();
const { page } = ctx;
await page.waitForSelector("#authForm");
expect(
await page.getByRole("button", { name: "Create account" }).isVisible()
).toBe(true);
await page
.getByRole("button", { name: "Skip & go directly to the app" })
.click();
await skipDialog(page);
test.extend({
options: {
version: "99.0.0-beta.0",
config: {
automaticUpdates: false,
releaseTrack: "beta"
}
}
})(
"update is not available if it latest stable version is older",
async ({ page }) => {
await page.waitForSelector(".ProseMirror");
const updateButton = page
.locator(".theme-scope-statusBar")
.getByRole("button", { name: /available/i });
await updateButton.waitFor({ state: "visible" });
const content = await updateButton.textContent();
const version = content?.split(" ")?.[0] || "";
expect(gt(version, "3.0.0-beta.0")).toBe(true);
});
});
const app = new AppModel(page);
const settings = await app.goToSettings();
describe("update is not available if it latest stable version is older", () => {
test.scoped({ options: { version: "99.0.0-beta.0" } });
test("test", async ({ ctx, expect, onTestFinished }) => {
onTestFinished(testCleanup);
await ctx.app.close();
await writeFile(
ctx.configPath,
JSON.stringify({
automaticUpdates: false,
releaseTrack: "beta"
})
);
await ctx.relaunch();
const { page } = ctx;
await page.waitForSelector("#authForm");
expect(
await page.getByRole("button", { name: "Create account" }).isVisible()
).toBe(true);
await page
.getByRole("button", { name: "Skip & go directly to the app" })
.click();
await skipDialog(page);
await page.waitForSelector(".ProseMirror");
await page
.locator(".theme-scope-statusBar")
.getByRole("button", { name: /checking for updates/i })
.waitFor({ state: "hidden" });
await settings.checkForUpdates();
await settings.close();
expect(
await page
@@ -173,45 +110,24 @@ describe("update is not available if it latest stable version is older", () => {
.getByRole("button", { name: /available/i })
.isHidden()
).toBe(true);
});
});
describe("downgrade to stable on switching to stable release track", () => {
test.scoped({ options: { version: "99.0.0-beta.0" } });
test("test", async ({ ctx, expect, onTestFinished }) => {
onTestFinished(testCleanup);
await ctx.app.close();
await writeFile(
ctx.configPath,
JSON.stringify({
automaticUpdates: false,
releaseTrack: "stable"
})
);
await ctx.relaunch();
const { page } = ctx;
await page.waitForSelector("#authForm");
expect(
await page.getByRole("button", { name: "Create account" }).isVisible()
).toBe(true);
await page
.getByRole("button", { name: "Skip & go directly to the app" })
.click();
await skipDialog(page);
}
);
test.extend({
options: {
version: "99.0.0-beta.0",
config: { automaticUpdates: false, releaseTrack: "stable" }
}
})(
"downgrade to stable on switching to stable release track",
async ({ page }) => {
await page.waitForSelector(".ProseMirror");
await page
.locator(".theme-scope-statusBar")
.getByRole("button", { name: /checking for updates/i })
.waitFor({ state: "hidden" });
const app = new AppModel(page);
const settings = await app.goToSettings();
await settings.checkForUpdates();
await settings.close();
const updateButton = page
.locator(".theme-scope-statusBar")
@@ -220,23 +136,5 @@ describe("downgrade to stable on switching to stable release track", () => {
const content = await updateButton.textContent();
const version = content?.split(" ")?.[0] || "";
expect(lt(version, "99.0.0-beta.0")).toBe(true);
});
});
async function skipDialog(page: Page) {
try {
const dialog = page.locator(".ReactModal__Content");
const positiveButton = dialog.locator(
"button[data-role='positive-button']"
);
const negativeButton = dialog.locator(
"button[data-role='negative-button']"
);
if (await positiveButton.isVisible())
await positiveButton.click({ timeout: 1000 });
else if (await negativeButton.isVisible())
await negativeButton.click({ timeout: 1000 });
} catch (e) {
// ignore error
}
}
);

View File

@@ -0,0 +1,64 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { mergeTests } from "@playwright/test";
import { test } from "@playwright/test";
import type { CommonFixtures, CommonWorkerFixtures } from "./common-fixtures";
import { commonFixtures } from "./common-fixtures";
import { platformTest } from "./platform-fixtures";
import { testModeTest } from "./test-mode-fixtures";
export const base = test;
export const baseTest = mergeTests(base, platformTest, testModeTest).extend<
CommonFixtures,
CommonWorkerFixtures
>(commonFixtures);
export function step<
This extends NonNullable<unknown>,
Args extends any[],
Return
>(
target: (this: This, ...args: Args) => Promise<Return>,
context: ClassMethodDecoratorContext<
This,
(this: This, ...args: Args) => Promise<Return>
>
) {
function replacementMethod(this: This, ...args: Args): Promise<Return> {
const name =
this.constructor.name +
"." +
(context.name as string) +
"(" +
args.map((a) => JSON.stringify(a)).join(",") +
")";
return test.step(name, async () => {
return await target.call(this, ...args);
});
}
return replacementMethod;
}
// declare global {
// interface Window {
// builtins: Builtins;
// }
// }

View File

@@ -0,0 +1,347 @@
/* eslint-disable header/header */
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Fixtures } from "@playwright/test";
import type { ChildProcess } from "child_process";
import { execSync, spawn } from "child_process";
import net from "net";
import fs from "fs";
import { stripAnsi } from "./playwright-utils";
type TestChildParams = {
command: string[];
cwd?: string;
env?: NodeJS.ProcessEnv;
shell?: boolean;
onOutput?: () => void;
};
import childProcess from "child_process";
type ProcessData = {
pid: number; // process ID
pgrp: number; // process group ID
children: Set<ProcessData>; // direct children of the process
};
function readAllProcessesLinux(): {
pid: number;
ppid: number;
pgrp: number;
}[] {
const result: { pid: number; ppid: number; pgrp: number }[] = [];
for (const dir of fs.readdirSync("/proc")) {
const pid = +dir;
if (isNaN(pid)) continue;
try {
const statFile = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
// Format of /proc/*/stat is described https://man7.org/linux/man-pages/man5/proc.5.html
const match = statFile.match(
/^(?<pid>\d+)\s+\((?<comm>.*)\)\s+(?<state>R|S|D|Z|T|t|W|X|x|K|W|P)\s+(?<ppid>\d+)\s+(?<pgrp>\d+)/
);
if (match && match.groups) {
result.push({
pid: +match.groups.pid,
ppid: +match.groups.ppid,
pgrp: +match.groups.pgrp
});
}
} catch (e) {
// We don't have access to some /proc/<pid>/stat file.
}
}
return result;
}
function readAllProcessesMacOS(): {
pid: number;
ppid: number;
pgrp: number;
}[] {
const result: { pid: number; ppid: number; pgrp: number }[] = [];
const processTree = childProcess.spawnSync("ps", ["-eo", "pid,pgid,ppid"]);
const lines = processTree.stdout.toString().trim().split("\n");
for (const line of lines) {
const [pid, pgrp, ppid] = line
.trim()
.split(/\s+/)
.map((token) => +token);
// On linux, the very first line of `ps` is the header with "PID PGID PPID".
if (isNaN(pid) || isNaN(pgrp) || isNaN(ppid)) continue;
result.push({ pid, ppid, pgrp });
}
return result;
}
function buildProcessTreePosix(pid: number): ProcessData | undefined {
// Certain Linux distributions might not have `ps` installed.
const allProcesses =
process.platform === "darwin"
? readAllProcessesMacOS()
: readAllProcessesLinux();
const pidToProcess = new Map<number, ProcessData>();
for (const { pid, pgrp } of allProcesses)
pidToProcess.set(pid, { pid, pgrp, children: new Set() });
for (const { pid, ppid } of allProcesses) {
const parent = pidToProcess.get(ppid);
const child = pidToProcess.get(pid);
// On POSIX, certain processes might not have parent (e.g. PID=1 and occasionally PID=2)
// or we might not have access to it proc info.
if (parent && child) parent.children.add(child);
}
return pidToProcess.get(pid);
}
export class TestChildProcess {
params: TestChildParams;
process: ChildProcess;
output = "";
stdout = "";
stderr = "";
fullOutput = "";
onOutput?: (chunk: string | Buffer) => void;
exited: Promise<{ exitCode: number | null; signal: string | null }>;
exitCode: Promise<number | null>;
private _outputCallbacks = new Set<() => void>();
constructor(params: TestChildParams) {
this.params = params;
// See https://nodejs.org/api/deprecations.html#DEP0190
const command = params.shell ? params.command.join(" ") : params.command[0];
const args = params.shell ? [] : params.command.slice(1);
this.process = spawn(command, args, {
env: {
...process.env,
...params.env
},
cwd: params.cwd,
shell: params.shell,
// On non-windows platforms, `detached: true` makes child process a leader of a new
// process group, making it possible to kill child process tree with `.kill(-pid)` command.
// @see https://nodejs.org/api/child_process.html#child_process_options_detached
detached: process.platform !== "win32"
});
if (process.env.PWTEST_DEBUG)
process.stdout.write(`\n\nLaunching ${params.command.join(" ")}\n`);
this.onOutput = params.onOutput;
const appendChunk = (type: "stdout" | "stderr", chunk: string | Buffer) => {
this.output += String(chunk);
if (type === "stderr") this.stderr += String(chunk);
else this.stdout += String(chunk);
if (process.env.PWTEST_DEBUG) process.stdout.write(String(chunk));
else this.fullOutput += String(chunk);
this.onOutput?.(chunk);
for (const cb of this._outputCallbacks) cb();
this._outputCallbacks.clear();
};
this.process.stderr!.on("data", appendChunk.bind(null, "stderr"));
this.process.stdout!.on("data", appendChunk.bind(null, "stdout"));
const killProcessGroup = this._killProcessTree.bind(this, "SIGKILL");
process.on("exit", killProcessGroup);
this.exited = new Promise((f) => {
this.process.on("exit", (exitCode, signal) => f({ exitCode, signal }));
process.off("exit", killProcessGroup);
});
this.exitCode = this.exited.then((r) => r.exitCode);
}
outputLines(): string[] {
const strippedOutput = stripAnsi(this.output);
return strippedOutput
.split("\n")
.filter((line) => line.startsWith("%%"))
.map((line) => line.substring(2).trim());
}
async kill(signal: "SIGINT" | "SIGKILL" = "SIGKILL") {
this._killProcessTree(signal);
return this.exited;
}
private _killProcessTree(signal: "SIGINT" | "SIGKILL") {
if (!this.process.pid || !this.process.kill(0)) return;
killProcessGroup(this.process.pid, signal);
}
async cleanExit() {
const r = await this.exited;
if (r.exitCode)
throw new Error(
`Process failed with exit code ${r.exitCode}. Output:\n${this.output}`
);
if (r.signal)
throw new Error(
`Process received signal: ${r.signal}. Output:\n${this.output}`
);
}
async waitForOutput(substring: string, count = 1) {
while (countTimes(stripAnsi(this.output), substring) < count)
await new Promise<void>((f) => this._outputCallbacks.add(f));
}
clearOutput() {
this.output = "";
}
write(chars: string) {
this.process.stdin!.write(chars);
}
}
export function killProcessGroup(
pid: number,
signal: "SIGINT" | "SIGKILL" = "SIGKILL"
) {
// On Windows, we always call `taskkill` no matter signal.
if (process.platform === "win32") {
try {
execSync(`taskkill /pid ${pid} /T /F /FI "MEMUSAGE gt 0"`, {
stdio: "ignore"
});
} catch (e) {
// the process might have already stopped
}
return;
}
// In case of POSIX and `SIGINT` signal, send it to the main process group only.
if (signal === "SIGINT") {
try {
process.kill(-pid, "SIGINT");
} catch (e) {
// the process might have already stopped
}
return;
}
// In case of POSIX and `SIGKILL` signal, we should send it to all descendant process groups.
const rootProcess = buildProcessTreePosix(pid);
if (!rootProcess) return;
const descendantProcessGroups = (function flatten(
processData: ProcessData,
result: Set<number> = new Set()
) {
// Process can nullify its own process group with `setpgid`. Use its PID instead.
result.add(processData.pgrp || processData.pid);
processData.children.forEach((child) => flatten(child, result));
return result;
})(rootProcess);
for (const pgrp of descendantProcessGroups) {
try {
process.kill(-pgrp, "SIGKILL");
} catch (e) {
// the process might have already stopped
}
}
}
export type CommonFixtures = {
childProcess: (params: TestChildParams) => TestChildProcess;
waitForPort: (port: number) => Promise<void>;
findFreePort: () => Promise<number>;
};
export type CommonWorkerFixtures = {
daemonProcess: (params: TestChildParams) => TestChildProcess;
};
export const commonFixtures: Fixtures<CommonFixtures, CommonWorkerFixtures> = {
childProcess: async ({}, use, testInfo) => {
const processes: TestChildProcess[] = [];
await use((params) => {
const process = new TestChildProcess(params);
processes.push(process);
return process;
});
await Promise.all(processes.map(async (child) => child.kill()));
if (
testInfo.status !== "passed" &&
testInfo.status !== "skipped" &&
!process.env.PWTEST_DEBUG
) {
for (const process of processes) {
console.log("====== " + process.params.command.join(" "));
console.log(process.fullOutput.replace(/\x1Bc/g, ""));
console.log("=========================================");
}
}
},
daemonProcess: [
async ({}, use) => {
const processes: TestChildProcess[] = [];
await use((params) => {
const process = new TestChildProcess(params);
processes.push(process);
return process;
});
await Promise.all(processes.map((child) => child.kill("SIGINT")));
},
{ scope: "worker" }
],
waitForPort: async ({}, use) => {
const token = { canceled: false };
await use(async (port) => {
while (!token.canceled) {
const promise = new Promise<boolean>((resolve) => {
const conn = net
.connect(port, "127.0.0.1")
.on("error", () => resolve(false))
.on("connect", () => {
conn.end();
resolve(true);
});
});
if (await promise) return;
await new Promise((x) => setTimeout(x, 100));
}
});
token.canceled = true;
},
findFreePort: async ({}, use) => {
await use(async () => {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, "127.0.0.1", () => {
const { port } = server.address() as net.AddressInfo;
server.close(() => resolve(port));
});
server.on("error", reject);
});
});
}
};
export function countTimes(s: string, sub: string): number {
let result = 0;
for (let index = 0; index !== -1; ) {
index = s.indexOf(sub, index);
if (index !== -1) {
result++;
index += sub.length;
}
}
return result;
}

View File

@@ -19,6 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { buildApp } from "./utils";
export default async function setup() {
export default async function globalSetup() {
console.log("Building the app...");
await buildApp();
}

View File

@@ -0,0 +1,152 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
/* eslint-disable no-empty-pattern */
import { AppContext, buildAndLaunchApp, TestOptions } from "./utils";
import path from "path";
import { version } from "../../package.json";
import type { Browser, ElectronApplication, Page } from "@playwright/test";
import type { TraceViewerFixtures } from "./trace-viewer-fixtures";
import { traceViewerFixtures } from "./trace-viewer-fixtures";
import { PageTestFixtures, PageWorkerFixtures } from "./page-test-api";
import fs from "fs";
import { tmpdir } from "os";
import { baseTest } from "./base-test";
export type ElectronTestFixtures = PageTestFixtures & {
electronApp: ElectronApplication;
launchElectronApp: (options?: TestOptions) => Promise<ElectronApplication>;
createUserDataDir: () => Promise<string>;
options: TestOptions;
newPage: () => Promise<Page>;
};
export type { Page, Browser } from "@playwright/test";
export { expect } from "@playwright/test";
export const test = baseTest
.extend<TraceViewerFixtures>(traceViewerFixtures)
.extend<ElectronTestFixtures, PageWorkerFixtures>({
browserVersion: [
({}, use) => use(process.env.ELECTRON_CHROMIUM_VERSION!),
{ scope: "worker" }
],
browserMajorVersion: [
({}, use) =>
use(Number(process.env.ELECTRON_CHROMIUM_VERSION!.split(".")[0])),
{ scope: "worker" }
],
electronMajorVersion: [
({}, use) =>
use(
parseInt(require("electron/package.json").version.split(".")[0], 10)
),
{ scope: "worker" }
],
isBidi: [false, { scope: "worker" }],
isAndroid: [false, { scope: "worker" }],
isElectron: [true, { scope: "worker" }],
isHeadlessShell: [false, { scope: "worker" }],
isFrozenWebkit: [false, { scope: "worker" }],
createUserDataDir: async ({}, run) => {
const dirs: string[] = [];
// We do not put user data dir in testOutputPath,
// because we do not want to upload them as test result artifacts.
await run(async () => {
const dir = await fs.promises.mkdtemp(
path.join(tmpdir(), "playwright-test-")
);
dirs.push(dir);
return dir;
});
await removeFolders(dirs);
},
launchElectronApp: async ({ createUserDataDir }, use) => {
// This env prevents 'Electron Security Policy' console message.
process.env["ELECTRON_DISABLE_SECURITY_WARNINGS"] = "true";
const apps: AppContext[] = [];
await use(async (options?: TestOptions) => {
const userDataDir = await createUserDataDir();
const ctx = await buildAndLaunchApp(
userDataDir,
options || {
version,
config: {}
}
);
apps.push(ctx);
return ctx.app;
});
for (const ctx of apps) {
await ctx.app.close();
}
await removeFolders(apps.map((ctx) => ctx.outputDir));
},
electronApp: async ({ launchElectronApp, options }, use) => {
await use(await launchElectronApp(options));
},
page: async ({ electronApp, viewport }, run) => {
const page = await electronApp.firstWindow();
if (viewport) {
await page.setViewportSize(viewport);
await electronApp.evaluate((p, viewport) => {
const mainWindow = p.BrowserWindow.getAllWindows()[0];
mainWindow.setSize(viewport.width, viewport.height);
}, viewport);
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
page.context().app = electronApp;
await run(page);
},
context: async ({ electronApp }, run) => {
await run(electronApp.context());
},
newPage: async ({ launchElectronApp, options }, use) => {
await use(async () => {
const app = await launchElectronApp(options);
return app.firstWindow();
});
},
options: async ({}, use) => {
await use({
version,
config: {}
});
}
});
async function removeFolders(folders: string[]) {
await Promise.all(
folders.map((folder) =>
fs.promises.rm(folder, {
force: true,
recursive: true,
maxRetries: 3,
retryDelay: 5000
})
)
);
}

View File

@@ -0,0 +1,58 @@
/* eslint-disable header/header */
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Browser, Page, ViewportSize } from "playwright-core";
import type {
PageScreenshotOptions,
ScreenshotMode,
VideoMode
} from "@playwright/test";
export { expect } from "@playwright/test";
// Page test does not guarantee an isolated context, just a new page (because Android).
export type PageTestFixtures = {
page: Page;
};
export type PageWorkerFixtures = {
headless: boolean;
channel: string | undefined;
screenshot:
| ScreenshotMode
| ({ mode: ScreenshotMode } & Pick<
PageScreenshotOptions,
"fullPage" | "omitBackground"
>);
trace:
| "off"
| "on"
| "retain-on-failure"
| "on-first-retry"
| "retain-on-first-failure"
| "on-all-retries"
| /** deprecated */ "retry-with-trace";
video: VideoMode | { mode: VideoMode; size: ViewportSize };
browserName: "chromium" | "firefox" | "webkit";
browserVersion: string;
browserMajorVersion: number;
electronMajorVersion: number;
isBidi: boolean;
isAndroid: boolean;
isElectron: boolean;
isHeadlessShell: boolean;
isFrozenWebkit: boolean;
};

View File

@@ -0,0 +1,47 @@
/* eslint-disable header/header */
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { test } from "@playwright/test";
import os from "os";
export type PlatformWorkerFixtures = {
platform: "win32" | "darwin" | "linux";
isWindows: boolean;
isMac: boolean;
isLinux: boolean;
macVersion: number; // major only, 11 or later, zero if not mac
};
function platform(): "win32" | "darwin" | "linux" {
if (process.env.PLAYWRIGHT_SERVICE_OS === "linux") return "linux";
if (process.env.PLAYWRIGHT_SERVICE_OS === "windows") return "win32";
if (process.env.PLAYWRIGHT_SERVICE_OS === "macos") return "darwin";
return process.platform as "win32" | "darwin" | "linux";
}
function macVersion() {
if (process.platform !== "darwin") return 0;
return +os.release().split(".")[0] - 9;
}
export const platformTest = test.extend<{}, PlatformWorkerFixtures>({
platform: [platform(), { scope: "worker" }],
isWindows: [platform() === "win32", { scope: "worker" }],
isMac: [platform() === "darwin", { scope: "worker" }],
isLinux: [platform() === "linux", { scope: "worker" }],
macVersion: [macVersion(), { scope: "worker" }]
});

View File

@@ -17,26 +17,10 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
testTimeout: 120 * 1000,
hookTimeout: 120 * 1000,
sequence: {
concurrent: true,
shuffle: true
},
globalSetup: "./__tests__/global-setup.ts",
dir: "./__tests__/",
exclude: [
"**/node_modules/**",
"**/dist/**",
"**/cypress/**",
"**/.{idea,git,cache,output,temp}/**",
"**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*",
"**/test-results/**",
"**/test-artifacts/**"
]
}
});
const ansiRegex = new RegExp(
"[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))",
"g"
);
export function stripAnsi(str: string): string {
return str.replace(ansiRegex, "");
}

View File

@@ -0,0 +1,75 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { test } from "@playwright/test";
import type { TestModeName } from "./test-mode";
import { DefaultTestMode, DriverTestMode } from "./test-mode";
export type TestModeWorkerOptions = {
mode: TestModeName;
};
export type TestModeTestFixtures = {
toImpl: (rpcObject?: any) => any;
};
export type TestModeWorkerFixtures = {
toImplInWorkerScope: (rpcObject?: any) => any;
playwright: typeof import("@playwright/test");
};
export const testModeTest = test.extend<
TestModeTestFixtures,
TestModeWorkerOptions & TestModeWorkerFixtures
>({
mode: ["default", { scope: "worker", option: true }],
playwright: [
async ({ mode }, run) => {
const testMode = {
default: new DefaultTestMode(),
service: new DefaultTestMode(),
service2: new DefaultTestMode(),
"service-grid": new DefaultTestMode(),
wsl: new DefaultTestMode(),
driver: new DriverTestMode()
}[mode];
const playwright = await testMode.setup();
await run(playwright);
await testMode.teardown();
},
{ scope: "worker" }
],
toImplInWorkerScope: [
async ({ playwright }, use) => {
await use((playwright as any)._connection.toImpl);
},
{ scope: "worker" }
],
toImpl: async (
{ toImplInWorkerScope: toImplWorker, mode },
use,
testInfo
) => {
if (mode !== "default" || process.env.PW_TEST_REUSE_CONTEXT)
testInfo.skip();
await use(toImplWorker);
}
});

View File

@@ -0,0 +1,59 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-ignore
import { start } from "playwright-core/lib/outofprocess";
// @ts-ignore
import type { Playwright } from "playwright-core/lib/client/playwright";
export type TestModeName =
| "default"
| "driver"
| "service"
| "service2"
| "wsl";
interface TestMode {
setup(): Promise<Playwright>;
teardown(): Promise<void>;
}
export class DriverTestMode implements TestMode {
private _impl: { playwright: Playwright; stop: () => Promise<void> };
async setup() {
this._impl = await start({
NODE_OPTIONS: undefined // Hide driver process while debugging.
});
return this._impl.playwright;
}
async teardown() {
await this._impl.stop();
}
}
export class DefaultTestMode implements TestMode {
async setup() {
return require("playwright-core");
}
async teardown() {}
}

View File

@@ -0,0 +1,243 @@
/* eslint-disable header/header */
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type {
Fixtures,
FrameLocator,
Locator,
Page,
Browser,
BrowserContext
} from "@playwright/test";
import { step } from "./base-test";
import path from "path";
import { CommonFixtures, TestChildProcess } from "./common-fixtures";
type BaseTestFixtures = CommonFixtures & {
context: BrowserContext;
};
type BaseWorkerFixtures = {
headless: boolean;
browser: Browser;
browserName: "chromium" | "firefox" | "webkit";
playwright: typeof import("@playwright/test");
};
export type TraceViewerFixtures = {
showTraceViewer: (
trace: string | undefined,
options?: { host?: string; port?: number; stdin?: boolean }
) => Promise<TraceViewerPage>;
runAndTrace: (
body: () => Promise<void>,
optsOverrides?: Parameters<BrowserContext["tracing"]["start"]>[0]
) => Promise<TraceViewerPage>;
};
class TraceViewerPage {
actionTitles: Locator;
actionsTree: Locator;
callLines: Locator;
consoleLines: Locator;
logLines: Locator;
errorMessages: Locator;
consoleLineMessages: Locator;
consoleStacks: Locator;
networkRequests: Locator;
metadataTab: Locator;
snapshotContainer: Locator;
sourceCodeTab: Locator;
networkTab: Locator;
settingsDialog: Locator;
themeSetting: Locator;
displayCanvasContentSetting: Locator;
constructor(public page: Page, public process: TestChildProcess) {
this.actionTitles = page.locator(".action-title");
this.actionsTree = page.getByTestId("actions-tree");
this.callLines = page.locator(".call-tab .call-line");
this.logLines = page
.getByRole("list", { name: "Log entries" })
.getByRole("listitem");
this.consoleLines = page
.getByRole("tabpanel", { name: "Console" })
.getByRole("listitem");
this.consoleLineMessages = page.locator(".console-line-message");
this.errorMessages = page.locator(".error-message");
this.consoleStacks = page.locator(".console-stack");
this.networkRequests = page
.getByRole("list", { name: "Network requests" })
.getByRole("listitem");
this.snapshotContainer = page.locator(
".snapshot-container iframe.snapshot-visible[name=snapshot]"
);
this.metadataTab = page.getByRole("tabpanel", { name: "Metadata" });
this.sourceCodeTab = page.getByRole("tabpanel", { name: "Source" });
this.networkTab = page.getByRole("tabpanel", { name: "Network" });
this.settingsDialog = page.getByTestId("settings-toolbar-dialog");
this.themeSetting = this.settingsDialog.getByRole("combobox", {
name: "Theme"
});
this.displayCanvasContentSetting = page
.locator(".setting")
.getByText("Display canvas content");
}
@step
async showAllActions() {
await this.page.getByRole("button", { name: "Filter actions" }).click();
await this.page.locator(".setting").getByText("Network routes").click();
await this.page.locator(".setting").getByText("Getters").click();
await this.page.locator(".setting").getByText("Configuration").click();
await this.page.getByRole("button", { name: "Filter actions" }).click();
}
stackFrames(options: { selected?: boolean } = {}) {
const entry = this.page
.getByRole("list", { name: "Stack trace" })
.getByRole("listitem");
if (options.selected) return entry.locator(":scope.selected");
return entry;
}
actionIconsText(action: string) {
const entry = this.actionsTree.getByRole("treeitem", { name: action });
return entry.locator(".action-icon-value").filter({ visible: true });
}
actionIcons(action: string) {
return this.actionsTree
.getByRole("treeitem", { name: action })
.locator(".action-icons")
.filter({ visible: true });
}
@step
async expandAction(title: string) {
await this.actionsTree
.getByRole("treeitem", { name: title })
.locator(".codicon-chevron-right")
.click();
}
@step
async selectAction(title: string, ordinal: number = 0) {
await this.actionsTree.getByTitle(title).nth(ordinal).click();
}
@step
async hoverAction(title: string, ordinal: number = 0) {
await this.actionsTree
.getByRole("treeitem", { name: title })
.nth(ordinal)
.hover();
}
@step
async selectSnapshot(name: string) {
await this.page.getByRole("tab", { name }).click();
}
async showErrorsTab() {
await this.page.getByRole("tab", { name: "Errors" }).click();
}
async showConsoleTab() {
await this.page.getByRole("tab", { name: "Console" }).click();
}
async showSourceTab() {
await this.page.getByRole("tab", { name: "Source" }).click();
}
async showNetworkTab() {
await this.page.getByRole("tab", { name: "Network" }).click();
}
async showMetadataTab() {
await this.page.getByRole("tab", { name: "Metadata" }).click();
}
async showSettings() {
await this.page.getByRole("button", { name: "Settings" }).click();
}
@step
async snapshotFrame(
actionName: string,
ordinal: number = 0,
hasSubframe: boolean = false
): Promise<FrameLocator> {
await this.selectAction(actionName, ordinal);
while (this.page.frames().length < (hasSubframe ? 4 : 3))
await this.page.waitForEvent("frameattached");
return this.page.frameLocator("iframe.snapshot-visible[name=snapshot]");
}
}
export const traceViewerFixtures: Fixtures<
TraceViewerFixtures,
{},
BaseTestFixtures,
BaseWorkerFixtures
> = {
showTraceViewer: async ({ playwright, childProcess, browserName }, use) => {
const browsers: Browser[] = [];
await use(async (trace: string | undefined, { host, port, stdin } = {}) => {
const command = [
"node",
path.join(__dirname, "../../node_modules/playwright-core/cli.js"),
"show-trace",
"--port",
"" + (port ?? "0")
];
if (host) command.push("--host", host);
if (stdin) command.push("--stdin");
if (trace) command.push(trace);
const cp = childProcess({ command });
await cp.waitForOutput("Listening on");
const browser = await playwright.chromium.launch({
...(browserName === "chromium" ? {} : { channel: "chromium" }),
executablePath: process.env.CRPATH // without this, setting FFPATH makes us launch Firefox with Chromium args
});
browsers.push(browser);
const page = await browser.newPage();
const url = cp.output.match(/Listening on (http:\/\/[^\s]+)/)![1];
await page.goto(url);
return new TraceViewerPage(page, cp);
});
for (const browser of browsers) await browser.close();
},
runAndTrace: async ({ context, showTraceViewer }, use, testInfo) => {
await use(async (body: () => Promise<void>, optsOverrides = {}) => {
const traceFile = testInfo.outputPath("trace.zip");
await context.tracing.start({
snapshots: true,
screenshots: true,
sources: true,
...optsOverrides
});
await body();
await context.tracing.stop({ path: traceFile });
return showTraceViewer(traceFile);
});
}
};

View File

@@ -19,21 +19,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { execSync } from "child_process";
import { cp } from "fs/promises";
import { fileURLToPath } from "node:url";
import path, { join, resolve } from "path";
import { _electron as electron } from "playwright";
import { _electron as electron, ElectronApplication, Page } from "playwright";
import { existsSync } from "fs";
import { mkdir, writeFile } from "node:fs/promises";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const IS_DEBUG = process.env.NN_DEBUG === "true" || process.env.CI === "true";
const productName = `NotesnookTestHarness`;
const SOURCE_DIR = resolve("output", productName);
const root = path.resolve(__dirname, "..", "..");
const SOURCE_DIR = resolve(root, "output", productName);
export interface AppContext {
app: import("playwright").ElectronApplication;
page: import("playwright").Page;
configPath: string;
userDataDir: string;
outputDir: string;
relaunch: () => Promise<void>;
@@ -41,6 +38,7 @@ export interface AppContext {
export interface TestOptions {
version: string;
config?: Record<string, unknown>;
}
export interface Fixtures {
@@ -49,35 +47,41 @@ export interface Fixtures {
}
export async function buildAndLaunchApp(
userDataDir: string,
options?: TestOptions
): Promise<AppContext> {
await buildApp(options?.version);
const productName = `notesnooktest${makeid(10)}`;
const outputDir = path.join("test-artifacts", `${productName}-output`);
const outputDir = path.join(root, "test-artifacts", `${productName}-output`);
const executablePath = await copyBuild({
...options,
outputDir
});
const { app, page, configPath, userDataDir } = await launchApp(
const configPath = path.join(userDataDir, "UserData", "config.json");
if (options?.config) {
await mkdir(path.dirname(configPath), { recursive: true });
await writeFile(configPath, JSON.stringify(options.config));
}
const { app } = await launchApp(
executablePath,
productName,
userDataDir,
options?.version
);
const ctx: AppContext = {
app,
page,
configPath,
userDataDir,
outputDir,
relaunch: async () => {
const { app, page, configPath, userDataDir } = await launchApp(
const { app } = await launchApp(
executablePath,
productName,
userDataDir,
options?.version
);
ctx.app = app;
ctx.page = page;
ctx.userDataDir = userDataDir;
ctx.configPath = configPath;
}
};
return ctx;
@@ -85,19 +89,14 @@ export async function buildAndLaunchApp(
async function launchApp(
executablePath: string,
packageName: string,
userDataDir: string,
version?: string
) {
const userDataDir = resolve(
__dirname,
"..",
"test-artifacts",
"user_data_dirs",
packageName
);
const app = await electron.launch({
executablePath,
args: IS_DEBUG ? [] : ["--hidden"],
baseURL: "https://app.notesnook.com",
acceptDownloads: true,
env: {
...(process.platform === "linux"
? {
@@ -114,20 +113,28 @@ async function launchApp(
}
});
const page = await app.firstWindow();
const configPath = path.join(userDataDir, "UserData", "config.json");
return {
app,
page,
configPath,
userDataDir
};
}
export function getAppFromPage(page: Page) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return page.context().app as ElectronApplication;
}
let MAX_RETRIES = 3;
export async function buildApp(version?: string) {
if (!existsSync(SOURCE_DIR)) {
execSync(
`node ${path.join(root, "scripts", "build.mjs")} --test --rebuild`,
{
stdio: IS_DEBUG ? "inherit" : "ignore"
}
);
const args = [
"electron-builder",
"--dir",
@@ -151,7 +158,7 @@ export async function buildApp(version?: string) {
});
} catch (e) {
if (--MAX_RETRIES) {
console.log("retrying...");
console.log("retrying...", e);
return await buildApp(version);
} else throw e;
}
@@ -189,8 +196,7 @@ async function makeBuildCopyMacOS(outputDir: string, productName: string) {
const platformDir = process.arch === "arm64" ? "mac-arm64" : "mac";
const appDir = await makeBuildCopy(outputDir, platformDir);
return resolve(
__dirname,
"..",
root,
appDir,
`${productName}.app`,
"Contents",

View File

@@ -17,24 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { testCleanup, test } from "./test-override.js";
test("make sure app loads", async ({
ctx: { page },
expect,
onTestFinished
}) => {
onTestFinished(testCleanup);
await page.waitForSelector("#authForm");
expect(
await page.getByRole("button", { name: "Create account" }).isVisible()
).toBe(true);
await page
.getByRole("button", { name: "Skip & go directly to the app" })
.click();
import { test } from "@nn/test";
test("make sure app loads", async ({ page }) => {
await page.waitForSelector(".ProseMirror");
});

View File

@@ -1,64 +0,0 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { test as vitestTest, TestContext } from "vitest";
import { buildAndLaunchApp, Fixtures, TestOptions } from "./utils";
import { mkdir, rm } from "fs/promises";
import path from "path";
import slugify from "slugify";
export const test = vitestTest.extend<Fixtures>({
options: { version: "3.0.0" } as TestOptions,
ctx: async ({ options }, use) => {
const ctx = await buildAndLaunchApp(options);
await use(ctx);
}
});
export async function testCleanup(context: TestContext) {
const ctx = (context.task.context as unknown as Fixtures).ctx;
if (context.task.result?.state === "fail") {
await mkdir("test-results", { recursive: true });
await ctx.page.screenshot({
path: path.join(
"test-results",
`${slugify(context.task.name)}-${process.platform}-${
process.arch
}-error.png`
)
});
}
await ctx.app.close();
await rm(ctx.userDataDir, {
force: true,
recursive: true,
maxRetries: 3,
retryDelay: 5000
}).catch(() => {
/*ignore */
});
await rm(ctx.outputDir, {
force: true,
recursive: true,
maxRetries: 3,
retryDelay: 5000
}).catch(() => {
/*ignore */
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,7 @@
"default": "./dist/esm/index.js"
}
},
"./testutils": "./__tests__/test-override.ts",
"./*": {
"require": {
"types": "./dist/types/*",
@@ -53,6 +54,7 @@
"zod": "3.24.3"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@streetwriters/kysely": "^0.27.4",
"@types/node": "22.15.3",
"@types/yargs": "^17.0.33",
@@ -60,9 +62,10 @@
"electron": "^37.0.0",
"electron-builder": "^26.8.1",
"esbuild": "0.21.5",
"fkill": "^10.0.3",
"node-abi": "^4.5.0",
"node-gyp-build": "^4.8.4",
"playwright": "1.48.2",
"playwright": "1.58.2",
"prebuildify": "^6.0.1",
"slugify": "1.6.6",
"tree-kill": "^1.2.2",
@@ -83,7 +86,7 @@
"bundle": "esbuild electron=./src/main.ts ./src/preload.ts --external:electron --external:fsevents --external:better-sqlite3-multiple-ciphers --external:sodium-native --bundle --outdir=./build --platform=node --tsconfig=tsconfig.json --define:MAC_APP_STORE=false --define:RELEASE=true",
"bundle:mas": "esbuild electron=./src/main.ts ./src/preload.ts --minify --external:electron --external:fsevents --bundle --outdir=./build --platform=node --tsconfig=tsconfig.json --define:MAC_APP_STORE=true --define:RELEASE=true",
"postinstall": "patch-package",
"test": "vitest run"
"test": "playwright test --tsconfig tsconfig.json"
},
"author": {
"name": "Streetwriters (Private) Limited",

View File

@@ -0,0 +1,24 @@
diff --git a/node_modules/playwright-core/lib/protocol/validator.js b/node_modules/playwright-core/lib/protocol/validator.js
index b36c979..9e8ca15 100644
--- a/node_modules/playwright-core/lib/protocol/validator.js
+++ b/node_modules/playwright-core/lib/protocol/validator.js
@@ -2536,6 +2536,7 @@ import_validatorPrimitives.scheme.ElectronLaunchParams = (0, import_validatorPri
executablePath: (0, import_validatorPrimitives.tOptional)(import_validatorPrimitives.tString),
args: (0, import_validatorPrimitives.tOptional)((0, import_validatorPrimitives.tArray)(import_validatorPrimitives.tString)),
cwd: (0, import_validatorPrimitives.tOptional)(import_validatorPrimitives.tString),
+ baseURL: (0, import_validatorPrimitives.tOptional)(import_validatorPrimitives.tString),
env: (0, import_validatorPrimitives.tOptional)((0, import_validatorPrimitives.tArray)((0, import_validatorPrimitives.tType)("NameValue"))),
timeout: import_validatorPrimitives.tFloat,
acceptDownloads: (0, import_validatorPrimitives.tOptional)((0, import_validatorPrimitives.tEnum)(["accept", "deny", "internal-browser-default"])),
diff --git a/node_modules/playwright-core/types/types.d.ts b/node_modules/playwright-core/types/types.d.ts
index f6e6165..4efd4d4 100644
--- a/node_modules/playwright-core/types/types.d.ts
+++ b/node_modules/playwright-core/types/types.d.ts
@@ -19233,6 +19233,7 @@ export interface Electron {
* Additional arguments to pass to the application when launching. You typically pass the main script name here.
*/
args?: Array<string>;
+ baseURL?: string;
/**
* Toggles bypassing page's Content-Security-Policy. Defaults to `false`.

View File

@@ -0,0 +1,92 @@
/* eslint-disable header/header */
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type {
Config,
PlaywrightTestOptions,
PlaywrightWorkerOptions
} from "@playwright/test";
import * as path from "path";
process.env.PWPAGE_IMPL = "electron";
process.env.TEST_DESKTOP = "true";
const outputDir = path.join(__dirname, "test-results");
const testDir = path.join(__dirname, "__tests__");
const config: Config<PlaywrightWorkerOptions & PlaywrightTestOptions> = {
testDir,
outputDir,
expect: {
timeout: 10000
},
use: {
acceptDownloads: true,
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retry-with-video",
viewport: {
width: 1920,
height: 1080
}
},
timeout: 60000,
globalTimeout: 5400000,
workers: process.env.CI ? 1 : undefined,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 3 : 0,
reporter: process.env.CI
? [["dot"], ["json", { outputFile: path.join(outputDir, "report.json") }]]
: "line",
projects: [],
globalSetup: "./__tests__/electron-test/global-setup.ts"
};
const metadata = {
platform: process.platform,
headless: "headed",
browserName: "electron",
channel: undefined,
mode: "default",
video: false
};
config.projects?.push({
name: "notesnook-desktop",
// Share screenshots with chromium.
snapshotPathTemplate:
"{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}-electron{ext}",
use: {
browserName: "chromium",
headless: false
},
testDir: "__tests__",
metadata
});
config.projects?.push({
name: "notesnook-web",
// Share screenshots with chromium.
snapshotPathTemplate:
"{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}-electron{ext}",
use: {
browserName: "chromium",
headless: false
},
testDir: path.resolve(__dirname, "../web/__e2e__"),
metadata
});
export default config;

View File

@@ -33,6 +33,7 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const root = args.root || path.join(__dirname, "..");
const skipTscBuild = args.skipTscBuild || false;
const buildForTesting = args.test || false;
const webAppPath = path.resolve(path.join(__dirname, "..", "..", "web"));
@@ -51,7 +52,11 @@ console.log("removed build folder");
if (args.rebuild || !existsSync(path.join(webAppPath, "build"))) {
console.log("rebuilding...");
await exec(
"node scripts/execute.mjs @notesnook/web:build:desktop",
`node scripts/execute.mjs ${
buildForTesting
? "@notesnook/web:build:test:desktop"
: "@notesnook/web:build:desktop"
}`,
path.join(__dirname, "..", "..", "..")
);
}

View File

@@ -1,9 +1,12 @@
{
"extends": "../../tsconfig",
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist/",
"lib": ["ESNext"],
"moduleResolution": "Bundler"
"moduleResolution": "Bundler",
"paths": {
"@nn/test": ["./__tests__/electron-test/index.ts"]
}
},
"include": ["src", "global.d.ts"]
"include": ["src", "global.d.ts", "__tests__"]
}

View File

@@ -18,7 +18,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { AppModel } from "./models/app.model";
import { test, expect, APP_LOCK_PASSWORD, USER } from "./utils";
import { APP_LOCK_PASSWORD, USER } from "./utils";
import { test, expect } from "@nn/test";
test("don't show status bar lock app button to unauthenticated user", async ({
page

View File

@@ -17,7 +17,8 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { AppModel } from "./models/app.model";
import { test, expect, USER } from "./utils";
import { USER } from "./utils";
import { test, expect } from "@nn/test";
test("create an unencrypted backup", async ({ page }) => {
const app = new AppModel(page);

View File

@@ -17,10 +17,10 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Page } from "@playwright/test";
import { test, expect, USER } from "./utils";
import { USER } from "./utils";
import { AppModel } from "./models/app.model";
import { PriceItem } from "./models/types";
import { test, expect, Page } from "@nn/test";
test.setTimeout(45 * 1000);
test.describe.configure({ mode: "serial" });

View File

@@ -17,7 +17,8 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { AppModel } from "./models/app.model";
import { test, expect, getTestId, NOTE } from "./utils";
import { getTestId, NOTE } from "./utils";
import { test, expect } from "@nn/test";
test("delete the last note of a color", async ({ page }) => {
const app = new AppModel(page);

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { test, expect } from "./utils";
import { test, expect } from "@nn/test";
import { AppModel } from "./models/app.model";
import { NotesViewModel } from "./models/notes-view.model";

View File

@@ -18,7 +18,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { AppModel } from "./models/app.model";
import { test, expect, NOTE, TITLE_ONLY_NOTE } from "./utils";
import { NOTE, TITLE_ONLY_NOTE } from "./utils";
import { test, expect } from "@nn/test";
test("focus mode", async ({ page }) => {
const app = new AppModel(page);

View File

@@ -0,0 +1 @@
53ad8e4e40ebebd0f400498dTest note 1 (0) Test note 1 (1) Test note 1 (2) Test note 1 (3) Test note 1 (4) Test note 1 (5) Test note 1 (6) Test note 1 (7) Test note 1 (8) Test note 1 (9)

View File

@@ -0,0 +1 @@
f054d19e9a2f46eff7b9bb25Test note 2 (0)Test note 2 (1)Test note 2 (2)Test note 2 (3)Test note 2 (4)Test note 2 (5)Test note 2 (6)Test note 2 (7)Test note 2 (8)Test note 2 (9)

View File

@@ -0,0 +1 @@
Test note 1Test note 1 (0) Test note 1 (1) Test note 1 (2) Test note 1 (3) Test note 1 (4) Test note 1 (5) Test note 1 (6) Test note 1 (7) Test note 1 (8) Test note 1 (9)

View File

@@ -0,0 +1 @@
Test note 2Test note 2 (0)Test note 2 (1)Test note 2 (2)Test note 2 (3)Test note 2 (4)Test note 2 (5)Test note 2 (6)Test note 2 (7)Test note 2 (8)Test note 2 (9)

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

View File

@@ -0,0 +1 @@
An edit I madeThis is Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1

View File

@@ -17,8 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Page } from "@playwright/test";
import { test, expect } from "./utils";
import { test, expect, Browser, Page } from "@nn/test";
import { AppModel } from "./models/app.model";
import { NoteItemModel } from "./models/note-item.model";

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Locator, Page } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
import { getTestId } from "../utils";
import { AuthModel } from "./auth.model";
import { CheckoutModel } from "./checkout.model";

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Locator, Page } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
import { getTestId } from "../utils";
import { generateSync } from "otplib";
@@ -77,7 +77,7 @@ export class AuthModel {
await this.page.locator(getTestId("close-plans")).click();
await this.page
.locator(getTestId("sync-status-synced"))
.locator(getTestId("sync-status-syncing"))
.waitFor({ state: "visible" });
}
}

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Locator, Page } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
import { getTestId } from "../utils";
export class BaseItemModel {

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Locator, Page } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
import { getTestId } from "../utils";
import { iterateList } from "./utils";
import { ContextMenuModel } from "./context-menu.model";
@@ -73,8 +73,10 @@ export class BaseViewModel {
return undefined;
}
async waitForItem(title: string) {
await this.list.locator(getTestId("title"), { hasText: title }).waitFor();
async waitForItem(title: string, timeout = 10000) {
await this.list
.locator(getTestId("title"), { hasText: title })
.waitFor({ timeout });
}
async waitForList() {

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Locator, Page } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
import { getTestId } from "../utils";
import { PriceItem } from "./types";
import { iterateList } from "./utils";

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Page, Locator } from "@playwright/test";
import type { Page, Locator } from "@playwright/test";
import { getTestId } from "../utils";
export class ContextMenuModel {

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Locator, Page } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
import { getTestId } from "../utils";
import { TabItemModel } from "./tab-item.model";
import { iterateList } from "./utils";
@@ -110,7 +110,7 @@ export class EditorModel {
await this.wordCountText.waitFor();
}
async waitForContent(text: string) {
async waitForContent(text: string, timeout = 10000) {
await this.page.waitForFunction(
({ expected }) => {
const contentElement = document.querySelector(
@@ -122,7 +122,27 @@ export class EditorModel {
.includes(expected.replace(/\s+/g, ""));
return false;
},
{ expected: text }
{ expected: text },
{ timeout }
);
}
async waitForTitle(title: string, timeout = 10000) {
await this.page.waitForFunction(
({ expected }) => {
const titleInput = document.querySelector(
`.active [data-test-id="editor-title"]`
) as HTMLInputElement | null;
if (titleInput)
return expected !== undefined
? titleInput.value === expected
: titleInput.value.length > 0;
return false;
},
{ expected: title },
{
timeout
}
);
}

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Locator } from "@playwright/test";
import type { Locator } from "@playwright/test";
import { BaseItemModel } from "./base-item.model";
import { ContextMenuModel } from "./context-menu.model";
import { NotesViewModel } from "./notes-view.model";

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Locator, Page } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
import { getTestId } from "../utils";
import { BaseViewModel } from "./base-view.model";
import { ItemModel } from "./item.model";

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Locator, Page } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
import { getTestId } from "../utils";
import { ContextMenuModel } from "./context-menu.model";
import { confirmDialog, fillItemDialog, iterateList } from "./utils";
@@ -31,6 +31,10 @@ export class NavigationMenuModel {
this.menu = page.locator(getTestId(id));
}
async waitFor() {
await this.menu.waitFor();
}
async findItem(title: string) {
for await (const item of this.iterateList()) {
const menuItem = new NavigationItemModel(item);

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Locator } from "@playwright/test";
import type { Locator } from "@playwright/test";
import { getTestId } from "../utils";
import { BaseItemModel } from "./base-item.model";
import { EditorModel } from "./editor.model";

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Locator, Page } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
import { downloadAndReadFile, getTestId } from "../utils";
import { ContextMenuModel } from "./context-menu.model";
import { ToggleModel } from "./toggle.model";

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Locator } from "@playwright/test";
import type { Locator } from "@playwright/test";
import { BaseItemModel } from "./base-item.model";
import { ContextMenuModel } from "./context-menu.model";
import { ToggleModel } from "./toggle.model";

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Locator, Page } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
import { getTestId } from "../utils";
import { BaseViewModel } from "./base-view.model";
import { NotebookItemModel } from "./notebook-item.model";

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Locator, Page } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
import { getTestId } from "../utils";
import { BaseViewModel } from "./base-view.model";
import { EditorModel } from "./editor.model";

View File

@@ -18,7 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Reminder } from "@notesnook/core";
import { Locator } from "@playwright/test";
import type { Locator } from "@playwright/test";
import { getTestId } from "../utils";
import { BaseItemModel } from "./base-item.model";
import { ContextMenuModel } from "./context-menu.model";

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Locator, Page } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
import { getTestId } from "../utils";
import { BaseViewModel } from "./base-view.model";
import { ReminderItemModel } from "./reminder-item.model";

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Page } from "@playwright/test";
import type { Page } from "@playwright/test";
import { BaseViewModel } from "./base-view.model";
import { ItemModel } from "./item.model";
import { Item } from "./types";

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Locator, Page } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
import { getTestId } from "../utils";
import { NotePropertiesModel } from "./note-properties.model";
import { SessionHistoryPreviewModel } from "./session-history-preview-model";

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Locator, Page } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
import { getTestId } from "../utils";
import { NotePropertiesModel } from "./note-properties.model";

View File

@@ -17,8 +17,13 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Page } from "@playwright/test";
import { downloadAndReadFile, getTestId, uploadFile } from "../utils";
import type { Page } from "@playwright/test";
import {
downloadAndReadFile,
getTestId,
IS_DESKTOP_TESTS,
uploadFile
} from "../utils";
import {
confirmDialog,
fillConfirmPasswordDialog,
@@ -27,6 +32,9 @@ import {
waitToHaveText
} from "./utils";
import { NavigationMenuModel } from "./navigation-menu.model";
import { AppModel } from "./app.model";
import { getAppFromPage } from "../../../desktop/__tests__/electron-test/utils";
import { readFile } from "node:fs/promises";
export class SettingsViewModel {
private readonly page: Page;
@@ -118,17 +126,35 @@ export class SettingsViewModel {
const item = await this.navigation.findItem("Backup & export");
await item?.click();
return await downloadAndReadFile(
this.page,
async () => {
const backupData = this.page
.locator(getTestId("setting-create-backup"))
.locator("select");
await backupData.selectOption({ value: "partial", label: "Backup" });
if (password) await fillPasswordDialog(this.page, password);
},
"utf-8"
);
const saveBackup = async () => {
const backupData = this.page
.locator(getTestId("setting-create-backup"))
.locator("select");
await backupData.selectOption({ value: "partial", label: "Backup" });
if (password) await fillPasswordDialog(this.page, password);
await waitForDialog(this.page, "Creating a backup");
};
if (IS_DESKTOP_TESTS) {
await saveBackup();
const toast = new AppModel(this.page).toasts.toasts.locator(
getTestId("toast-message")
);
await toast.waitFor();
const backupPath = (await toast.textContent())
?.replace("Backup saved at", "")
.trim();
if (!backupPath)
throw new Error("Backup path not found in toast message");
const app = getAppFromPage(this.page);
const resolvedDocumentPath = await app.evaluate(({ app }) =>
app.getPath("documents")
);
const resolvedBackupPath = backupPath.startsWith("documents")
? backupPath.replace("documents", resolvedDocumentPath)
: backupPath;
return await readFile(resolvedBackupPath, "utf-8");
} else return await downloadAndReadFile(this.page, saveBackup, "utf-8");
}
async restoreData(filename: string, password?: string) {
@@ -191,4 +217,16 @@ export class SettingsViewModel {
.locator("input");
await titleFormatInput.fill(format);
}
async checkForUpdates() {
await this.navigation.waitFor();
const item = await this.navigation.findItem("About");
await item?.click();
const button = this.page
.locator(getTestId("setting-version"))
.locator("button", { hasText: "Check for updates" });
await button.click();
}
}

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Locator, Page } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
import { getTestId } from "../utils";
import { BaseViewModel } from "./base-view.model";
import { NotebookItemModel } from "./notebook-item.model";

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Locator, Page } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
import { getTestId } from "../utils";
import { ContextMenuModel } from "./context-menu.model";

View File

@@ -17,11 +17,11 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Locator, Page } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
import { getTestId } from "../utils";
export class ToastsModel {
private readonly toasts: Locator;
readonly toasts: Locator;
constructor(page: Page) {
this.toasts = page.locator(".toasts-container > div");

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Locator, Page } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
import { getTestId } from "../utils";
export enum TOGGLE_STATES {

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Locator } from "@playwright/test";
import type { Locator } from "@playwright/test";
import { getTestId } from "../utils";
import { BaseItemModel } from "./base-item.model";
import { ContextMenuModel } from "./context-menu.model";

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Page } from "@playwright/test";
import type { Page } from "@playwright/test";
import { getTestId } from "../utils";
import { BaseViewModel } from "./base-view.model";
import { TrashItemModel } from "./trash-item.model";

View File

@@ -18,7 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Reminder } from "@notesnook/core";
import { Locator, Page } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
import { getTestId } from "../utils";
import { Color, Item, Notebook } from "./types";
import dayjs from "dayjs";

View File

@@ -18,7 +18,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { AppModel } from "./models/app.model";
import { test, expect, USER } from "./utils";
import { USER } from "./utils";
import { test, expect } from "@nn/test";
test("don't show monographs list to unauthenticated user", async ({ page }) => {
const app = new AppModel(page);

View File

@@ -18,7 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
/* eslint-disable no-undef */
import { test, expect } from "./utils";
import { test, expect } from "@nn/test";
import { AppModel } from "./models/app.model";
function createRoute(key: string, header: string) {

View File

@@ -17,6 +17,6 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { test } from "./utils";
import { test } from "@nn/test";
test.skip("TODO: pin 3 notebooks & make sure they can be unpinned", () => {});
test.skip("TODO: pin 4 notebooks & make sure 4th pin shows an error", () => {});

View File

@@ -20,14 +20,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { AppModel } from "./models/app.model";
import { Notebook } from "./models/types";
import {
test,
expect,
getTestId,
NOTE,
NOTEBOOK,
orderByOptions,
sortByOptions
} from "./utils";
import { test, expect } from "@nn/test";
test("create a notebook", async ({ page }) => {
const app = new AppModel(page);

View File

@@ -17,7 +17,8 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { test, expect, createHistorySession, PASSWORD } from "./utils";
import { createHistorySession, PASSWORD } from "./utils";
import { test, expect } from "@nn/test";
test.setTimeout(60 * 1000);

View File

@@ -18,7 +18,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { AppModel } from "./models/app.model";
import { expect, test, NOTE } from "./utils";
import { NOTE } from "./utils";
import { test, expect } from "@nn/test";
test.skip("TODO: make sure jump to group works", () => {});
test("#1002 Can't add a tag that's a substring of an existing tag", async ({

View File

@@ -20,14 +20,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import dayjs from "dayjs";
import { AppModel } from "./models/app.model";
import {
test,
expect,
groupByOptions,
NOTE,
orderByOptions,
PASSWORD,
sortByOptions
} from "./utils";
import { test, expect } from "@nn/test";
test("create a note", async ({ page }) => {
const app = new AppModel(page);

View File

@@ -0,0 +1,184 @@
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=Edge,chrome=1" />
<meta
name="description"
content="This is Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1"
/>
<title>Test 1</title>
<meta name="created-at" content="xxx" />
<meta name="updated-at" content="xxx" />
<link rel="stylesheet" href="https://app.notesnook.com/assets/editor-styles.css?d=1690887574068">
<style>
.image-container {
display: block;
}
.image-container.align-right {
display: flex;
justify-content: end;
}
.image-container.align-center {
display: flex;
justify-content: center;
}
.image-container.float {
float: left;
}
.image-container.float.align-right {
float: right;
}
body {
background-color: transparent !important;
color: #202124;
font-family: "Open Sans", "Noto Sans", Frutiger, Calibri, Myriad, Arial, Ubuntu, Helvetica, -apple-system, BlinkMacSystemFont, sans-serif;
}
.math-block {
padding-top: 20px;
padding-bottom: 20px;
}
h1,
h2,
h3,
h4,
h5,
h6 {
color: #212121;
}
p {
margin-bottom: 0px;
}
p[data-spacing="double"] {
margin-top: 1em;
}
p[data-spacing="single"] {
margin-top: 0px;
}
p[data-spacing="single"]:empty {
margin-top: 1em;
}
pre.codeblock {
overflow-x: auto;
}
iframe {
max-width: 100% !important;
background-color: transparent !important;
}
li > p {
margin-top: 0px;
margin-bottom: 10px;
}
.checklist > li {
list-style: none;
margin: 0.25em 0;
}
.checklist > li::before {
content: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cg%20id%3D%22checklist-unchecked%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Crect%20id%3D%22Rectangle%22%20width%3D%2215%22%20height%3D%2215%22%20x%3D%22.5%22%20y%3D%22.5%22%20fill-rule%3D%22nonzero%22%20stroke%3D%22%234C4C4C%22%20rx%3D%222%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E%0A");
cursor: pointer;
height: 1.1em;
margin-left: -2.5em;
margin-top: 0em;
position: absolute;
width: 1.5em;
padding-left: 1em;
}
.checklist li.checked::before {
content: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cg%20id%3D%22checklist-checked%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Crect%20id%3D%22Rectangle%22%20width%3D%2216%22%20height%3D%2216%22%20fill%3D%22%23008837%22%20fill-rule%3D%22nonzero%22%20rx%3D%222%22%2F%3E%3Cpath%20id%3D%22Path%22%20fill%3D%22%23FFF%22%20fill-rule%3D%22nonzero%22%20d%3D%22M11.5703186%2C3.14417309%20C11.8516238%2C2.73724603%2012.4164781%2C2.62829933%2012.83558%2C2.89774797%20C13.260121%2C3.17069355%2013.3759736%2C3.72932262%2013.0909105%2C4.14168582%20L7.7580587%2C11.8560195%20C7.43776896%2C12.3193404%206.76483983%2C12.3852142%206.35607322%2C11.9948725%20L3.02491697%2C8.8138662%20C2.66090143%2C8.46625845%202.65798871%2C7.89594698%203.01850234%2C7.54483354%20C3.373942%2C7.19866177%203.94940006%2C7.19592841%204.30829608%2C7.5386474%20L6.85276923%2C9.9684299%20L11.5703186%2C3.14417309%20Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E%0A");
}
.checklist li.checked {
color: #505050;
}
[dir="rtl"] .checklist > li::before {
margin-left: 0;
margin-right: -1.5em;
}
blockquote {
border-left: 5px solid #e5e5e5;
padding-left: 10px;
margin-top: 0px;
}
code:not(pre code) {
background-color: #f7f7f7;
border: 1px solid #e5e5e5;
border-radius: 5px;
padding: 3px 5px 0px 5px;
font-family: Hack, Consolas, "Andale Mono", "Lucida Console", "Liberation Mono", "Courier New", Courier, monospace !important;
font-size: 10pt !important;
}
.ProseMirror code > span {
font-family: Hack, Consolas, "Andale Mono", "Lucida Console", "Liberation Mono", "Courier New", Courier, monospace !important;
}
pre {
padding: 10px;
background-color: #e5e5e5;
border-radius: 5px;
font-family: Hack, Consolas, "Andale Mono", "Lucida Console", "Liberation Mono", "Courier New", Courier, monospace !important;
margin-bottom: 16px !important;
}
table {
border-collapse: collapse;
margin: 0;
overflow: hidden;
table-layout: fixed;
}
table td,
table th {
border: 1px solid #e5e5e5;
box-sizing: border-box;
min-width: 1em;
padding: 3px 5px;
position: relative;
vertical-align: top;
}
table td > *,
table th > * {
margin-bottom: 0;
}
table th {
background-color: #f7f7f7;
font-weight: bold;
text-align: left;
}
table p {
margin: 0;
}
</style>
<style>
code[class*=language-],pre[class*=language-]{color:#f8f8f2;background:0 0;text-shadow:0 1px rgba(0,0,0,.3);font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto;border-radius:.3em}:not(pre)>code[class*=language-],pre[class*=language-]{background:#282a36}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#6272a4}.token.punctuation{color:#f8f8f2}.namespace{opacity:.7}.token.constant,.token.deleted,.token.property,.token.symbol,.token.tag{color:#ff79c6}.token.boolean,.token.number{color:#bd93f9}.token.attr-name,.token.builtin,.token.char,.token.inserted,.token.selector,.token.string{color:#50fa7b}.language-css .token.string,.style .token.string,.token.entity,.token.operator,.token.url,.token.variable{color:#f8f8f2}.token.atrule,.token.attr-value,.token.class-name,.token.function{color:#f1fa8c}.token.keyword{color:#8be9fd}.token.important,.token.regex{color:#ffb86c}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}
</style>
</head>
<body>
<h1>Test 1</h1>
<p data-block-id="xxx" data-spacing="double">This is Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1</p>
</body>
</html>

View File

@@ -0,0 +1,4 @@
# Test 1
This is Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1

View File

@@ -0,0 +1,3 @@
Test 1
This is Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1

View File

@@ -19,7 +19,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { Reminder } from "@notesnook/core";
import { AppModel } from "./models/app.model";
import { test, expect, getTestId } from "./utils";
import { getTestId } from "./utils";
import { test, expect } from "@nn/test";
const ONE_TIME_REMINDER: Partial<Reminder> = {
title: "Test reminder 1",
@@ -41,7 +42,7 @@ test("add a one-time reminder", async ({ page }, info) => {
expect(reminder).toBeDefined();
page.on("dialog", (dialog) => dialog.accept());
await page.waitForEvent("dialog");
await page.waitForEvent("dialog", { timeout: 0 });
});
test("adding a one-time reminder before current time should not be possible", async ({

View File

@@ -18,7 +18,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { AppModel } from "./models/app.model";
import { test, expect, getTestId } from "./utils";
import { getTestId } from "./utils";
import { test, expect } from "@nn/test";
test("closing search via close button should clear query", async ({ page }) => {
const app = new AppModel(page);

View File

@@ -18,7 +18,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { AppModel } from "./models/app.model";
import { test, expect, NOTE } from "./utils";
import { NOTE } from "./utils";
import { test, expect } from "@nn/test";
test("ask for image compression during image upload when 'Image Compression' setting is 'Ask every time'", async ({
page

View File

@@ -16,18 +16,17 @@ GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Browser } from "@playwright/test";
import { AppModel } from "./models/app.model";
import { test, expect, USER } from "./utils";
import { USER } from "./utils";
import { test, expect, TestArgs } from "@nn/test";
// run this test file sequentially
test.describe.configure({ mode: "serial" });
async function createDevice(browser: Browser) {
// Create two isolated browser contexts
const context = await browser.newContext();
const page = await context.newPage();
async function createDevice(args: { newPage: TestArgs["newPage"] }) {
const page = await args.newPage?.();
if (!page) throw new Error("Page not found");
const app = new AppModel(page);
await app.auth.goto();
await app.auth.login(USER.CURRENT);
@@ -36,56 +35,44 @@ async function createDevice(browser: Browser) {
return app;
}
async function actAndSync<T>(
devices: AppModel[],
...actions: (Promise<T> | undefined)[]
) {
const results = await Promise.all([
...actions.filter((a) => !!a),
...devices.map((d) =>
d.waitForSync("syncing").then(() => d.waitForSync("synced"))
)
]);
await Promise.all(devices.map((d) => d.page.waitForTimeout(2000)));
return results.slice(0, actions.length) as T[];
}
test.setTimeout(120 * 1000);
test(`content edits in a note opened on 2 devices in multiple tabs should sync in real-time`, async ({
browser
newPage
}, info) => {
const NOTE = {
title: `Note ${makeid(20)}`
};
info.setTimeout(70 * 1000);
info.setTimeout(120 * 1000);
const newContent = makeid(24).repeat(2);
const [deviceA, deviceB] = await Promise.all([
createDevice(browser),
createDevice(browser)
createDevice({ newPage }),
createDevice({ newPage })
]);
const [notesA, notesB] = await Promise.all(
[deviceA, deviceB].map((d) => d.goToNotes())
);
const noteB = (
await actAndSync([deviceA, deviceB], notesB.createNote(NOTE))
)[0];
const noteA = await notesA.findNote(NOTE);
await Promise.all([noteA, noteB].map((note) => note?.openNote()));
await noteA?.openNote(true);
if ((await notesB.editor.getContent("text")) !== "")
await actAndSync([deviceA, deviceB], notesB.editor.clear());
await expect(notesA.editor.content).toBeEmpty();
await expect(notesB.editor.content).toBeEmpty();
await actAndSync([deviceA, deviceB], notesB.editor.setContent(newContent));
const noteB = await notesB.createNote(NOTE);
await notesA.waitForItem(NOTE.title, 0);
const noteA = await notesA.findNote({ title: NOTE.title });
expect(noteA).toBeDefined();
expect(noteB).toBeDefined();
await noteA?.openNote();
await noteA?.openNote(true);
await expect(notesA.editor.content).toBeEmpty();
await expect(notesB.editor.content).toBeEmpty();
await notesA.editor.setContent(newContent);
await notesB.editor.waitForContent(newContent, 0);
await expect(notesA.editor.content).toHaveText(newContent);
await expect(notesB.editor.content).toHaveText(newContent);
const tabsA = await notesA.editor.getTabs();
await tabsA[0].click();
await expect(notesA.editor.content).toHaveText(newContent);
@@ -95,40 +82,47 @@ test(`content edits in a note opened on 2 devices in multiple tabs should sync i
});
test(`title edits in a note opened on 2 devices in multiple tabs should sync in real-time`, async ({
browser
newPage
}, info) => {
const NOTE = {
title: `Note ${makeid(20)}`
};
info.setTimeout(70 * 1000);
info.setTimeout(120 * 1000);
const newContent = makeid(24).repeat(2);
const [deviceA, deviceB] = await Promise.all([
createDevice(browser),
createDevice(browser)
createDevice({
newPage
}),
createDevice({
newPage
})
]);
const [notesA, notesB] = await Promise.all(
[deviceA, deviceB].map((d) => d.goToNotes())
);
const noteB = (
await actAndSync([deviceA, deviceB], notesB.createNote(NOTE))
)[0];
const noteA = await notesA.findNote(NOTE);
await Promise.all([noteA, noteB].map((note) => note?.openNote()));
await noteA?.openNote(true);
if ((await notesB.editor.getContent("text")) !== "")
await actAndSync([deviceA, deviceB], notesB.editor.clear());
await expect(notesA.editor.content).toBeEmpty();
await expect(notesB.editor.content).toBeEmpty();
await actAndSync([deviceA, deviceB], notesB.editor.setTitle(newContent));
const noteB = await notesB.createNote(NOTE);
await notesA.waitForItem(NOTE.title, 0);
const noteA = await notesA.findNote({ title: NOTE.title });
expect(noteA).toBeDefined();
expect(noteB).toBeDefined();
await noteA?.openNote();
await noteA?.openNote(true);
await expect(notesA.editor.content).toBeEmpty();
await expect(notesB.editor.content).toBeEmpty();
await notesA.editor.setTitle(newContent);
await notesB.editor.waitForTitle(newContent, 0);
expect(await notesA.editor.getTitle()).toBe(newContent);
expect(await notesB.editor.getTitle()).toBe(newContent);
const tabsA = await notesA.editor.getTabs();
await tabsA[0].click();
expect(await notesA.editor.getTitle()).toBe(newContent);

View File

@@ -18,7 +18,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { AppModel } from "./models/app.model";
import { test, expect, createHistorySession, NOTE } from "./utils";
import { createHistorySession, NOTE } from "./utils";
import { test, expect } from "@nn/test";
test("notes should open in the same tab", async ({ page }) => {
const app = new AppModel(page);

View File

@@ -19,14 +19,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { AppModel } from "./models/app.model";
import { Item } from "./models/types";
import {
test,
expect,
getTestId,
NOTE,
orderByOptions,
sortByOptions
} from "./utils";
import { getTestId, NOTE, orderByOptions, sortByOptions } from "./utils";
import { test, expect } from "@nn/test";
const TAG: Item = { title: "hello-world" };
const EDITED_TAG: Item = { title: "hello-world-2" };

View File

@@ -18,7 +18,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { AppModel } from "./models/app.model";
import { test, expect, USER } from "./utils";
import { USER } from "./utils";
import { test, expect } from "@nn/test";
// async function forceExpireSession() {
// await page.evaluate(() => {

View File

@@ -20,7 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import fs from "fs";
import dotenv from "dotenv";
import path, { join } from "path";
import { test as base, Locator, Page } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
import {
GroupByOptions,
Notebook,
@@ -30,18 +30,7 @@ import {
import { tmpdir } from "os";
import { AppModel } from "../models/app.model";
export type { Browser, Page } from "@playwright/test";
export { expect } from "@playwright/test";
const test = base.extend<NonNullable<unknown>>({
page: async ({ page }, use) => {
const client = await page.context().newCDPSession(page);
await client.send("Emulation.setCPUThrottlingRate", {
rate: 1
});
await use(page);
}
});
export const IS_DESKTOP_TESTS = process.env.TEST_DESKTOP === "true";
type Note = {
title: string;
@@ -124,7 +113,7 @@ async function downloadAndReadFile(
page: Page,
action: () => Promise<void>,
encoding: BufferEncoding | null | undefined = "utf-8"
) {
): Promise<string | NonSharedBuffer> {
const [download] = await Promise.all([
page.waitForEvent("download"),
action()
@@ -206,7 +195,6 @@ export async function createHistorySession(page: Page, locked = false) {
}
export {
test,
USER,
NOTE,
TITLE_ONLY_NOTE,

View File

@@ -0,0 +1,53 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {
test as base,
PlaywrightTestArgs,
PlaywrightTestOptions,
PlaywrightWorkerArgs,
PlaywrightWorkerOptions
} from "@playwright/test";
import { ElectronTestFixtures } from "../../../desktop/__tests__/electron-test";
export { expect } from "@playwright/test";
export type { Page, Browser } from "@playwright/test";
export type TestArgs = PlaywrightTestArgs &
PlaywrightTestOptions &
PlaywrightWorkerArgs &
PlaywrightWorkerOptions &
Partial<ElectronTestFixtures>;
export const test = base.extend<
NonNullable<unknown> & Partial<ElectronTestFixtures>
>({
page: async ({ page }, use) => {
const client = await page.context().newCDPSession(page);
await client.send("Emulation.setCPUThrottlingRate", {
rate: 1
});
await use(page);
},
newPage: async ({ browser }, use) => {
await use(async () => {
const context = await browser.newContext();
const page = await context.newPage();
return page;
});
}
});

View File

@@ -18,7 +18,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { AppModel } from "./models/app.model";
import { test, expect, getTestId, NOTE, PASSWORD } from "./utils";
import { getTestId, NOTE, PASSWORD } from "./utils";
import { test, expect } from "@nn/test";
test("locking a note should show vault unlocked status", async ({ page }) => {
const app = new AppModel(page);

View File

@@ -87,7 +87,7 @@
"devDependencies": {
"@babel/core": "7.22.5",
"@cloudflare/workers-types": "^4.20250224.0",
"@playwright/test": "^1.58.2",
"@playwright/test": "1.58.2",
"@swc/plugin-react-remove-properties": "^6.0.2",
"@trpc/server": "10.45.2",
"@types/babel__core": "^7.20.1",
@@ -136,6 +136,7 @@
"build:test": "cross-env PLATFORM=web TEST=true vite build",
"build:beta": "cross-env PLATFORM=web BETA=true vite build",
"build:desktop": "cross-env PLATFORM=desktop vite build",
"build:test:desktop": "cross-env PLATFORM=desktop TEST=true vite build",
"analyze": "cross-env ANALYZING=true PLATFORM=web vite build",
"test": "playwright test -u",
"postinstall": "patch-package"

View File

@@ -62,6 +62,7 @@ import { UpgradeDialog } from "../dialogs/buy-dialog/upgrade-dialog";
import { setToolbarPreset } from "./toolbar-config";
import { useKeyStore } from "../interfaces/key-store";
import { TaskScheduler } from "../utils/task-scheduler";
import { path } from "@notesnook-importer/core/dist/src/utils/path";
export const CREATE_BUTTON_MAP = {
notes: {
@@ -187,7 +188,13 @@ export async function createBackup(
);
console.error(error);
} else {
showToast("success", `${strings.backupSavedAt(filePath)}`);
const backupDirectory = useSettingStore.getState().backupStorageLocation;
showToast(
"success",
IS_DESKTOP_APP
? `${strings.backupSavedAt(path.join(backupDirectory, filePath))}`
: strings.backupSuccess()
);
return true;
}
return false;

View File

@@ -137,5 +137,6 @@ function findAndCallAction(items: MenuItem[], ids: string[]) {
}
function canShowNativeMenu(items: MenuItem[]) {
if (IS_TESTING) return false;
return items.every((item) => item.type !== "popup");
}

View File

@@ -1,11 +1,14 @@
{
"extends": "../../tsconfig",
"extends": "../../tsconfig.json",
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"jsx": "react-jsx",
"moduleResolution": "Bundler",
"noEmit": true
"noEmit": true,
"paths": {
"@nn/test": ["./__e2e__/utils/test.ts"]
}
},
"include": ["src", "global.d.ts", "__tests__", "__e2e__"]
}

View File

@@ -96,6 +96,7 @@
"build",
"release",
"build:test",
"build:test:desktop",
"build:beta",
"build:desktop",
"start",