web: rewrite the whole test suite to be more resilient

**Why?**
The old test suite was a confusing mess, hard to maintain, update, and
add more tests to. It lacked a much needed layer of expressivity &
the reusable functions were all over the place.
Since it used a global `page` (by mistake), it couldn't run in parallel.
Moreover, the global `page` approach caused random flakiness.

All the above reasons led to this OM (Object Model) based approach to tests.
The tests are now much more expressive, reslient, resuable & easier to
maintain. During the rewriting process I also added a couple more
tests (about 10) so this is a net improvement.

Previously, running the tests were also quite slow (15-25s avg). This has
now been improved to (5-8s avg) by running the tests in production.
This means the app now requires to be built before running the tests:

```sh
npm run build:test:web # this is only required once
npm run test:web
```
This commit is contained in:
Abdullah Atta
2022-09-14 11:43:05 +05:00
committed by Abdullah Atta
parent a6c3aeac84
commit d31a43b463
161 changed files with 3897 additions and 2989 deletions

View File

@@ -1,250 +0,0 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2022 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/>.
*/
const { test, expect } = require("@playwright/test");
const { getTestId, loginUser } = require("./utils");
test.setTimeout(45 * 1000);
/**
* @type {import('@playwright/test').Page}
*/
var page = null;
global.page = null;
test.describe.configure({ mode: "serial" });
test.beforeAll(async ({ browser, baseURL }) => {
// Create page yourself and sign in.
const _page = await browser.newPage();
global.page = _page;
page = _page;
await page.goto(baseURL);
await page.waitForSelector(getTestId("routeHeader"));
await loginUser();
});
test.afterAll(async () => {
await page.close();
});
const plans = [
{ key: "monthly", title: "Monthly", coupon: "INTRO50" },
{ key: "yearly", title: "Yearly", coupon: "YEAR15" }
];
const pricingItems = ["subtotal", "tax", "discount", "total"];
function getPaddleTestId(id) {
return `[data-testid="${id}"]`;
}
async function getPrices() {
let prices = [];
for (let item of pricingItems) {
const price = await page.innerText(getTestId(`checkout-price-${item}`));
prices.push(`${item}: ${price}`);
}
return prices;
}
/**
*
* @param {string} prices
*/
function roundOffPrices(prices) {
return prices.replaceAll(/(\d+.\d+)/gm, (str, price) => {
const digits = "0".repeat(price.length - 1);
return isNaN(parseFloat(price)) ? 0 : `${price[0]}${digits}`;
// Math.ceil(Math.round(price) / 10) * 10;
});
}
async function getPaddleFrame(selector = "inlineComplianceBarContainer") {
await page.waitForSelector(`.checkout-container iframe`);
const paddleFrame = await (
await page.$(".checkout-container iframe")
).contentFrame();
await paddleFrame.waitForSelector(getPaddleTestId(selector));
return paddleFrame;
}
/**
*
* @param {Page} page
* @param {*} paddleFrame
* @param {*} countryCode
* @param {*} pinCode
*/
async function changeCountry(page, paddleFrame, countryCode, pinCode) {
await paddleFrame.selectOption(
getPaddleTestId("countriesSelect"),
countryCode,
{ force: true }
);
if (pinCode)
await paddleFrame.fill(getPaddleTestId("postcodeInput"), pinCode);
await paddleFrame.click(getPaddleTestId("locationFormSubmitButton"));
await paddleFrame.waitForSelector(
getPaddleTestId("inlineComplianceBarContainer")
);
await page.waitForSelector(
getTestId(`checkout-plan-country-${countryCode}`),
{ state: "attached" }
);
}
async function forEachPlan(action) {
for (let i = 0; i < plans.length; ++i) {
const plan = plans[i];
const planTestId = getTestId(`checkout-plan-${plan.key}`);
await page.isEnabled(planTestId, { timeout: 10000 });
await page.click(planTestId);
await action(plan);
if (i < plans.length - 1) {
await page.click(getTestId("checkout-plan-change"));
}
}
}
test("change plans", async () => {
await page.goto("/notes/#/buy/");
await page.waitForSelector(getTestId("see-all-plans"));
await page.click(getTestId("see-all-plans"));
await forEachPlan(async (plan) => {
await expect(
page.innerText(getTestId("checkout-plan-title"))
).resolves.toBe(plan.title);
});
});
test("confirm plan prices", async () => {
await page.goto("/#/buy/");
await page.waitForSelector(getTestId("see-all-plans"));
await page.click(getTestId("see-all-plans"));
await forEachPlan(async (plan) => {
const prices = roundOffPrices((await getPrices()).join("\n"));
expect(prices).toMatchSnapshot(`checkout-${plan.key}-prices.txt`);
});
});
test("changing locale should show localized prices", async () => {
await page.goto("/#/buy/");
await page.waitForSelector(getTestId("see-all-plans"));
await page.click(getTestId("see-all-plans"));
await forEachPlan(async (plan) => {
const paddleFrame = await getPaddleFrame();
await changeCountry(page, paddleFrame, "IN", "110001");
const prices = roundOffPrices((await getPrices()).join("\n"));
expect(prices).toMatchSnapshot(`checkout-${plan.key}-IN-prices.txt`);
});
});
test("applying coupon should change discount & total price", async () => {
await page.goto("/#/buy/");
await page.waitForSelector(getTestId("see-all-plans"));
await page.click(getTestId("see-all-plans"));
await forEachPlan(async (plan) => {
await getPaddleFrame();
await page.fill(getTestId("checkout-coupon-code"), plan.coupon);
await page.press(getTestId("checkout-coupon-code"), "Enter");
await getPaddleFrame();
await page.waitForSelector(getTestId(`checkout-plan-coupon-applied`), {
state: "attached"
});
const prices = roundOffPrices((await getPrices()).join("\n"));
expect(prices).toMatchSnapshot(
`checkout-${plan.key}-discounted-prices.txt`
);
});
});
test("apply coupon through url", async () => {
for (let plan of plans) {
await page.goto(`/#/buy/${plan.key}/${plan.coupon}`);
await getPaddleFrame();
await page.waitForSelector(getTestId(`checkout-plan-coupon-applied`), {
state: "attached"
});
const prices = roundOffPrices((await getPrices()).join("\n"));
expect(prices).toMatchSnapshot(
`checkout-${plan.key}-discounted-prices.txt`
);
}
});
test("apply coupon after changing country", async () => {
await page.goto("/#/buy/");
await page.waitForSelector(getTestId("see-all-plans"));
await page.click(getTestId("see-all-plans"));
await forEachPlan(async (plan) => {
const paddleFrame = await getPaddleFrame();
await changeCountry(page, paddleFrame, "IN", "110001");
await page.fill(getTestId("checkout-coupon-code"), plan.coupon);
await page.press(getTestId("checkout-coupon-code"), "Enter");
await getPaddleFrame();
await page.waitForSelector(getTestId(`checkout-plan-coupon-applied`), {
state: "attached"
});
const prices = roundOffPrices((await getPrices()).join("\n"));
expect(prices).toMatchSnapshot(
`checkout-${plan.key}-IN-discounted-prices.txt`
);
});
});

View File

@@ -1,5 +0,0 @@
subtotal: ₹300000
tax: ₹60000
discount: -₹200000
total: ₹200000
first month then ₹400000/mo

View File

@@ -1,5 +0,0 @@
subtotal: ₹300000
tax: ₹60000
discount: -₹200000
total: ₹200000
first month then ₹400000/mo

View File

@@ -1,5 +0,0 @@
subtotal: ₹300000
tax: ₹60000
discount: -₹200000
total: ₹200000
first month then ₹400000/mo

View File

@@ -1,5 +0,0 @@
subtotal: ₹300000
tax: ₹60000
discount: ₹0000
total: ₹400000/mo
forever

View File

@@ -1,5 +0,0 @@
subtotal: ₹300000
tax: ₹60000
discount: ₹0000
total: ₹400000/mo
forever

View File

@@ -1,5 +0,0 @@
subtotal: ₹300000
tax: ₹60000
discount: ₹0000
total: ₹400000/mo
forever

View File

@@ -1,5 +0,0 @@
subtotal: $4000
tax: $0000
discount: -$2000
total: $2000
first month then $4000/mo

View File

@@ -1,5 +0,0 @@
subtotal: $4000
tax: $0000
discount: -$2000
total: $2000
first month then $4000/mo

View File

@@ -1,5 +0,0 @@
subtotal: $4000
tax: $0000
discount: -$2000
total: $2000
first month then $4000/mo

View File

@@ -1,5 +0,0 @@
subtotal: $4000
tax: $0000
discount: $0000
total: $4000/mo
forever

View File

@@ -1,5 +0,0 @@
subtotal: $4000
tax: $0000
discount: $0000
total: $4000/mo
forever

View File

@@ -1,5 +0,0 @@
subtotal: $4000
tax: $0000
discount: $0000
total: $4000/mo
forever

View File

@@ -1,5 +0,0 @@
subtotal: ₹4000000
tax: ₹700000
discount: -₹700000
total: ₹4000000
first year then ₹4000000/yr

View File

@@ -1,5 +0,0 @@
subtotal: ₹4000000
tax: ₹700000
discount: -₹700000
total: ₹4000000
first year then ₹4000000/yr

View File

@@ -1,5 +0,0 @@
subtotal: ₹4000000
tax: ₹700000
discount: -₹700000
total: ₹4000000
first year then ₹4000000/yr

View File

@@ -1,5 +0,0 @@
subtotal: ₹4000000
tax: ₹700000
discount: ₹0000
total: ₹4000000/yr
forever

View File

@@ -1,5 +0,0 @@
subtotal: ₹4000000
tax: ₹700000
discount: ₹0000
total: ₹4000000/yr
forever

View File

@@ -1,5 +0,0 @@
subtotal: ₹4000000
tax: ₹700000
discount: ₹0000
total: ₹4000000/yr
forever

View File

@@ -1,5 +0,0 @@
subtotal: $40000
tax: $0000
discount: -$7000
total: $40000
first year then $40000/yr

View File

@@ -1,5 +0,0 @@
subtotal: $40000
tax: $0000
discount: -$7000
total: $40000
first year then $40000/yr

View File

@@ -1,5 +0,0 @@
subtotal: $40000
tax: $0000
discount: -$7000
total: $40000
first year then $40000/yr

View File

@@ -1,5 +0,0 @@
subtotal: $40000
tax: $0000
discount: $0000
total: $40000/yr
forever

View File

@@ -1,5 +0,0 @@
subtotal: $40000
tax: $0000
discount: $0000
total: $40000/yr
forever

View File

@@ -1,5 +0,0 @@
subtotal: $40000
tax: $0000
discount: $0000
total: $40000/yr
forever

View File

@@ -0,0 +1,180 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2022 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, Page, expect } from "@playwright/test";
import { USER } from "./utils";
import { AppModel } from "./models/app.model";
import { PriceItem } from "./models/types";
test.setTimeout(45 * 1000);
test.describe.configure({ mode: "serial" });
let page: Page;
let app: AppModel;
test.beforeAll(async ({ browser }) => {
const { email, key, password } = USER.CURRENT;
if (!email || !password) throw new Error("Failed to load user credentials.");
// Create page yourself and sign in.
page = await browser.newPage();
app = new AppModel(page);
await app.auth.goto();
await app.auth.login({ email, key, password });
});
test.afterAll(async () => {
await page.close();
});
test.afterEach(async () => {
await app.goto();
});
function roundOffPrices(prices: PriceItem[]) {
return prices
.map((p) => {
const price = p.value.replace(/(\d+).(\d+)/gm, (_, whole, decimal) => {
const decimalDigits = "0".repeat(decimal.length);
const wholeDigits = "0".repeat(whole.length - 1);
return `${whole[0]}${wholeDigits}.${decimalDigits}`;
});
return `${p.label}: ${price}`;
})
.join("\n");
}
test("change plans", async () => {
await app.checkout.goto();
const plans = await app.checkout.getPlans();
const titles: string[] = [];
for (const plan of plans) {
const pricing = await plan.open();
const title = await pricing.getTitle();
if (title) titles.push(title);
await pricing.changePlan();
}
expect(titles).toHaveLength(2);
expect(titles.join("").length).toBeGreaterThan(0);
});
test("confirm plan prices", async () => {
await app.checkout.goto();
const plans = await app.checkout.getPlans();
const planPrices: Record<string, string> = {};
for (const plan of plans) {
const pricing = await plan.open();
const title = await pricing.getTitle();
if (!title) continue;
planPrices[title.toLowerCase()] = roundOffPrices(await pricing.getPrices());
await pricing.changePlan();
}
for (const key in planPrices) {
expect(planPrices[key]).toMatchSnapshot(`checkout-${key}-prices.txt`);
}
});
test("changing locale should show localized prices", async () => {
await app.checkout.goto();
const plans = await app.checkout.getPlans();
const planPrices: Record<string, string> = {};
for (const plan of plans) {
const pricing = await plan.open();
const title = await pricing.getTitle();
if (!title) continue;
await pricing.changeCountry("IN", 110001);
planPrices[title.toLowerCase()] = roundOffPrices(await pricing.getPrices());
await pricing.changePlan();
}
for (const key in planPrices) {
expect(planPrices[key]).toMatchSnapshot(`checkout-${key}-IN-prices.txt`);
}
});
test("applying coupon should change discount & total price", async () => {
await app.checkout.goto();
const plans = await app.checkout.getPlans();
const planPrices: Record<string, string> = {};
for (const plan of plans) {
const pricing = await plan.open();
const title = await pricing.getTitle();
if (!title) continue;
await pricing.applyCoupon("INTRO50");
planPrices[title.toLowerCase()] = roundOffPrices(await pricing.getPrices());
await pricing.changePlan();
}
for (const key in planPrices) {
expect(planPrices[key]).toMatchSnapshot(
`checkout-${key}-discounted-prices.txt`
);
}
});
test("apply coupon through url", async () => {
const planPrices: Record<string, string> = {};
for (const plan of ["monthly", "yearly"] as const) {
await app.checkout.goto(plan, "INTRO50");
const pricing = await app.checkout.getPricing();
await pricing.waitForCoupon();
planPrices[plan] = roundOffPrices(await pricing.getPrices());
await app.goto();
}
for (const key in planPrices) {
expect(planPrices[key]).toMatchSnapshot(
`checkout-${key}-discounted-prices.txt`
);
}
});
test("apply coupon after changing country", async () => {
await app.checkout.goto();
const plans = await app.checkout.getPlans();
const planPrices: Record<string, string> = {};
for (const plan of plans) {
const pricing = await plan.open();
const title = await pricing.getTitle();
if (!title) continue;
await pricing.changeCountry("IN", 110001);
await pricing.applyCoupon("INTRO50");
planPrices[title.toLowerCase()] = roundOffPrices(await pricing.getPrices());
await pricing.changePlan();
}
for (const key in planPrices) {
expect(planPrices[key]).toMatchSnapshot(
`checkout-${key}-IN-discounted-prices.txt`
);
}
});

View File

@@ -0,0 +1,4 @@
Subtotal: ₹300.00
Sales tax: ₹60.00
Discount: -₹200.00
Total: ₹200.00

View File

@@ -0,0 +1,4 @@
Subtotal: ₹300.00
Sales tax: ₹60.00
Discount: ₹0.00
Total: ₹400.00/mo

View File

@@ -0,0 +1,4 @@
Subtotal: $4.00
Sales tax: $0.00
Discount: -$2.00
Total: $2.00

View File

@@ -0,0 +1,4 @@
Subtotal: $4.00
Sales tax: $0.00
Discount: $0.00
Total: $4.00/mo

View File

@@ -0,0 +1,4 @@
Subtotal: $4.00
Sales tax: $0.00
Discount: $0.00
Total: $4.00/mo

View File

@@ -0,0 +1,4 @@
Subtotal: $4.00
Sales tax: $0.00
Discount: $0.00
Total: $4.00/mo

View File

@@ -0,0 +1,4 @@
Subtotal: ₹4000.00
Sales tax: ₹700.00
Discount: -₹2000.00
Total: ₹2000.00

View File

@@ -0,0 +1,4 @@
Subtotal: ₹4000.00
Sales tax: ₹700.00
Discount: ₹0.00
Total: ₹4000.00/yr

View File

@@ -0,0 +1,4 @@
Subtotal: $40.00
Sales tax: $0.00
Discount: -$20.00
Total: $20.00

View File

@@ -0,0 +1,4 @@
Subtotal: $40.00
Sales tax: $0.00
Discount: $0.00
Total: $40.00/yr

View File

@@ -0,0 +1,4 @@
Subtotal: $40.00
Sales tax: $0.00
Discount: $0.00
Total: $40.00/yr

View File

@@ -0,0 +1,4 @@
Subtotal: $40.00
Sales tax: $0.00
Discount: $0.00
Total: $40.00/yr

View File

@@ -1,59 +0,0 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2022 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/>.
*/
const { test } = require("@playwright/test");
const { getTestId } = require("./utils");
const { useContextMenu, clickMenuItem } = require("./utils/actions");
const { createNoteAndCheckPresence } = require("./utils/conditions");
const Menu = require("./utils/menuitemidbuilder");
// test.skip(
// "TODO: make sure to navigate to home if there are 0 notes in a color"
// );
/**
* @type {import("@playwright/test").Page}
*/
var page = null;
global.page = null;
test.beforeEach(async ({ page: _page, baseURL }) => {
global.page = _page;
page = _page;
await page.goto(baseURL);
await page.waitForSelector(getTestId("routeHeader"));
});
test("delete the last note of a color", async ({ page }) => {
const noteSelector = await createNoteAndCheckPresence();
await useContextMenu(noteSelector, async () => {
await clickMenuItem("colors");
await clickMenuItem("red");
});
const navItem = new Menu("navitem").item("red").build();
await page.waitForSelector(navItem);
await useContextMenu(noteSelector, async () => {
await clickMenuItem("movetotrash");
});
await page.click(new Menu("navitem").item("trash").build());
});

View File

@@ -0,0 +1,48 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2022 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, expect } from "@playwright/test";
import { AppModel } from "./models/app.model";
import { NOTE } from "./utils";
test("delete the last note of a color", async ({ page }) => {
const app = new AppModel(page);
await app.goto();
const notes = await app.goToNotes();
const note = await notes.createNote(NOTE);
await note?.contextMenu.color("red");
await app.navigation.findItem("red");
await note?.contextMenu.moveToTrash();
await app.goToTrash();
expect(await app.getRouteHeader()).toBe("Trash");
});
test("rename color", async ({ page }) => {
const app = new AppModel(page);
await app.goto();
const notes = await app.goToNotes();
const note = await notes.createNote(NOTE);
await note?.contextMenu.color("red");
const colorItem = await app.navigation.findItem("red");
await colorItem?.renameColor("priority-33");
expect(await app.navigation.findItem("priority-33"));
});

View File

@@ -1,349 +0,0 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2022 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/>.
*/
const { test, expect } = require("@playwright/test");
const {
createNote,
NOTE,
getTestId,
getEditorTitle,
getEditorContent,
getEditorContentAsHTML,
editNote
} = require("./utils");
const {
checkNotePresence,
createNoteAndCheckPresence
} = require("./utils/conditions");
/**
* @type {import("@playwright/test").Page}
*/
var page = null;
global.page = null;
test.beforeEach(async ({ page: _page, baseURL }) => {
global.page = _page;
page = _page;
await page.goto(baseURL);
await page.waitForSelector(getTestId("routeHeader"));
});
test("focus mode", async () => {
await createNote(NOTE, "notes");
await page.click(getTestId("focus-mode"));
await page.waitForTimeout(500);
expect(
await page.screenshot({ fullPage: true, quality: 100, type: "jpeg" })
).toMatchSnapshot("focus-mode.jpg", { threshold: 99 });
});
test("dark mode in focus mode", async () => {
await createNote(NOTE, "notes");
await page.click(getTestId("focus-mode"));
await page.waitForTimeout(500);
await page.click(getTestId("dark-mode"));
await page.waitForTimeout(1000);
expect(
await page.screenshot({ fullPage: true, quality: 100, type: "jpeg" })
).toMatchSnapshot("dark-focus-mode.jpg", { threshold: 99 });
await page.click(getTestId("dark-mode"));
await page.waitForTimeout(1000);
expect(
await page.screenshot({ fullPage: true, quality: 100, type: "jpeg" })
).toMatchSnapshot("light-focus-mode.jpg", { threshold: 99 });
});
test("full screen in focus mode", async () => {
await createNote(NOTE, "notes");
await page.click(getTestId("focus-mode"));
await page.waitForTimeout(500);
await page.click(getTestId("enter-fullscreen"));
await page.waitForTimeout(100);
await page.click(getTestId("exit-fullscreen"));
});
test("normal mode from focus mode", async () => {
await createNote(NOTE, "notes");
await page.click(getTestId("focus-mode"));
await page.waitForTimeout(500);
await page.click(getTestId("normal-mode"));
await page.waitForTimeout(1000);
expect(
await page.screenshot({ fullPage: true, quality: 100, type: "jpeg" })
).toMatchSnapshot("normal-mode-from-focus-mode.jpg", { threshold: 99 });
});
test("creating a new note should clear the editor contents & title", async () => {
await createNoteAndCheckPresence();
await page.click(getTestId("notes-action-button"));
expect(await getEditorTitle()).toBe("");
expect(await getEditorContent()).toBe("");
});
test("creating a new note should clear the word count", async () => {
const selector = await createNoteAndCheckPresence();
await page.click(getTestId("notes-action-button"));
await page.click(selector);
await createNote({ title: "Hello World" }, "notes");
await expect(page.innerText(getTestId("editor-word-count"))).resolves.toBe(
"0 words"
);
});
test("creating a new title-only note should add it to the list", async () => {
const selector = await createNoteAndCheckPresence();
await page.click(getTestId("notes-action-button"));
await page.click(selector);
await createNoteAndCheckPresence({ title: "Hello World" });
});
test.skip("format changes should get saved", async () => {
const selector = await createNoteAndCheckPresence();
await page.click(getTestId("notes-action-button"));
await page.click(selector);
await page.waitForSelector(".mce-content-body");
await page.keyboard.press("Shift+End");
await page.click(`#editorToolbar button[title="Bold"]`);
await page.waitForTimeout(200);
await page.click(getTestId("notes-action-button"));
await page.click(selector);
const content = await getEditorContentAsHTML();
expect(content).toMatchSnapshot(`format-changes-should-get-saved.txt`);
});
test("opening an empty titled note should empty out editor contents", async () => {
await createNoteAndCheckPresence();
const onlyTitle = await createNoteAndCheckPresence({
title: "Only a title"
});
await page.click(getTestId("notes-action-button"));
await page.reload();
const fullNote = await checkNotePresence("home", 1, NOTE);
await page.click(fullNote);
await expect(getEditorContent()).resolves.toBe(NOTE.content);
await expect(getEditorTitle()).resolves.toBe(NOTE.title);
await page.click(onlyTitle);
await expect(getEditorTitle()).resolves.toBe("Only a title");
await expect(getEditorContent()).resolves.toBe("");
});
test("focus should not jump to editor while typing in title input", async () => {
await page.click(getTestId("notes-action-button"));
await page.waitForSelector(".ProseMirror");
await page.type(getTestId("editor-title"), "Hello", { delay: 200 });
await expect(getEditorTitle()).resolves.toBe("Hello");
await expect(getEditorContent()).resolves.toBe("");
});
test("select all & backspace should clear all content in editor", async () => {
const selector = await createNoteAndCheckPresence();
await page.focus(".ProseMirror");
await page.keyboard.press("Home");
await page.keyboard.press("Shift+End");
await page.waitForTimeout(500);
await page.keyboard.press("Backspace");
await page.waitForTimeout(200);
await page.click(getTestId("notes-action-button"));
await page.click(selector);
await page.waitForSelector(".ProseMirror");
await expect(getEditorContent()).resolves.toBe("");
});
test.skip("last line doesn't get saved if it's font is different", async () => {
const selector = await createNoteAndCheckPresence();
await page.keyboard.press("Enter");
await page.click(`#editorToolbar button[title="Fonts"]`);
await page.click(`div[title="Serif"]`);
await page.type(".ProseMirror", "I am another line in Serif font.");
await page.waitForTimeout(200);
await page.click(getTestId("notes-action-button"));
await page.click(selector);
const content = await getEditorContentAsHTML();
expect(content).toMatchSnapshot(`last-line-with-different-font.txt`);
});
test("editing a note and switching immediately to another note and making an edit shouldn't overlap both notes", async ({
page
}) => {
await createNoteAndCheckPresence({
title: "Test note 1",
content: "53ad8e4e40ebebd0f400498d"
});
await createNoteAndCheckPresence({
title: "Test note 2",
content: "f054d19e9a2f46eff7b9bb25"
});
const selector1 = `[data-item-index="1"] div`;
const selector2 = `[data-item-index="2"] div`;
for (let i = 0; i < 10; ++i) {
await page.click(selector2);
await editNote(null, `Test note 1 (${i}) `);
await page.click(selector2);
await editNote(null, `Test note 2 (${i})`);
}
await page.click(selector1);
expect(await getEditorContent()).toMatchSnapshot(
`fast-switch-and-edit-note-2.txt`
);
await page.click(selector2);
expect(await getEditorContent()).toMatchSnapshot(
`fast-switch-and-edit-note-1.txt`
);
});
test("editing a note and switching immediately to another note and editing the title shouldn't overlap both notes", async ({
page
}, testInfo) => {
testInfo.setTimeout(0);
await createNoteAndCheckPresence({
title: "Test note 1",
content: "53ad8e4e40ebebd0f400498d"
});
await createNoteAndCheckPresence({
title: "Test note 2",
content: "f054d19e9a2f46eff7b9bb25"
});
const selector1 = `[data-item-index="1"] div`;
const selector2 = `[data-item-index="2"] div`;
for (let i = 0; i < 10; ++i) {
await page.click(selector2);
await editNote(`Test note 1 (${i})`, null, true);
await page.waitForTimeout(100);
await page.click(selector2);
await editNote(`Test note 2 (${i})`, null, true);
await page.waitForTimeout(100);
}
await page.click(selector1);
expect(await getEditorTitle()).toMatchSnapshot(
`fast-switch-and-edit-note-title-2.txt`
);
await page.click(selector2);
expect(await getEditorTitle()).toMatchSnapshot(
`fast-switch-and-edit-note-title-1.txt`
);
});
test("editing a note and toggling read-only mode should show updated content", async () => {
const selector = await createNoteAndCheckPresence();
await page.click(getTestId("notes-action-button"));
await page.click(selector);
await editNote(null, `An edit I made`, false);
await page.click(getTestId("properties"));
await page.click(getTestId("properties-readonly"));
await page.click(getTestId("properties-close"));
expect(await getEditorContent()).toMatchSnapshot(`readonly-edited-note.txt`);
});

View File

@@ -1 +0,0 @@
<p><strong>This is Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1</strong></p>

View File

@@ -1 +0,0 @@
<p><strong>This is Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1</strong><br></p>

View File

@@ -1 +0,0 @@
<p><strong>This is Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1</strong></p>

View File

@@ -1 +0,0 @@
<p>This is Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1<span id="_mce_caret" data-mce-bogus="1"><span style="font-family: serif;">I am another line in Serif font.</span></span></p>

View File

@@ -1 +0,0 @@
<p>This is Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1<span id="_mce_caret" data-mce-bogus="1"><span style="font-family: serif;">I am another line in Serif font.</span></span><br></p>

View File

@@ -1 +0,0 @@
<p>This is Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1Test 1<span id="_mce_caret" data-mce-bogus="1"><span style="font-family: serif;">I am another line in Serif font.</span></span></p>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

View File

@@ -0,0 +1,261 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2022 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, expect } from "@playwright/test";
import { AppModel } from "./models/app.model";
import { NOTE, TITLE_ONLY_NOTE } from "./utils";
test("focus mode", async ({ page }) => {
const app = new AppModel(page);
await app.goto();
const notes = await app.goToNotes();
await notes.createNote(NOTE);
await notes.editor.enterFocusMode();
expect(await notes.editor.isFocusMode()).toBeTruthy();
expect(
await page.screenshot({ fullPage: true, quality: 100, type: "jpeg" })
).toMatchSnapshot("focus-mode.jpg", { maxDiffPixelRatio: 0.01 });
});
test("switch theme in focus mode", async ({ page }) => {
const app = new AppModel(page);
await app.goto();
const notes = await app.goToNotes();
await notes.createNote(NOTE);
await notes.editor.enterFocusMode();
await notes.editor.enterDarkMode();
expect(await notes.editor.isDarkMode()).toBeTruthy();
expect(
await page.screenshot({ fullPage: true, quality: 100, type: "jpeg" })
).toMatchSnapshot("dark-focus-mode.jpg", { maxDiffPixelRatio: 0.01 });
await notes.editor.exitDarkMode();
expect(await notes.editor.isDarkMode()).toBeFalsy();
expect(
await page.screenshot({ fullPage: true, quality: 100, type: "jpeg" })
).toMatchSnapshot("light-focus-mode.jpg", { maxDiffPixelRatio: 0.01 });
});
test("full screen in focus mode", async ({ page }) => {
const app = new AppModel(page);
await app.goto();
const notes = await app.goToNotes();
await notes.createNote(NOTE);
await notes.editor.enterFocusMode();
await notes.editor.enterFullscreen();
expect(await notes.editor.isFullscreen()).toBeTruthy();
await notes.editor.exitFullscreen();
expect(await notes.editor.isFullscreen()).toBeFalsy();
});
test("normal mode from focus mode", async ({ page }) => {
const app = new AppModel(page);
await app.goto();
const notes = await app.goToNotes();
await notes.createNote(NOTE);
await notes.editor.enterFocusMode();
await notes.editor.exitFocusMode();
expect(await notes.editor.isFocusMode()).toBeFalsy();
expect(
await page.screenshot({ fullPage: true, quality: 100, type: "jpeg" })
).toMatchSnapshot("normal-mode-from-focus-mode.jpg", {
maxDiffPixelRatio: 0.01
});
});
test("creating a new note should clear the editor contents & title", async ({
page
}) => {
const app = new AppModel(page);
await app.goto();
const notes = await app.goToNotes();
await notes.createNote(NOTE);
await notes.newNote();
expect(await notes.editor.isUnloaded()).toBeTruthy();
expect(await notes.editor.getTitle()).toBe("");
expect(await notes.editor.getContent("text")).toBe("");
});
test("creating a new title-only note should add it to the list", async ({
page
}) => {
const app = new AppModel(page);
await app.goto();
const notes = await app.goToNotes();
const note = await notes.createNote({ title: NOTE.title });
expect(note).toBeDefined();
});
test("opening an empty titled note should empty out editor contents", async ({
page
}) => {
const app = new AppModel(page);
await app.goto();
let notes = await app.goToNotes();
await notes.createNote(NOTE);
await notes.createNote(TITLE_ONLY_NOTE);
await notes.newNote();
await app.goto();
notes = await app.goToNotes();
const fullNote = await notes.findNote(NOTE);
await fullNote?.openNote();
const onlyTitle = await notes.findNote(TITLE_ONLY_NOTE);
await onlyTitle?.openNote();
expect(await notes.editor.getContent("text")).toBe("");
expect(await notes.editor.getTitle()).toBe(TITLE_ONLY_NOTE.title);
});
test("focus should not jump to editor while typing in title input", async ({
page
}) => {
const app = new AppModel(page);
await app.goto();
const notes = await app.goToNotes();
await notes.newNote();
await notes.editor.typeTitle("Hello", 200);
expect(await notes.editor.getTitle()).toBe("Hello");
expect(await notes.editor.getContent("text")).toBe("");
});
test("select all & backspace should clear all content in editor", async ({
page
}) => {
const app = new AppModel(page);
await app.goto();
const notes = await app.goToNotes();
const note = await notes.createNote(NOTE);
await notes.editor.selectAll();
await page.keyboard.press("Backspace");
await notes.editor.waitForSaving();
await notes.newNote();
await note?.openNote();
expect(await notes.editor.getContent("text")).toBe("");
});
test("editing a note and switching immediately to another note and making an edit shouldn't overlap both notes", async ({
page
}) => {
const app = new AppModel(page);
await app.goto();
const notes = await app.goToNotes();
const note1 = await notes.createNote({
title: "Test note 1",
content: "53ad8e4e40ebebd0f400498d"
});
const note2 = await notes.createNote({
title: "Test note 2",
content: "f054d19e9a2f46eff7b9bb25"
});
for (let i = 0; i < 10; ++i) {
await note1?.openNote();
await notes.editor.setContent(`Test note 1 (${i}) `);
await note2?.openNote();
await notes.editor.setContent(`Test note 2 (${i})`);
}
await notes.editor.waitForSaving();
await notes.newNote();
await note2?.openNote();
expect(await notes.editor.getContent("text")).toMatchSnapshot(
`fast-switch-and-edit-note-2.txt`
);
await note1?.openNote();
expect(await notes.editor.getContent("text")).toMatchSnapshot(
`fast-switch-and-edit-note-1.txt`
);
});
test("editing a note and switching immediately to another note and editing the title shouldn't overlap both notes", async ({
page
}) => {
const app = new AppModel(page);
await app.goto();
const notes = await app.goToNotes();
const note1 = await notes.createNote({
title: "Test note 1",
content: "53ad8e4e40ebebd0f400498d"
});
const note2 = await notes.createNote({
title: "Test note 2",
content: "f054d19e9a2f46eff7b9bb25"
});
for (let i = 0; i < 10; ++i) {
await note1?.openNote();
await notes.editor.typeTitle(`Test note 1 (${i}) `);
await note2?.openNote();
await notes.editor.typeTitle(`Test note 2 (${i})`);
}
await notes.editor.waitForSaving();
await notes.newNote();
await note2?.openNote();
expect(await notes.editor.getTitle()).toMatchSnapshot(
`fast-switch-and-edit-note-title-2.txt`
);
await note1?.openNote();
expect(await notes.editor.getTitle()).toMatchSnapshot(
`fast-switch-and-edit-note-title-1.txt`
);
});
test("editing a note and toggling read-only mode should show updated content", async ({
page
}) => {
const app = new AppModel(page);
await app.goto();
const notes = await app.goToNotes();
const note = await notes.createNote(NOTE);
await notes.newNote();
await note?.openNote();
await notes.editor.setContent(`An edit I made`);
await note?.properties.readonly();
expect(await note?.properties.isReadonly()).toBeTruthy();
expect(await notes.editor.getContent("text")).toMatchSnapshot(
`readonly-edited-note.txt`
);
});

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 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 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)

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)

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: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

View File

@@ -17,48 +17,48 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const { test, expect } = require("@playwright/test");
const { getTestId, isTestAll, loginUser } = require("./utils");
const path = require("path");
// const { test, expect } = require("@playwright/test");
// const { getTestId, isTestAll, loginUser } = require("./utils");
// const path = require("path");
/**
* @type {import("@playwright/test").Page}
*/
var page = null;
global.page = null;
test.beforeEach(async ({ page: _page, baseURL }) => {
global.page = _page;
page = _page;
await page.goto(baseURL);
await page.waitForSelector(getTestId("routeHeader"));
});
// /**
// * @type {import("@playwright/test").Page}
// */
// var page = null;
// global.page = null;
// test.beforeEach(async ({ page: _page, baseURL }) => {
// global.page = _page;
// page = _page;
// await page.goto(baseURL);
// await page.waitForSelector(getTestId("routeHeader"));
// });
if (!isTestAll()) test.skip();
// if (!isTestAll()) test.skip();
test("login user & import notes", async () => {
await loginUser();
// test("login user & import notes", async () => {
// await loginUser();
await page.click(getTestId("navitem-settings"));
// await page.click(getTestId("navitem-settings"));
await page.click(getTestId("settings-importer"));
// await page.click(getTestId("settings-importer"));
await page.click(getTestId("settings-importer-import"));
// await page.click(getTestId("settings-importer-import"));
const [fileChooser] = await Promise.all([
page.waitForEvent("filechooser"),
page.click(getTestId("import-dialog-select-files"))
]);
// const [fileChooser] = await Promise.all([
// page.waitForEvent("filechooser"),
// page.click(getTestId("import-dialog-select-files"))
// ]);
await fileChooser.setFiles(path.join(__dirname, "data", "importer-data.zip"));
// await fileChooser.setFiles(path.join(__dirname, "data", "importer-data.zip"));
await page.click(getTestId("importer-dialog-notes"));
// await page.click(getTestId("importer-dialog-notes"));
let titles = [];
for (let i = 0; i < 6; ++i) {
const noteId = getTestId(`note-${i}-title`);
const text = await page.innerText(noteId);
titles.push(text);
}
// let titles = [];
// for (let i = 0; i < 6; ++i) {
// const noteId = getTestId(`note-${i}-title`);
// const text = await page.innerText(noteId);
// titles.push(text);
// }
expect(titles.join("\n")).toMatchSnapshot("importer-notes-titles.txt");
});
// expect(titles.join("\n")).toMatchSnapshot("importer-notes-titles.txt");
// });

View File

@@ -1,6 +0,0 @@
2021-05-21T22_31_28.334+05_00
2021-11-29T10_12_57.757+05_00
2021-11-29T10_11_39.427+05_00
2021-11-29T10_08_04.344+05_00
2021-11-29T10_12_25.187+05_00
Some really amazing note

View File

@@ -1,6 +0,0 @@
2021-05-21T22_31_28.334+05_00
2021-11-29T10_12_57.757+05_00
2021-11-29T10_11_39.427+05_00
2021-11-29T10_08_04.344+05_00
2021-11-29T10_12_25.187+05_00
Some really amazing note

View File

@@ -1,11 +0,0 @@
2021-05-21T22_31_28.334+05_00
2021-11-29T10_12_57.757+05_00
2021-11-29T10_11_39.427+05_00
2021-11-29T10_08_04.344+05_00
2021-11-29T10_12_25.187+05_00
Some really amazing note

View File

@@ -0,0 +1,106 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2022 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 { Page } from "@playwright/test";
import { getTestId } from "../utils";
import { AuthModel } from "./auth.model";
import { CheckoutModel } from "./checkout.model";
import { ItemsViewModel } from "./items-view.model";
import { NavigationMenuModel } from "./navigation-menu.model";
import { NotebooksViewModel } from "./notebooks-view.model";
import { NotesViewModel } from "./notes-view.model";
import { SettingsViewModel } from "./settings-view.model";
import { ToastsModel } from "./toasts.model";
import { TrashViewModel } from "./trash-view.model";
export class AppModel {
private readonly page: Page;
readonly toasts: ToastsModel;
readonly navigation: NavigationMenuModel;
readonly auth: AuthModel;
readonly checkout: CheckoutModel;
constructor(page: Page) {
this.page = page;
this.toasts = new ToastsModel(page);
this.navigation = new NavigationMenuModel(page);
this.auth = new AuthModel(page);
this.checkout = new CheckoutModel(page);
}
async goto() {
await this.page.goto("/");
await this.getRouteHeader();
}
async goToNotes() {
await this.navigateTo("Notes");
return new NotesViewModel(this.page, "home");
}
async goToNotebooks() {
await this.navigateTo("Notebooks");
return new NotebooksViewModel(this.page);
}
async goToFavorites() {
await this.navigateTo("Favorites");
return new NotesViewModel(this.page, "notes");
}
async goToTags() {
await this.navigateTo("Tags");
return new ItemsViewModel(this.page, "tags");
}
async goToColor(color: string) {
await this.navigateTo(color);
return new NotesViewModel(this.page, "notes");
}
async goToTrash() {
await this.navigateTo("Trash");
return new TrashViewModel(this.page);
}
async goToSettings() {
await this.navigateTo("Settings");
return new SettingsViewModel(this.page);
}
private async navigateTo(title: string) {
if ((await this.getRouteHeader()) === title) return;
const item = await this.navigation.findItem(title);
await item?.click();
await this.page.waitForTimeout(1000);
}
getRouteHeader() {
return this.page.locator(getTestId("routeHeader")).inputValue();
}
async isSynced() {
return (
(await this.page
.locator(getTestId("sync-status-completed"))
.isVisible()) ||
(await this.page.locator(getTestId("sync-status-synced")).isVisible())
);
}
}

View File

@@ -0,0 +1,58 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2022 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 { Locator, Page } from "@playwright/test";
import { getTestId } from "../utils";
type User = {
email: string;
password: string;
key?: string;
};
export class AuthModel {
private readonly page: Page;
private readonly emailInput: Locator;
private readonly passwordInput: Locator;
private readonly submitButton: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.locator(getTestId("email"));
this.passwordInput = page.locator(getTestId("password"));
this.submitButton = page.locator(getTestId("submitButton"));
}
async goto() {
await this.page.goto("/login");
}
async login(user: Partial<User>) {
if (!user.email && !user.password) return;
if (user.email) await this.emailInput.fill(user.email);
if (user.password) await this.passwordInput.fill(user.password);
await this.submitButton.click();
await this.page
.locator(getTestId("sync-status-completed"))
.waitFor({ state: "visible" });
}
}

View File

@@ -0,0 +1,57 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2022 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 { Locator, Page } from "@playwright/test";
import { getTestId } from "../utils";
export class BaseItemModel {
protected readonly page: Page;
private readonly titleText: Locator;
readonly descriptionText: Locator;
constructor(protected readonly locator: Locator) {
this.page = locator.page();
this.titleText = this.locator.locator(getTestId(`title`));
this.descriptionText = this.locator.locator(getTestId(`description`));
}
async getId() {
return await this.locator.getAttribute("id");
}
async getTitle() {
if (await this.titleText.isVisible())
return (await this.titleText.textContent()) || "";
return "";
}
async getDescription() {
if (await this.descriptionText.isVisible())
return (await this.descriptionText.textContent()) || "";
return "";
}
isPresent() {
return this.locator.isVisible();
}
waitFor(state: "attached" | "detached" | "visible" | "hidden") {
return this.locator.waitFor({ state });
}
}

View File

@@ -0,0 +1,61 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2022 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 { Locator, Page } from "@playwright/test";
import { getTestId } from "../utils";
import { iterateList } from "./utils";
export class BaseViewModel {
protected readonly page: Page;
protected readonly list: Locator;
constructor(page: Page, pageId: string) {
this.page = page;
this.list = page.locator(`#${pageId} >> ${getTestId("note-list")}`);
}
async findGroup(groupName: string) {
const locator = this.list.locator(
`${getTestId(`virtuoso-item-list`)} >> ${getTestId("group-header")}`
);
for await (const item of iterateList(locator)) {
if ((await item.locator(getTestId("title")).textContent()) === groupName)
return item;
}
return undefined;
}
protected async *iterateItems() {
const locator = this.list.locator(
`${getTestId(`virtuoso-item-list`)} >> ${getTestId("list-item")}`
);
for await (const _item of iterateList(locator)) {
const id = await _item.getAttribute("id");
if (!id) return;
yield this.list.locator(`#${id}`);
}
return undefined;
}
async waitForItem(title: string) {
await this.list.locator(getTestId("title"), { hasText: title }).waitFor();
}
}

View File

@@ -0,0 +1,161 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2022 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 { Locator, Page } from "@playwright/test";
import { getTestId } from "../utils";
import { PriceItem } from "./types";
import { iterateList } from "./utils";
export class CheckoutModel {
private readonly page: Page;
private readonly seeAllPlansButton: Locator;
constructor(page: Page) {
this.page = page;
this.seeAllPlansButton = page.locator(getTestId("see-all-plans"));
}
async goto(period?: "monthly" | "yearly", coupon?: string) {
let url = "/notes/#/buy/";
if (period) url += period;
if (coupon) url += `/${coupon}`;
await this.page.goto(url);
if (!period && !coupon) await this.seeAllPlansButton.waitFor();
}
async getPlans() {
await this.seeAllPlansButton.click();
const plans: Plan[] = [];
for await (const item of iterateList(
this.page.locator(getTestId("checkout-plan"))
)) {
plans.push(new Plan(item));
}
return plans;
}
async getPricing() {
const pricing = new PricingModel(this.page);
await pricing.waitFor();
return pricing;
}
}
class Plan {
private readonly titleText: Locator;
constructor(private readonly locator: Locator) {
this.titleText = locator.locator(getTestId("title"));
}
getTitle() {
return this.titleText.textContent();
}
async open() {
await this.locator.click();
const pricing = new PricingModel(this.locator.page());
await pricing.waitFor();
return pricing;
}
}
class PricingModel {
private readonly title: Locator;
private readonly changePlanButton: Locator;
private readonly couponInput: Locator;
constructor(private readonly page: Page) {
this.title = page.locator(getTestId("checkout-plan-title"));
this.changePlanButton = page.locator(getTestId("checkout-plan-change"));
this.couponInput = page.locator(getTestId("checkout-coupon-code"));
}
async waitFor() {
await this.changePlanButton.waitFor();
}
async waitForCoupon() {
await getPaddleFrame(this.page);
await this.page.locator(getTestId(`checkout-plan-coupon-applied`)).waitFor({
state: "attached"
});
}
getTitle() {
return this.title.textContent();
}
async getPrices() {
const prices: PriceItem[] = [];
for await (const item of iterateList(
this.page.locator(getTestId("checkout-price-item"))
)) {
const label = await item.locator(getTestId("label")).innerText();
const value = await item.locator(getTestId("value")).innerText();
prices.push({ label, value });
}
return prices;
}
async applyCoupon(couponCode: string) {
await this.couponInput.fill(couponCode);
await this.couponInput.press("Enter");
await this.waitForCoupon();
}
async changeCountry(countryCode: string, pinCode?: number) {
const paddle = await getPaddleFrame(this.page);
await paddle
.locator(getPaddleTestId("countriesSelect"))
.selectOption(countryCode, { force: true });
if (pinCode)
await paddle
.locator(getPaddleTestId("postcodeInput"))
.fill(pinCode.toString());
await paddle.locator(getPaddleTestId("locationFormSubmitButton")).click();
await paddle
.locator(getPaddleTestId("inlineComplianceBarContainer"))
.waitFor();
await this.page
.locator(getTestId(`checkout-plan-country-${countryCode}`))
.waitFor({ state: "attached" });
}
async changePlan() {
await this.changePlanButton.click();
}
}
function getPaddleTestId(id: string) {
return `[data-testid="${id}"]`;
}
async function getPaddleFrame(page: Page) {
const paddleFrame = page.frameLocator(`.checkout-container iframe`);
await paddleFrame
.locator(getPaddleTestId("inlineComplianceBarContainer"))
.waitFor();
return paddleFrame;
}

View File

@@ -17,31 +17,25 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const { getTestId } = require(".");
import { Page, Locator } from "@playwright/test";
import { getTestId } from "../utils";
class MenuItemIDBuilder {
static new(type) {
return new MenuItemIDBuilder(type);
}
constructor(type) {
this.type = type;
this.suffix = "";
export class ContextMenuModel {
constructor(private readonly page: Page) {}
async open(locator: Locator) {
await locator.click({ button: "right" });
}
item(itemId) {
this.itemId = itemId;
return this;
async clickOnItem(id: string) {
await this.getItem(id).click();
}
checked() {
this.suffix = "checked";
return this;
getItem(id: string) {
return this.page.locator(getTestId(`menuitem-${id}`));
}
build() {
return getTestId(
`${this.type}-${this.itemId}${this.suffix ? `-${this.suffix}` : ""}`
);
async close() {
await this.page.keyboard.press("Escape");
}
}
module.exports = MenuItemIDBuilder;

View File

@@ -0,0 +1,240 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2022 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 { Locator, Page } from "@playwright/test";
import { getTestId } from "../utils";
export class EditorModel {
private readonly page: Page;
private readonly title: Locator;
private readonly content: Locator;
private readonly tags: Locator;
private readonly tagInput: Locator;
private readonly focusModeButton: Locator;
private readonly normalModeButton: Locator;
private readonly darkModeButton: Locator;
private readonly lightModeButton: Locator;
private readonly enterFullscreenButton: Locator;
private readonly exitFullscreenButton: Locator;
private readonly wordCountText: Locator;
private readonly dateEditedText: Locator;
private readonly searchButton: Locator;
private readonly previewCancelButton: Locator;
private readonly previewRestoreButton: Locator;
private readonly previewNotice: Locator;
constructor(page: Page) {
this.page = page;
this.title = page.locator(getTestId("editor-title"));
this.content = page.locator(".ProseMirror");
this.tagInput = page.locator(getTestId("editor-tag-input"));
this.tags = page.locator(`${getTestId("tags")} > ${getTestId("tag")}`);
this.focusModeButton = page.locator(getTestId("Focus mode"));
this.normalModeButton = page.locator(getTestId("Normal mode"));
this.darkModeButton = page.locator(getTestId("Dark mode"));
this.lightModeButton = page.locator(getTestId("Light mode"));
this.enterFullscreenButton = page.locator(getTestId("Enter fullscreen"));
this.exitFullscreenButton = page.locator(getTestId("Exit fullscreen"));
this.wordCountText = page.locator(getTestId("editor-word-count"));
this.dateEditedText = page.locator(getTestId("editor-date-edited"));
this.searchButton = page.locator(getTestId("Search"));
this.previewNotice = page.locator(getTestId("preview-notice"));
this.previewCancelButton = page.locator(getTestId("preview-notice-cancel"));
this.previewRestoreButton = page.locator(
getTestId("preview-notice-restore")
);
}
async waitForLoading(title?: string, content?: string) {
await this.title.waitFor();
await this.page.waitForFunction(
({ expected }) => {
const titleInput = document.querySelector(
`[data-test-id="editor-title"]`
) as HTMLInputElement | null;
if (titleInput)
return expected !== undefined
? titleInput.value === expected
: titleInput.value.length > 0;
return false;
},
{ expected: title }
);
if (content !== undefined)
await this.content.locator(":scope", { hasText: content }).waitFor();
}
async waitForUnloading() {
await this.page.waitForURL(/#\/notes\/create\/\d+/gm);
await this.searchButton.waitFor({ state: "hidden" });
await this.page.locator(getTestId("tags")).waitFor({ state: "hidden" });
await this.wordCountText.waitFor({ state: "hidden" });
await this.waitForLoading("", "");
}
async waitForSaving() {
await this.page.waitForURL(/#\/notes\/?.+\/edit/gm);
await this.page.locator(getTestId("tags")).waitFor();
await this.searchButton.waitFor();
await this.wordCountText.waitFor();
}
async isUnloaded() {
return (
(await this.tagInput.isHidden()) &&
(await this.darkModeButton.isHidden()) &&
(await this.enterFullscreenButton.isHidden()) &&
(await this.wordCountText.isHidden())
);
}
async setTitle(text: string) {
await this.editAndWait(async () => {
await this.title.fill(text);
});
}
async typeTitle(text: string, delay = 0) {
await this.editAndWait(async () => {
await this.title.focus();
await this.title.type(text, { delay });
});
}
async setContent(text: string) {
await this.editAndWait(async () => {
await this.content.focus();
await this.content.type(text);
});
}
private async editAndWait(action: () => Promise<void>) {
const oldDateEdited = await this.getDateEdited();
await action();
await this.page.waitForFunction(
({ oldDateEdited }) => {
const dateEditedText = document.querySelector(
`[data-test-id="editor-date-edited"]`
);
const timestampText = dateEditedText?.getAttribute("title");
if (!timestampText) return false;
const timestamp = parseInt(timestampText);
return timestamp > oldDateEdited;
},
{ oldDateEdited }
);
}
async getDateEdited() {
if (await this.dateEditedText.isHidden()) return 0;
const timestamp = await this.dateEditedText.getAttribute("title");
if (timestamp) return parseInt(timestamp);
return 0;
}
async selectAll() {
await this.content.focus();
await this.page.keyboard.press("Home");
await this.page.keyboard.press("Shift+End");
await this.page.waitForTimeout(500);
}
async setTags(tags: string[]) {
for (const tag of tags) {
await this.tagInput.fill(tag);
await this.tagInput.press("Enter");
}
}
async getTags() {
const tags: string[] = [];
const count = await this.tags.count();
for (let i = 0; i < count; ++i) {
const item = this.tags.nth(i);
const tag = await item.textContent();
if (tag) tags.push(tag);
}
return tags;
}
async getTitle() {
return this.title.inputValue();
}
async getContent(format: "html" | "text") {
return format === "html"
? await this.content.innerHTML()
: (await this.content.innerText()).trim().replace(/\n+/gm, "\n");
}
async enterFocusMode() {
await this.focusModeButton.click();
await this.normalModeButton.waitFor();
}
async exitFocusMode() {
await this.normalModeButton.click();
await this.focusModeButton.waitFor();
}
async isFocusMode() {
return await this.normalModeButton.isVisible();
}
async enterDarkMode() {
await this.darkModeButton.click();
await this.lightModeButton.waitFor();
}
async exitDarkMode() {
await this.lightModeButton.click();
await this.darkModeButton.waitFor();
}
async isDarkMode() {
return await this.lightModeButton.isVisible();
}
async enterFullscreen() {
await this.enterFullscreenButton.click();
await this.exitFullscreenButton.waitFor();
}
async exitFullscreen() {
await this.exitFullscreenButton.click();
await this.enterFullscreenButton.waitFor();
}
async isFullscreen() {
return await this.exitFullscreenButton.isVisible();
}
async cancelPreview() {
await this.previewCancelButton.click();
await this.previewNotice.waitFor({ state: "hidden" });
}
async restoreSession() {
await this.previewRestoreButton.click();
await this.previewNotice.waitFor({ state: "hidden" });
}
}

View File

@@ -0,0 +1,72 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2022 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 { Locator } from "@playwright/test";
import { BaseItemModel } from "./base-item.model";
import { ContextMenuModel } from "./context-menu.model";
import { NotesViewModel } from "./notes-view.model";
import { Item } from "./types";
import { fillItemDialog } from "./utils";
export class ItemModel extends BaseItemModel {
private readonly contextMenu: ContextMenuModel;
constructor(locator: Locator) {
super(locator);
this.contextMenu = new ContextMenuModel(this.page);
}
async open() {
await this.locator.click();
return new NotesViewModel(this.page, "notes");
}
async delete() {
await this.contextMenu.open(this.locator);
await Promise.all([
this.contextMenu.clickOnItem("delete"),
this.waitFor("detached")
]);
}
async editItem(item: Item) {
await this.contextMenu.open(this.locator);
await this.contextMenu.clickOnItem("edit");
await fillItemDialog(this.page, item);
}
async createShortcut() {
await this.contextMenu.open(this.locator);
await this.contextMenu.clickOnItem("shortcut");
}
async removeShortcut() {
await this.contextMenu.open(this.locator);
await this.contextMenu.clickOnItem("shortcut");
}
async isShortcut() {
await this.contextMenu.open(this.locator);
const state =
(await this.contextMenu.getItem("shortcut").textContent()) ===
"Remove shortcut";
await this.contextMenu.close();
return state;
}
}

View File

@@ -0,0 +1,54 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2022 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 { Locator, Page } from "@playwright/test";
import { getTestId } from "../utils";
import { BaseViewModel } from "./base-view.model";
import { ItemModel } from "./item.model";
import { Item } from "./types";
import { fillItemDialog } from "./utils";
export class ItemsViewModel extends BaseViewModel {
private readonly createButton: Locator;
constructor(page: Page, private readonly id: "topics" | "tags") {
super(page, id);
this.createButton = page.locator(getTestId(`${id}-action-button`));
}
async createItem(item: Item) {
const titleToCompare = this.id === "tags" ? `#${item.title}` : item.title;
await this.createButton.first().click();
await fillItemDialog(this.page, item);
await this.waitForItem(titleToCompare);
return await this.findItem(item);
}
async findItem(item: Item) {
const titleToCompare = this.id === "tags" ? `#${item.title}` : item.title;
for await (const _item of this.iterateItems()) {
const itemModel = new ItemModel(_item);
const title = await itemModel.getTitle();
if (title === titleToCompare) return itemModel;
}
return undefined;
}
}

View File

@@ -0,0 +1,93 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2022 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 { Locator, Page } from "@playwright/test";
import { getTestId } from "../utils";
import { ContextMenuModel } from "./context-menu.model";
import { fillItemDialog, iterateList } from "./utils";
export class NavigationMenuModel {
protected readonly page: Page;
private readonly menu: Locator;
constructor(page: Page) {
this.page = page;
this.menu = page.locator(getTestId("navigation-menu"));
}
async findItem(title: string) {
for await (const item of this.iterateList()) {
const menuItem = new NavigationItemModel(item);
if ((await menuItem.getTitle()) === title) return menuItem;
}
}
async getShortcuts() {
const shortcuts: string[] = [];
for await (const item of this.iterateList()) {
const menuItem = new NavigationItemModel(item);
if (await menuItem.isShortcut()) {
const titleText = await menuItem.getTitle();
if (titleText) shortcuts.push(titleText);
}
}
return shortcuts;
}
private async *iterateList() {
const locator = this.menu.locator(getTestId(`navigation-item`));
yield* iterateList(locator);
}
}
class NavigationItemModel {
private readonly title: Locator;
private readonly shortcut: Locator;
private readonly menu: ContextMenuModel;
private readonly page: Page;
constructor(private readonly locator: Locator) {
this.page = locator.page();
this.title = locator.locator(getTestId("title"));
this.shortcut = locator.locator(getTestId("shortcut"));
this.menu = new ContextMenuModel(this.page);
}
async click() {
await this.locator.click();
}
async isShortcut() {
return await this.shortcut.isVisible();
}
async getTitle() {
return await this.title.textContent();
}
async renameColor(alias: string) {
await this.menu.open(this.locator);
await this.menu.clickOnItem("rename");
await fillItemDialog(this.page, { title: alias });
}
async removeShortcut() {
await this.menu.open(this.locator);
await this.menu.clickOnItem("remove-shortcut");
}
}

View File

@@ -0,0 +1,69 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2022 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 { Locator } from "@playwright/test";
import { getTestId } from "../utils";
import { BaseItemModel } from "./base-item.model";
import { EditorModel } from "./editor.model";
import {
NoteContextMenuModel,
NotePropertiesModel
} from "./note-properties.model";
import { iterateList } from "./utils";
export class NoteItemModel extends BaseItemModel {
readonly properties: NotePropertiesModel;
readonly contextMenu: NoteContextMenuModel;
private readonly editor: EditorModel;
constructor(locator: Locator) {
super(locator);
this.properties = new NotePropertiesModel(this.page, locator);
this.contextMenu = new NoteContextMenuModel(this.page, locator);
this.editor = new EditorModel(this.page);
}
async openNote() {
await this.locator.click();
const title = await this.getTitle();
const description = await this.getDescription();
await this.editor.waitForLoading(title, description);
}
async openLockedNote(password: string) {
if (!(await this.contextMenu.isLocked())) return;
await this.page.locator(getTestId("unlock-note-password")).fill(password);
await this.page.locator(getTestId("unlock-note-submit")).click();
const title = await this.getTitle();
await this.editor.waitForLoading(title);
}
async getTags() {
const tags: string[] = [];
for await (const item of iterateList(
this.locator.locator(getTestId("tag-item"))
)) {
const title = await item.textContent();
if (title) tags.push(title.replace("#", ""));
}
return tags;
}
}

View File

@@ -0,0 +1,333 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2022 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 { Locator, Page } from "@playwright/test";
import { downloadAndReadFile, getTestId } from "../utils";
import { ContextMenuModel } from "./context-menu.model";
import { ToggleModel } from "./toggle.model";
import { Notebook } from "./types";
import { fillPasswordDialog, iterateList } from "./utils";
abstract class BaseProperties {
protected readonly page: Page;
private readonly pinToggle: ToggleModel;
private readonly favoriteToggle: ToggleModel;
private readonly lockToggle: ToggleModel;
constructor(
page: Page,
protected readonly noteLocator: Locator,
private readonly itemPrefix: string
) {
this.page = page;
this.pinToggle = new ToggleModel(page, `${itemPrefix}-pin`);
this.lockToggle = new ToggleModel(page, `${itemPrefix}-lock`);
this.favoriteToggle = new ToggleModel(page, `${itemPrefix}-favorite`);
}
async isPinned() {
await this.open();
const state = await this.pinToggle.isToggled();
await this.close();
return state;
}
async pin() {
await this.open();
await this.pinToggle.on();
await this.close();
}
async unpin() {
await this.open();
await this.pinToggle.off();
await this.close();
}
async lock(password: string) {
await this.open();
await this.lockToggle.on(false);
await fillPasswordDialog(this.page, password);
await this.noteLocator
.locator(getTestId("locked"))
.waitFor({ state: "visible" });
}
async unlock(password: string) {
await this.open();
await this.lockToggle.off(false);
await fillPasswordDialog(this.page, password);
await this.noteLocator
.locator(getTestId("locked"))
.waitFor({ state: "hidden" });
}
async isLocked() {
return (
(await this.noteLocator.locator(getTestId("locked")).isVisible()) &&
(await this.noteLocator.locator(getTestId(`description`)).isHidden()) &&
(await (async () => {
await this.noteLocator.click();
return await this.page
.locator(getTestId("unlock-note-title"))
.isVisible();
})()) &&
(await (async () => {
await this.open();
const state = await this.lockToggle.isToggled();
await this.close();
return state;
})())
);
}
async isFavorited() {
await this.open();
const state = await this.favoriteToggle.isToggled();
await this.close();
return state;
}
async favorite() {
await this.open();
await this.favoriteToggle.on();
await this.close();
}
async unfavorite() {
await this.open();
await this.favoriteToggle.off();
await this.close();
}
abstract isColored(color: string): Promise<boolean>;
abstract color(color: string): Promise<void>;
abstract open(): Promise<void>;
abstract close(): Promise<void>;
}
export class NotePropertiesModel extends BaseProperties {
private readonly propertiesButton: Locator;
private readonly propertiesCloseButton: Locator;
private readonly readonlyToggle: ToggleModel;
private readonly sessionItems: Locator;
constructor(page: Page, noteLocator: Locator) {
super(page, noteLocator, "properties");
this.propertiesButton = page.locator(getTestId("Properties"));
this.propertiesCloseButton = page.locator(getTestId("properties-close"));
this.readonlyToggle = new ToggleModel(page, `properties-readonly`);
this.sessionItems = page.locator(getTestId("session-item"));
}
async isColored(color: string): Promise<boolean> {
await this.open();
const state = await new ToggleModel(
this.page,
`properties-${color}`
).isToggled();
await this.close();
return state;
}
async color(color: string) {
await this.open();
await new ToggleModel(this.page, `properties-${color}`).on();
await this.close();
}
async isReadonly() {
await this.open();
const state = await this.readonlyToggle.isToggled();
await this.close();
return state;
}
async readonly() {
await this.open();
await this.readonlyToggle.on();
await this.close();
}
async editable() {
await this.open();
await this.readonlyToggle.off();
await this.close();
}
async open() {
await this.propertiesButton.click();
}
async close() {
await this.propertiesCloseButton.click();
}
async getSessionHistory() {
await this.open();
const history: SessionHistoryItemModel[] = [];
for await (const item of iterateList(this.sessionItems)) {
history.push(new SessionHistoryItemModel(this, item));
}
await this.close();
return history;
}
}
export class NoteContextMenuModel extends BaseProperties {
private readonly menu: ContextMenuModel;
constructor(page: Page, noteLocator: Locator) {
super(page, noteLocator, "menuitem");
this.menu = new ContextMenuModel(page);
}
async isColored(color: string): Promise<boolean> {
await this.open();
await this.menu.clickOnItem("colors");
const state = await new ToggleModel(
this.page,
`menuitem-${color}`
).isToggled();
await this.close();
return state;
}
async color(color: string) {
await this.open();
await this.menu.clickOnItem("colors");
await new ToggleModel(this.page, `menuitem-${color}`).on();
await this.close();
}
async moveToTrash() {
await this.open();
await Promise.all([
this.menu.clickOnItem("movetotrash"),
this.noteLocator.waitFor({ state: "detached" })
]);
}
async export(format: "html" | "md" | "txt") {
await this.open();
await this.menu.clickOnItem("export");
// we need to override date time so
// date created & date edited remain fixed.
await this.noteLocator.evaluate(() => {
// eslint-disable-next-line no-extend-native
Date.prototype.toLocaleString = () => "xxx";
});
return await downloadAndReadFile(
this.noteLocator.page(),
this.menu.getItem(format),
"utf-8"
);
}
async addToNotebook(notebook: Notebook) {
await this.open();
await this.menu.clickOnItem("addtonotebook");
const filterInput = this.page.locator(getTestId("filter-input"));
await filterInput.type(notebook.title);
await filterInput.press("Enter");
await this.page.waitForSelector(getTestId("notebook"), {
state: "visible",
strict: false
});
const notebookItems = this.page.locator(getTestId("notebook"));
for await (const item of iterateList(notebookItems)) {
const treeItem = item.locator(getTestId(`tree-item`));
const title = treeItem.locator(getTestId("title"));
const newItemButton = treeItem.locator(getTestId("tree-item-new"));
if ((await title.textContent()) === notebook.title) {
for (const topic of notebook.topics) {
await newItemButton.click();
const newItemInput = item.locator(getTestId("new-tree-item-input"));
await newItemInput.waitFor({ state: "visible" });
await newItemInput.fill(topic);
await newItemInput.press("Enter");
}
const topicItems = item.locator(getTestId("topic"));
for await (const topicItem of iterateList(topicItems)) {
const treeItem = topicItem.locator(getTestId(`tree-item`));
await treeItem.click();
}
}
}
const dialogConfirm = this.page.locator(getTestId("dialog-yes"));
await dialogConfirm.click();
await dialogConfirm.waitFor({ state: "detached" });
}
async open() {
await this.menu.open(this.noteLocator);
}
async close() {
await this.menu.close();
}
}
class SessionHistoryItemModel {
private readonly title: Locator;
private readonly page: Page;
private readonly previewNotice: Locator;
private readonly locked: Locator;
constructor(
private readonly properties: NotePropertiesModel,
private readonly locator: Locator
) {
this.page = locator.page();
this.title = locator.locator(getTestId("title"));
this.previewNotice = this.page.locator(getTestId("preview-notice"));
this.locked = locator.locator(getTestId("locked"));
}
async getTitle() {
return await this.title.textContent();
}
async preview(password?: string) {
await this.properties.open();
const isLocked = await this.locked.isVisible();
await this.locator.click();
if (password && isLocked) {
await fillPasswordDialog(this.page, password);
}
await this.previewNotice.waitFor();
}
async isLocked() {
await this.properties.open();
const state = await this.locked.isVisible();
await this.properties.close();
return state;
}
}

Some files were not shown because too many files have changed in this diff Show More