diff --git a/.github/workflows/main-docker.yml b/.github/workflows/main-docker.yml new file mode 100644 index 000000000..70a3592b1 --- /dev/null +++ b/.github/workflows/main-docker.yml @@ -0,0 +1,152 @@ +on: + push: + branches: + - "develop" + - "feature/update**" + - "feature/server_esm**" + paths-ignore: + - "docs/**" + - "bin/**" + tags: + - "v*" + workflow_dispatch: + +env: + GHCR_REGISTRY: ghcr.io + DOCKERHUB_REGISTRY: docker.io + IMAGE_NAME: ${{ github.repository }} + TEST_TAG: triliumnext/notes:test + PLATFORMS: linux/amd64,linux/arm64,linux/arm/v7 + +jobs: + test_docker: + name: Check Docker build + runs-on: ubuntu-latest + steps: + - name: Checkout the repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Set up node & dependencies + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "npm" + + - run: npm ci + + - name: Run the TypeScript build + run: npx tsc + + - name: Create server-package.json + run: cat package.json | grep -v electron > server-package.json + + - name: Build and export to Docker + uses: docker/build-push-action@v6 + with: + context: . + load: true + tags: ${{ env.TEST_TAG }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Run the container in the background + run: docker run -d --rm --name trilium_local ${{ env.TEST_TAG }} + + - name: Wait for the healthchecks to pass + uses: stringbean/docker-healthcheck-action@v1 + with: + container: trilium_local + wait-time: 50 + require-status: running + require-healthy: true + + build_docker: + name: Build Docker images + runs-on: ubuntu-latest + needs: + - test_docker + permissions: + contents: read + packages: write + attestations: write + id-token: write + steps: + - uses: actions/checkout@v4 + - name: Extract metadata (tags, labels) for GHCR image + id: ghcr-meta + uses: docker/metadata-action@v4 + with: + images: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=tag + type=sha + - name: Extract metadata (tags, labels) for DockerHub image + id: dh-meta + uses: docker/metadata-action@v4 + with: + images: ${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=tag + type=sha + - name: Set up node & dependencies + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "npm" + - run: npm ci + - name: Run the TypeScript build + run: npx tsc + - name: Create server-package.json + run: cat package.json | grep -v electron > server-package.json + - name: Log in to the GHCR container registry + uses: docker/login-action@v2 + with: + registry: ${{ env.GHCR_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/setup-buildx-action@v3 + - name: Build and push container image to GHCR + uses: docker/build-push-action@v6 + id: ghcr-push + with: + context: . + platforms: ${{ env.PLATFORMS }} + push: true + tags: ${{ steps.ghcr-meta.outputs.tags }} + labels: ${{ steps.ghcr-meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + - name: Generate and push artifact attestation to GHCR + uses: actions/attest-build-provenance@v1 + with: + subject-name: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME}} + subject-digest: ${{ steps.ghcr-push.outputs.digest }} + push-to-registry: true + - name: Log in to the DockerHub container registry + uses: docker/login-action@v2 + with: + registry: ${{ env.DOCKERHUB_REGISTRY }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Build and push image to DockerHub + uses: docker/build-push-action@v6 + id: dh-push + with: + context: . + platforms: ${{ env.PLATFORMS }} + push: true + tags: ${{ steps.dh-meta.outputs.tags }} + labels: ${{ steps.dh-meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + - name: Generate and push artifact attestation to DockerHub + uses: actions/attest-build-provenance@v1 + with: + subject-name: ${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME}} + subject-digest: ${{ steps.dh-push.outputs.digest }} + push-to-registry: true \ No newline at end of file diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ae322f7a4..66eff0d1e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -8,17 +8,15 @@ on: paths-ignore: - "docs/**" - "bin/**" + - ".github/workflows/main-docker.yml" + tags: + - "v*" workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true -env: - GHCR_REGISTRY: ghcr.io - DOCKERHUB_REGISTRY: docker.io - IMAGE_NAME: ${{ github.repository }} - jobs: build_darwin: name: Build macOS (x86_64, arm64) @@ -141,79 +139,4 @@ jobs: uses: actions/upload-artifact@v4 with: name: TriliumNext Notes for Windows (Setup) - path: out/make/squirrel.windows/x64/*.exe - build_docker: - name: Build Docker images - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - attestations: write - id-token: write - steps: - - uses: actions/checkout@v4 - - name: Extract metadata (tags, labels) for GHCR image - id: ghcr-meta - uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 - with: - images: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }} - - name: Extract metadata (tags, labels) for DockerHub image - id: dh-meta - uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 - with: - images: ${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }} - - name: Set up node & dependencies - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: "npm" - - run: npm ci - - name: Run the TypeScript build - run: npx tsc - - name: Create server-package.json - run: cat package.json | grep -v electron > server-package.json - - name: Log in to the GHCR container registry - uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 - with: - registry: ${{ env.GHCR_REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/setup-buildx-action@v3 - - name: Build and push container image to GHCR - uses: docker/build-push-action@v6 - id: ghcr-push - with: - context: . - push: true - tags: ${{ steps.ghcr-meta.outputs.tags }} - labels: ${{ steps.ghcr-meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - - name: Generate and push artifact attestation to GHCR - uses: actions/attest-build-provenance@v1 - with: - subject-name: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME}} - subject-digest: ${{ steps.ghcr-push.outputs.digest }} - push-to-registry: true - - name: Log in to the DockerHub container registry - uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 - with: - registry: ${{ env.DOCKERHUB_REGISTRY }} - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Build and push image to DockerHub - uses: docker/build-push-action@v6 - id: dh-push - with: - context: . - push: true - tags: ${{ steps.dh-meta.outputs.tags }} - labels: ${{ steps.dh-meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - - name: Generate and push artifact attestation to DockerHub - uses: actions/attest-build-provenance@v1 - with: - subject-name: ${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME}} - subject-digest: ${{ steps.dh-push.outputs.digest }} - push-to-registry: true + path: out/make/squirrel.windows/x64/*.exe \ No newline at end of file diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 000000000..467190be6 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,27 @@ +name: Playwright Tests +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: lts/* + - name: Install dependencies + run: npm ci + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run Playwright tests + run: npx playwright test + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index c87703b2b..42c96c6d3 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,14 @@ build/ src/public/app-dist/ npm-debug.log yarn-error.log + *.db +!integration-tests/db/document.db +integration-tests/db/log +integration-tests/db/sessions +integration-tests/db/backup +integration-tests/db/session_secret.txt + config.ini cert.key cert.crt @@ -18,8 +25,11 @@ tmp/ out/ -images/app-icons/png/16x16.png -images/app-icons/png/32x32.png images/app-icons/png/512x512.png images/app-icons/png/1024x1024.png -images/app-icons/mac/*.png \ No newline at end of file +images/app-icons/mac/*.png +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 8087af0af..2dc3ccef4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ # !!! Don't try to build this Dockerfile directly, run it through bin/build-docker.sh script !!! -FROM node:20.15.1-alpine +FROM node:20.15.1-bullseye-slim # Configure system dependencies -RUN apk add --no-cache --virtual .build-dependencies \ +RUN apt-get update && apt-get install -y --no-install-recommends \ autoconf \ automake \ g++ \ @@ -11,7 +11,9 @@ RUN apk add --no-cache --virtual .build-dependencies \ make \ nasm \ libpng-dev \ - python3 + python3 \ + gosu \ + && rm -rf /var/lib/apt/lists/* # Create app directory WORKDIR /usr/src/app @@ -24,27 +26,41 @@ COPY server-package.json package.json # Copy TypeScript build artifacts into the original directory structure. RUN ls RUN cp -R build/src/* src/. + +# Copy the healthcheck +RUN cp build/docker_healthcheck.js . +RUN rm docker_healthcheck.ts + RUN rm -r build # Install app dependencies -RUN set -x \ - && npm install \ - && apk del .build-dependencies \ - && npm run webpack \ - && npm prune --omit=dev \ - && cp src/public/app/share.js src/public/app-dist/. \ - && cp -r src/public/app/doc_notes src/public/app-dist/. \ - && rm -rf src/public/app \ - && rm src/services/asset_path.ts +RUN set -x +RUN npm install +RUN apt-get purge -y --auto-remove \ + autoconf \ + automake \ + g++ \ + gcc \ + libtool \ + make \ + nasm \ + libpng-dev \ + python3 \ + && rm -rf /var/lib/apt/lists/* +RUN npm run webpack +RUN npm prune --omit=dev +RUN cp src/public/app/share.js src/public/app-dist/. +RUN cp -r src/public/app/doc_notes src/public/app-dist/. +RUN rm -rf src/public/app +RUN rm src/services/asset_path.ts # Some setup tools need to be kept -RUN apk add --no-cache su-exec shadow - -# Add application user and setup proper volume permissions -RUN adduser -s /bin/false node; exit 0 +RUN apt-get update && apt-get install -y --no-install-recommends \ + gosu \ + && rm -rf /var/lib/apt/lists/* # Start the application EXPOSE 8080 CMD [ "./start-docker.sh" ] -HEALTHCHECK --start-period=10s CMD exec su-exec node node docker_healthcheck.js +HEALTHCHECK --start-period=10s CMD exec gosu node node docker_healthcheck.js \ No newline at end of file diff --git a/README.md b/README.md index 75e8d0b93..f5bb6f4e4 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [English](https://github.com/TriliumNext/Notes/blob/master/README.md) | [Chinese](https://github.com/TriliumNext/Notes/blob/master/README-ZH_CN.md) | [Russian](https://github.com/TriliumNext/Notes/blob/master/README.ru.md) | [Japanese](https://github.com/TriliumNext/Notes/blob/master/README.ja.md) | [Italian](https://github.com/TriliumNext/Notes/blob/master/README.it.md) -TriliumNext Notes is a hierarchical note taking application with focus on building large personal knowledge bases. +TriliumNext Notes is an open-source, cross-platform hierarchical note taking application with focus on building large personal knowledge bases. See [screenshots](https://triliumnext.github.io/Docs/Wiki/screenshot-tour) for quick overview: @@ -14,18 +14,13 @@ See [screenshots](https://triliumnext.github.io/Docs/Wiki/screenshot-tour) for q ## 💬 Discuss with us -Feel free to join our official discussions and community. We are focused on the development on Trilium, and would love to hear what features, suggestions, or issues you may have! +Feel free to join our official conversations. We would love to hear what features, suggestions, or issues you may have! - [Matrix](https://matrix.to/#/#triliumnext:matrix.org) (For synchronous discussions) + - The `General` Matrix room is also bridged to [XMPP](xmpp:discuss@trilium.thisgreat.party?join) - [Github Discussions](https://github.com/TriliumNext/Notes/discussions) (For Asynchronous discussions) - [Wiki](https://triliumnext.github.io/Docs/) (For common how-to questions and user guides) -The two rooms linked above are mirrored, so you can use either XMPP or Matrix, from any client you prefer, on pretty much any platform under the sun! - -### Unofficial Communities - -[Trilium Rocks](https://discord.gg/aqdX9mXX4r) - ## 🎁 Features * Notes can be arranged into arbitrarily deep tree. Single note can be placed into multiple places in the tree (see [cloning](https://triliumnext.github.io/Docs/Wiki/cloning-notes) @@ -48,28 +43,38 @@ The two rooms linked above are mirrored, so you can use either XMPP or Matrix, f * [Evernote](https://triliumnext.github.io/Docs/Wiki/evernote-import) and [Markdown import & export](https://triliumnext.github.io/Docs/Wiki/markdown) * [Web Clipper](https://triliumnext.github.io/Docs/Wiki/web-clipper) for easy saving of web content -✨ Check out the following third-party resources for more TriliumNext related goodies: +✨ Check out the following third-party resources/communities for more TriliumNext related goodies: - [awesome-trilium](https://github.com/Nriver/awesome-trilium) for 3rd party themes, scripts, plugins and more. - [TriliumRocks!](https://trilium.rocks/) for tutorials, guides, and much more. -## 🏗 Builds +## 🏗 Installation -Trilium is provided as either desktop application (Linux and Windows) or web application hosted on your server (Linux). Mac OS desktop build is available, but it is [unsupported](https://triliumnext.github.io/Docs/Wiki/faq#mac-os-support). +### Desktop -* If you want to use TriliumNext on the desktop, download binary release for your platform from [latest release](https://github.com/TriliumNext/Notes/releases/latest), unzip the package and run ```trilium``` executable. -* If you want to install TriliumNext on your own server, follow [this page](https://triliumnext.github.io/Docs/Wiki/server-installation). - * Currently only recent versions of Chrome and Firefox are supported (tested) browsers. +To use TriliumNext on your desktop machine (Linux, MacOS, and Windows) you have a few options: -TriliumNext will also provided as a Flatpak: +* Download the binary release for your platform from the [latest release page](https://github.com/TriliumNext/Notes/releases/latest), unzip the package and run the ```trilium``` executable. +* Access TriliumNext via the web interface of a server installation (see below) + * Currently only the latest versions of Chrome & Firefox are supported (and tested). +* (Coming Soon) TriliumNext will also be provided as a Flatpak - +### Mobile + +To use TriliumNext on a mobile device: + +* Use a mobile web browser to access the mobile interface of a server installation (see below) +* Use of a mobile app is not yet supported ([see here](https://github.com/TriliumNext/Notes/issues/72)) to track mobile improvements. + +### Server + +To install TriliumNext on your own server (including via Docker from [Dockerhub](https://hub.docker.com/r/triliumnext/notes)) follow [the server installation docs](https://triliumnext.github.io/Docs/Wiki/server-installation). ## 📝 Documentation [See wiki for complete list of documentation pages.](https://triliumnext.github.io/Docs) -You can also read [Patterns of personal knowledge base](https://triliumnext.github.io/Docs/Wiki/patterns-of-personal-knowledge) to get some inspiration on how you might use Trilium. +You can also read [Patterns of personal knowledge base](https://triliumnext.github.io/Docs/Wiki/patterns-of-personal-knowledge) to get some inspiration on how you might use TriliumNext. ## 💻 Contribute @@ -82,7 +87,7 @@ npm run start-server ## 👏 Shoutouts * [CKEditor 5](https://github.com/ckeditor/ckeditor5) - best WYSIWYG editor on the market, very interactive and listening team -* [FancyTree](https://github.com/mar10/fancytree) - very feature rich tree library without real competition. Trilium Notes would not be the same without it. +* [FancyTree](https://github.com/mar10/fancytree) - very feature rich tree library without real competition. TriliumNext Notes would not be the same without it. * [CodeMirror](https://github.com/codemirror/CodeMirror) - code editor with support for huge amount of languages * [jsPlumb](https://github.com/jsplumb/jsplumb) - visual connectivity library without competition. Used in [relation maps](https://triliumnext.github.io/Docs/Wiki/Relation-map) and [link maps](https://triliumnext.github.io/Docs/Wiki/Link-map) diff --git a/bin/build-docker.sh b/bin/build-docker.sh index 9d614eb2b..a765930db 100755 --- a/bin/build-docker.sh +++ b/bin/build-docker.sh @@ -10,8 +10,8 @@ cat package.json | grep -v electron > server-package.json echo "Compiling typescript..." npx tsc -sudo docker build -t zadam/trilium:$VERSION --network host -t zadam/trilium:$SERIES . +sudo docker build -t triliumnext/notes:$VERSION --network host -t triliumnext/notes:$SERIES . if [[ $VERSION != *"beta"* ]]; then - sudo docker tag zadam/trilium:$VERSION zadam/trilium:latest + sudo docker tag triliumnext/notes:$VERSION triliumnext/notes:latest fi diff --git a/bin/release.sh b/bin/release.sh index 0d4ef905d..30dd0c462 100755 --- a/bin/release.sh +++ b/bin/release.sh @@ -69,7 +69,7 @@ if [ ! -z "$GITHUB_CLI_AUTH_TOKEN" ]; then echo "$GITHUB_CLI_AUTH_TOKEN" | gh auth login --with-token fi -gh release create "$TAG" \ +gh release create -d "$TAG" \ --title "$TAG release" \ --notes "" \ $EXTRA \ diff --git a/docker-compose.yml b/docker-compose.yml index 6798574ac..d6f5a3c65 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,21 @@ # Running `docker-compose up` will create/use the "trilium-data" directory in the user home # Run `TRILIUM_DATA_DIR=/path/of/your/choice docker-compose up` to set a different directory -version: '2.1' +# To run in the background, use `docker-compose up -d` services: trilium: - image: zadam/trilium - restart: always + # Optionally, replace `latest` with a version tag like `v0.90.3` + # Using `latest` may cause unintended updates to the container + image: triliumnext/notes:latest + # Restart the container unless it was stopped by the user + restart: unless-stopped environment: - TRILIUM_DATA_DIR=/home/node/trilium-data ports: - - "8080:8080" + # By default, Trilium will be available at http://localhost:8080 + # It will also be accessible at http://:8080 + # You might want to limit this with something like Docker Networks, reverse proxies, or firewall rules, such as UFW + - '8080:8080' volumes: + # Unless TRILIUM_DATA_DIR is set, the data will be stored in the "trilium-data" directory in the home directory. + # This can also be changed with by replacing the line below with `- /path/of/your/choice:/home/node/trilium-data - ${TRILIUM_DATA_DIR:-~/trilium-data}:/home/node/trilium-data - -volumes: - trilium: diff --git a/docker_healthcheck.js b/docker_healthcheck.ts similarity index 72% rename from docker_healthcheck.js rename to docker_healthcheck.ts index 9761aebe2..c11c853a4 100755 --- a/docker_healthcheck.js +++ b/docker_healthcheck.ts @@ -1,7 +1,7 @@ -const http = require("http"); -const ini = require("ini"); -const fs = require("fs"); -const dataDir = require('./src/services/data_dir'); +import http from "http"; +import ini from "ini"; +import fs from "fs"; +import dataDir from './src/services/data_dir.js'; const config = ini.parse(fs.readFileSync(dataDir.CONFIG_INI_PATH, 'utf-8')); if (config.Network.https) { @@ -10,12 +10,12 @@ if (config.Network.https) { process.exit(0); } -const port = require('./src/services/port'); -const host = require('./src/services/host'); +import port from './src/services/port.js'; +import host from './src/services/host.js'; -const options = { timeout: 2000 }; +const options: http.RequestOptions = { timeout: 2000 }; -const callback = res => { +const callback: (res: http.IncomingMessage) => void = res => { console.log(`STATUS: ${res.statusCode}`); if (res.statusCode === 200) { process.exit(0); diff --git a/images/app-icons/png/16x16.png b/images/app-icons/png/16x16.png new file mode 100644 index 000000000..4645fe056 Binary files /dev/null and b/images/app-icons/png/16x16.png differ diff --git a/images/app-icons/png/32x32.png b/images/app-icons/png/32x32.png new file mode 100644 index 000000000..dbe57df0e Binary files /dev/null and b/images/app-icons/png/32x32.png differ diff --git a/integration-tests/auth.setup.ts b/integration-tests/auth.setup.ts new file mode 100644 index 000000000..ed27ca648 --- /dev/null +++ b/integration-tests/auth.setup.ts @@ -0,0 +1,17 @@ +import { test as setup, expect } from '@playwright/test'; + +const authFile = 'playwright/.auth/user.json'; + +const ROOT_URL = "http://localhost:8082"; +const LOGIN_PASSWORD = "demo1234"; + +// Reference: https://playwright.dev/docs/auth#basic-shared-account-in-all-tests + +setup("authenticate", async ({ page }) => { + await page.goto(ROOT_URL); + await expect(page).toHaveURL(`${ROOT_URL}/login`); + + await page.getByRole("textbox", { name: "Password" }).fill(LOGIN_PASSWORD); + await page.getByRole("button", { name: "Login"}).click(); + await page.context().storageState({ path: authFile }); +}); \ No newline at end of file diff --git a/integration-tests/db/document.db b/integration-tests/db/document.db new file mode 100644 index 000000000..4857edcd9 Binary files /dev/null and b/integration-tests/db/document.db differ diff --git a/integration-tests/duplicate.spec.ts b/integration-tests/duplicate.spec.ts new file mode 100644 index 000000000..7decbd7f0 --- /dev/null +++ b/integration-tests/duplicate.spec.ts @@ -0,0 +1,9 @@ +import { test, expect } from '@playwright/test'; + +test("Can duplicate note with broken links", async ({ page }) => { + await page.goto(`http://localhost:8082/#2VammGGdG6Ie`); + await page.locator('.tree-wrapper .fancytree-active').getByText('Note map').click({ button: 'right' }); + await page.getByText('Duplicate subtree').click(); + await expect(page.locator(".toast-body")).toBeHidden(); + await expect(page.locator('.tree-wrapper').getByText('Note map (dup)')).toBeVisible(); +}); \ No newline at end of file diff --git a/integration-tests/example.disabled.ts b/integration-tests/example.disabled.ts new file mode 100644 index 000000000..54a906a4e --- /dev/null +++ b/integration-tests/example.disabled.ts @@ -0,0 +1,18 @@ +import { test, expect } from '@playwright/test'; + +test('has title', async ({ page }) => { + await page.goto('https://playwright.dev/'); + + // Expect a title "to contain" a substring. + await expect(page).toHaveTitle(/Playwright/); +}); + +test('get started link', async ({ page }) => { + await page.goto('https://playwright.dev/'); + + // Click the get started link. + await page.getByRole('link', { name: 'Get started' }).click(); + + // Expects page to have a heading with the name of Installation. + await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible(); +}); diff --git a/integration-tests/katex.disabled.ts b/integration-tests/katex.disabled.ts new file mode 100644 index 000000000..c1ce0d9d7 --- /dev/null +++ b/integration-tests/katex.disabled.ts @@ -0,0 +1,18 @@ +import { test, expect } from '@playwright/test'; + +const ROOT_URL = "http://localhost:8080"; +const LOGIN_PASSWORD = "eliandoran"; + +test("Can insert equations", async ({ page }) => { + await page.setDefaultTimeout(60_000); + await page.setDefaultNavigationTimeout(60_000); + + // Create a new note + // await page.locator("button.button-widget.bx-file-blank") + // .click(); + + const activeNote = page.locator(".component.note-split:visible"); + const noteContent = activeNote + .locator(".note-detail-editable-text-editor") + await noteContent.press("Ctrl+M"); +}); \ No newline at end of file diff --git a/integration-tests/update_check.spec.ts b/integration-tests/update_check.spec.ts new file mode 100644 index 000000000..207395296 --- /dev/null +++ b/integration-tests/update_check.spec.ts @@ -0,0 +1,12 @@ +import { test, expect } from '@playwright/test'; + +const expectedVersion = "0.90.3"; + +test("Displays update badge when there is a version available", async ({ page }) => { + await page.goto("http://localhost:8080"); + await page.getByRole('button', { name: '' }).click(); + await page.getByText(`Version ${expectedVersion} is available,`).click(); + + const page1 = await page.waitForEvent('popup'); + expect(page1.url()).toBe(`https://github.com/TriliumNext/Notes/releases/tag/v${expectedVersion}`); +}); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 125c22d49..b633603ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "trilium", - "version": "0.90.2-beta", + "version": "0.90.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "trilium", - "version": "0.90.2-beta", + "version": "0.90.3", "license": "AGPL-3.0-only", "dependencies": { "@braintree/sanitize-url": "^7.1.0", @@ -96,6 +96,7 @@ "@electron-forge/maker-squirrel": "^6.4.2", "@electron-forge/maker-zip": "^7.4.0", "@electron-forge/plugin-auto-unpack-natives": "^6.4.2", + "@playwright/test": "^1.46.0", "@types/archiver": "^6.0.2", "@types/better-sqlite3": "^7.6.9", "@types/cls-hooked": "^4.3.8", @@ -113,6 +114,7 @@ "@types/jsdom": "^21.1.6", "@types/mime-types": "^2.1.4", "@types/multer": "^1.4.11", + "@types/node": "^22.1.0", "@types/safe-compare": "^1.1.2", "@types/sanitize-html": "^2.11.0", "@types/sax": "^1.2.7", @@ -3590,6 +3592,21 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.46.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.46.0.tgz", + "integrity": "sha512-/QYft5VArOrGRP5pgkrfKksqsKA6CEFyGQ/gjNe6q0y4tZ1aaPfq4gIjudr1s3D+pXyrPRdsy4opKDrjBabE5w==", + "dev": true, + "dependencies": { + "playwright": "1.46.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -4007,11 +4024,11 @@ } }, "node_modules/@types/node": { - "version": "20.14.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.11.tgz", - "integrity": "sha512-kprQpL8MMeszbz6ojB5/tU8PLN4kesnN8Gjzw349rDlNgsSzg90lAVj3llK99Dh7JON+t9AuscPPFW6mPbTnSA==", + "version": "22.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.1.0.tgz", + "integrity": "sha512-AOmuRF0R2/5j1knA3c6G3HOk523Ga+l+ZXltX8SF1+5oqcXijjfTd8fY3XRZqSihEu9XhtQnKYLmkFaoxgsJHw==", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.13.0" } }, "node_modules/@types/qs": { @@ -7931,6 +7948,19 @@ "node": ">=6 <7 || >=8" } }, + "node_modules/electron/node_modules/@types/node": { + "version": "20.14.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.14.tgz", + "integrity": "sha512-d64f00982fS9YoOgJkAMolK7MN8Iq3TDdVjchbYHdEmjth/DHowx82GnoA+tVUAN+7vxfYUgAzi+JXbKNd2SDQ==", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/electron/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + }, "node_modules/elkjs": { "version": "0.9.2", "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.2.tgz", @@ -9502,6 +9532,19 @@ "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", "devOptional": true }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -13919,6 +13962,36 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.46.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.46.0.tgz", + "integrity": "sha512-XYJ5WvfefWONh1uPAUAi0H2xXV5S3vrtcnXe6uAOgdGi3aSpqOSXX08IAjXW34xitfuOJsvXU5anXZxPSEQiJw==", + "dev": true, + "dependencies": { + "playwright-core": "1.46.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.46.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.46.0.tgz", + "integrity": "sha512-9Y/d5UIwuJk8t3+lhmMSAJyNP1BUC/DqP3cQJDQQL/oWqAiuPTLgy7Q5dzglmTLwcBRdetzgNM/gni7ckfTr6A==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/plist": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", @@ -16310,6 +16383,20 @@ "fsevents": "~2.3.3" } }, + "node_modules/tsx/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -16414,9 +16501,9 @@ "dev": true }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.13.0.tgz", + "integrity": "sha512-xtFJHudx8S2DSoujjMd1WeWvn7KKWFRESZTMeL1RptAYERu29D6jphMjjY+vn96jvN3kVPDNxU/E13VTaXj6jg==" }, "node_modules/unescape": { "version": "1.0.1", diff --git a/package.json b/package.json index 95d4599ca..d2a3f1d76 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "trilium", "productName": "TriliumNext Notes", "description": "Build your personal knowledge base with TriliumNext Notes", - "version": "0.90.2-beta", + "version": "0.90.3", "license": "AGPL-3.0-only", "main": "./dist/electron.js", "author": { @@ -42,7 +42,9 @@ "package-electron": "electron-forge package", "prepare-dist": "rimraf ./dist && tsc && tsx ./bin/copy-dist.ts", "update-build-info": "tsx bin/update-build-info.ts", - "errors": "tsc --watch --noEmit" + "errors": "tsc --watch --noEmit", + "integration-edit-db": "cross-env TRILIUM_INTEGRATION_TEST=edit TRILIUM_PORT=8081 TRILIUM_DATA_DIR=./integration-tests/db nodemon src/www.ts", + "integration-mem-db": "cross-env TRILIUM_INTEGRATION_TEST=memory TRILIUM_PORT=8082 TRILIUM_DATA_DIR=./integration-tests/db nodemon src/www.ts" }, "dependencies": { "@braintree/sanitize-url": "^7.1.0", @@ -129,6 +131,7 @@ "@electron-forge/maker-squirrel": "^6.4.2", "@electron-forge/maker-zip": "^7.4.0", "@electron-forge/plugin-auto-unpack-natives": "^6.4.2", + "@playwright/test": "^1.46.0", "@types/archiver": "^6.0.2", "@types/better-sqlite3": "^7.6.9", "@types/cls-hooked": "^4.3.8", @@ -146,6 +149,7 @@ "@types/jsdom": "^21.1.6", "@types/mime-types": "^2.1.4", "@types/multer": "^1.4.11", + "@types/node": "^22.1.0", "@types/safe-compare": "^1.1.2", "@types/sanitize-html": "^2.11.0", "@types/sax": "^1.2.7", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 000000000..12420d3a5 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,77 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './integration-tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://127.0.0.1:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: "setup", + testMatch: /.*\.setup\.ts/ + }, + + { + name: "firefox", + use: { + ...devices[ "Desktop Firefox" ], + storageState: "playwright/.auth/user.json" + }, + dependencies: [ "setup" ] + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://127.0.0.1:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/src/becca/entities/abstract_becca_entity.ts b/src/becca/entities/abstract_becca_entity.ts index 0016b2a07..cd169c0b2 100644 --- a/src/becca/entities/abstract_becca_entity.ts +++ b/src/becca/entities/abstract_becca_entity.ts @@ -34,7 +34,7 @@ abstract class AbstractBeccaEntity> { isSynced?: boolean; blobId?: string; - protected beforeSaving() { + protected beforeSaving(opts?: {}) { const constructorData = (this.constructor as unknown as ConstructorData); if (!(this as any)[constructorData.primaryKeyName]) { (this as any)[constructorData.primaryKeyName] = utils.newEntityId(); @@ -101,7 +101,6 @@ abstract class AbstractBeccaEntity> { /** * Saves entity - executes SQL, but doesn't commit the transaction on its own */ - // TODO: opts not used but called a few times, maybe should be used by derived classes or passed to beforeSaving. save(opts?: {}): this { const constructorData = (this.constructor as unknown as ConstructorData); const entityName = constructorData.entityName; @@ -109,7 +108,7 @@ abstract class AbstractBeccaEntity> { const isNewEntity = !(this as any)[primaryKeyName]; - this.beforeSaving(); + this.beforeSaving(opts); const pojo = this.getPojoToSave(); diff --git a/src/etapi/etapi.openapi.yaml b/src/etapi/etapi.openapi.yaml index 61eb1a6cf..b53bfb01c 100644 --- a/src/etapi/etapi.openapi.yaml +++ b/src/etapi/etapi.openapi.yaml @@ -48,7 +48,7 @@ paths: - name: search in: query required: true - description: search query string as described in https://github.com/zadam/trilium/wiki/Search + description: search query string as described in https://github.com/TriliumNext/Docs/blob/main/Wiki/search.md schema: type: string examples: diff --git a/src/public/app/components/entrypoints.js b/src/public/app/components/entrypoints.js index 6b281911f..6a375d5af 100644 --- a/src/public/app/components/entrypoints.js +++ b/src/public/app/components/entrypoints.js @@ -102,7 +102,7 @@ export default class Entrypoints extends Component { if (utils.isElectron()) { // standard JS version does not work completely correctly in electron const webContents = utils.dynamicRequire('@electron/remote').getCurrentWebContents(); - const activeIndex = parseInt(webContents.getActiveIndex()); + const activeIndex = parseInt(webContents.navigationHistory.getActiveIndex()); webContents.goToIndex(activeIndex - 1); } @@ -115,7 +115,7 @@ export default class Entrypoints extends Component { if (utils.isElectron()) { // standard JS version does not work completely correctly in electron const webContents = utils.dynamicRequire('@electron/remote').getCurrentWebContents(); - const activeIndex = parseInt(webContents.getActiveIndex()); + const activeIndex = parseInt(webContents.navigationHistory.getActiveIndex()); webContents.goToIndex(activeIndex + 1); } diff --git a/src/public/app/services/frontend_script_api.js b/src/public/app/services/frontend_script_api.js index 989a49879..f0e8cc30b 100644 --- a/src/public/app/services/frontend_script_api.js +++ b/src/public/app/services/frontend_script_api.js @@ -249,7 +249,7 @@ function FrontendScriptApi(startNote, currentNote, originEntity = null, $contain /** * This is a powerful search method - you can search by attributes and their values, e.g.: - * "#dateModified =* MONTH AND #log". See full documentation for all options at: https://github.com/zadam/trilium/wiki/Search + * "#dateModified =* MONTH AND #log". See full documentation for all options at: https://github.com/TriliumNext/Docs/blob/main/Wiki/search.md * * @method * @param {string} searchString @@ -261,7 +261,7 @@ function FrontendScriptApi(startNote, currentNote, originEntity = null, $contain /** * This is a powerful search method - you can search by attributes and their values, e.g.: - * "#dateModified =* MONTH AND #log". See full documentation for all options at: https://github.com/zadam/trilium/wiki/Search + * "#dateModified =* MONTH AND #log". See full documentation for all options at: https://github.com/TriliumNext/Docs/blob/main/Wiki/search.md * * @method * @param {string} searchString @@ -558,7 +558,7 @@ function FrontendScriptApi(startNote, currentNote, originEntity = null, $contain this.getYearNote = dateNotesService.getYearNote; /** - * Hoist note in the current tab. See https://github.com/zadam/trilium/wiki/Note-hoisting + * Hoist note in the current tab. See https://github.com/TriliumNext/Docs/blob/main/Wiki/note-hoisting.md * * @method * @param {string} noteId - set hoisted note. 'root' will effectively unhoist diff --git a/src/public/app/services/glob.js b/src/public/app/services/glob.js index f2ef87128..925feaf82 100644 --- a/src/public/app/services/glob.js +++ b/src/public/app/services/glob.js @@ -27,7 +27,7 @@ function setupGlobs() { window.glob.importMarkdownInline = async () => appContext.triggerCommand("importMarkdownInline"); window.glob.SEARCH_HELP_TEXT = ` - Search tips - also see + Search tips - also see

  • Just enter any text for full text search
  • diff --git a/src/public/app/services/utils.js b/src/public/app/services/utils.js index 9e17f1c38..7802ff128 100644 --- a/src/public/app/services/utils.js +++ b/src/public/app/services/utils.js @@ -330,7 +330,7 @@ function initHelpDropdown($el) { initHelpButtons($dropdownMenu); } -const wikiBaseUrl = "https://github.com/zadam/trilium/wiki/"; +const wikiBaseUrl = "https://github.com/TriliumNext/Docs/blob/main/Wiki/"; function openHelp($button) { const helpPage = $button.attr("data-help-page"); diff --git a/src/public/app/widgets/attribute_widgets/attribute_detail.js b/src/public/app/widgets/attribute_widgets/attribute_detail.js index 0ffac8fd7..fa4dc724d 100644 --- a/src/public/app/widgets/attribute_widgets/attribute_detail.js +++ b/src/public/app/widgets/attribute_widgets/attribute_detail.js @@ -211,8 +211,8 @@ const ATTR_HELP = { "cssClass": "value of this label is then added as CSS class to the node representing given note in the tree. This can be useful for advanced theming. Can be used in template notes.", "iconClass": "value of this label is added as a CSS class to the icon on the tree which can help visually distinguish the notes in the tree. Example might be bx bx-home - icons are taken from boxicons. Can be used in template notes.", "pageSize": "number of items per page in note listing", - "customRequestHandler": 'see Custom request handler', - "customResourceProvider": 'see Custom request handler', + "customRequestHandler": 'see Custom request handler', + "customResourceProvider": 'see Custom request handler', "widget": "marks this note as a custom widget which will be added to the Trilium component tree", "workspace": "marks this note as a workspace which allows easy hoisting", "workspaceIconClass": "defines box icon CSS class which will be used in tab when hoisted to this note", @@ -245,7 +245,7 @@ const ATTR_HELP = {
  • Log for \${now.format('YYYY-MM-DD HH:mm:ss')}
- See wiki with details, API docs for parentNote and now for details.`, + See wiki with details, API docs for parentNote and now for details.`, "template": "This note will appear in the selection of available template when creating new note", "toc": "#toc or #toc=show will force the Table of Contents to be shown, #toc=hide will force hiding it. If the label doesn't exist, the global setting is observed", "color": "defines color of the note in note tree, links etc. Use any valid CSS color value like 'red' or #a13d5f", diff --git a/src/public/app/widgets/buttons/global_menu.js b/src/public/app/widgets/buttons/global_menu.js index 7203cb690..ad7806f0e 100644 --- a/src/public/app/widgets/buttons/global_menu.js +++ b/src/public/app/widgets/buttons/global_menu.js @@ -337,7 +337,7 @@ export default class GlobalMenuWidget extends BasicWidget { } downloadLatestVersionCommand() { - window.open("https://github.com/zadam/trilium/releases/latest"); + window.open("https://github.com/TriliumNext/Notes/releases/latest"); } activeContextChangedEvent() { diff --git a/src/public/app/widgets/dialogs/add_link.js b/src/public/app/widgets/dialogs/add_link.js index 117bc052f..0c7772fc8 100644 --- a/src/public/app/widgets/dialogs/add_link.js +++ b/src/public/app/widgets/dialogs/add_link.js @@ -10,7 +10,7 @@ const TPL = ` @@ -78,7 +78,7 @@ const TPL = `
  • not set, not set - multi-select note above/below
  • not set - select all notes in the current level
  • Shift+click - select note
  • -
  • not set - copy active note (or current selection) into clipboard (used for cloning)
  • +
  • not set - copy active note (or current selection) into clipboard (used for cloning)
  • not set - cut current (or current selection) note into clipboard (used for moving notes)
  • not set - paste note(s) as sub-note into active note (which is either move or clone depending on whether it was copied or cut into clipboard)
  • not set - delete note / sub-tree
  • @@ -107,7 +107,7 @@ const TPL = `
    -
    Markdown-like autoformatting
    +
    Markdown-like autoformatting

      diff --git a/src/public/app/widgets/dialogs/protected_session_password.js b/src/public/app/widgets/dialogs/protected_session_password.js index 23ddd3726..4985fc09d 100644 --- a/src/public/app/widgets/dialogs/protected_session_password.js +++ b/src/public/app/widgets/dialogs/protected_session_password.js @@ -9,7 +9,7 @@ const TPL = ` `; export default class SharedInfoWidget extends NoteContextAwareWidget { diff --git a/src/public/app/widgets/shared_switch.js b/src/public/app/widgets/shared_switch.js index c219bac3e..d68a086d3 100644 --- a/src/public/app/widgets/shared_switch.js +++ b/src/public/app/widgets/shared_switch.js @@ -20,7 +20,7 @@ export default class SharedSwitchWidget extends SwitchWidget { this.$switchOffName.text("Shared"); this.$switchOffButton.attr("title", "Unshare the note"); - this.$helpButton.attr("data-help-page", "Sharing").show(); + this.$helpButton.attr("data-help-page", "sharing.md").show(); this.$helpButton.on('click', e => utils.openHelp($(e.target))); } diff --git a/src/public/app/widgets/type_widgets/attachment_detail.js b/src/public/app/widgets/type_widgets/attachment_detail.js index fbc38e561..03041b54e 100644 --- a/src/public/app/widgets/type_widgets/attachment_detail.js +++ b/src/public/app/widgets/type_widgets/attachment_detail.js @@ -47,7 +47,7 @@ export default class AttachmentDetailTypeWidget extends TypeWidget { this.$wrapper.empty(); this.children = []; - const $helpButton = $(''); + const $helpButton = $(''); utils.initHelpButtons($helpButton); this.$linksWrapper.empty().append( diff --git a/src/public/app/widgets/type_widgets/attachment_list.js b/src/public/app/widgets/type_widgets/attachment_list.js index 8f6aa9f9e..247f9e1e0 100644 --- a/src/public/app/widgets/type_widgets/attachment_list.js +++ b/src/public/app/widgets/type_widgets/attachment_list.js @@ -39,7 +39,7 @@ export default class AttachmentListTypeWidget extends TypeWidget { } async doRefresh(note) { - const $helpButton = $(''); + const $helpButton = $(''); utils.initHelpButtons($helpButton); const noteLink = await linkService.createLink(this.noteId); // do separately to avoid race condition between empty() and .append() diff --git a/src/public/app/widgets/type_widgets/book.js b/src/public/app/widgets/type_widgets/book.js index 4c6c54c33..5b7d615f2 100644 --- a/src/public/app/widgets/type_widgets/book.js +++ b/src/public/app/widgets/type_widgets/book.js @@ -13,7 +13,7 @@ const TPL = `
      - This note of type Book doesn't have any child notes so there's nothing to display. See wiki for details. + This note of type Book doesn't have any child notes so there's nothing to display. See wiki for details.
    `; diff --git a/src/public/app/widgets/type_widgets/options/etapi.js b/src/public/app/widgets/type_widgets/options/etapi.js index a1e399711..b96fd7b8b 100644 --- a/src/public/app/widgets/type_widgets/options/etapi.js +++ b/src/public/app/widgets/type_widgets/options/etapi.js @@ -8,7 +8,7 @@ const TPL = `

    ETAPI

    ETAPI is a REST API used to access Trilium instance programmatically, without UI.
    - See more details on wiki and ETAPI OpenAPI spec.

    + See more details on wiki and ETAPI OpenAPI spec.

    diff --git a/src/public/app/widgets/type_widgets/options/other/revisions_snapshot_interval.js b/src/public/app/widgets/type_widgets/options/other/revisions_snapshot_interval.js index 7bd6315c6..a959c5b7b 100644 --- a/src/public/app/widgets/type_widgets/options/other/revisions_snapshot_interval.js +++ b/src/public/app/widgets/type_widgets/options/other/revisions_snapshot_interval.js @@ -4,7 +4,7 @@ const TPL = `

    Note Revisions Snapshot Interval

    -

    Note revision snapshot time interval is time in seconds after which a new note revision will be created for the note. See wiki for more info.

    +

    Note revision snapshot time interval is time in seconds after which a new note revision will be created for the note. See wiki for more info.

    diff --git a/src/public/app/widgets/type_widgets/options/password.js b/src/public/app/widgets/type_widgets/options/password.js index 783154718..ca18cc248 100644 --- a/src/public/app/widgets/type_widgets/options/password.js +++ b/src/public/app/widgets/type_widgets/options/password.js @@ -37,7 +37,7 @@ const TPL = `

    Protected Session Timeout

    Protected session timeout is a time period after which the protected session is wiped from - the browser's memory. This is measured from the last interaction with protected notes. See wiki for more info.

    + the browser's memory. This is measured from the last interaction with protected notes. See wiki for more info.

    diff --git a/src/public/app/widgets/type_widgets/options/sync.js b/src/public/app/widgets/type_widgets/options/sync.js index 3f7ad72b6..b9ebc39af 100644 --- a/src/public/app/widgets/type_widgets/options/sync.js +++ b/src/public/app/widgets/type_widgets/options/sync.js @@ -28,7 +28,7 @@ const TPL = `
    - +
    diff --git a/src/public/app/widgets/type_widgets/render.js b/src/public/app/widgets/type_widgets/render.js index 46784db37..9c694385f 100644 --- a/src/public/app/widgets/type_widgets/render.js +++ b/src/public/app/widgets/type_widgets/render.js @@ -12,7 +12,7 @@ const TPL = `

    This help note is shown because this note of type Render HTML doesn't have required relation to function properly.

    -

    Render HTML note type is used for scripting. In short, you have a HTML code note (optionally with some JavaScript) and this note will render it. To make it work, you need to define a relation called "renderNote" pointing to the HTML note to render.

    +

    Render HTML note type is used for scripting. In short, you have a HTML code note (optionally with some JavaScript) and this note will render it. To make it work, you need to define a relation called "renderNote" pointing to the HTML note to render.

    diff --git a/src/public/stylesheets/ckeditor-theme.css b/src/public/stylesheets/ckeditor-theme.css index eac57cf47..225788c5b 100644 --- a/src/public/stylesheets/ckeditor-theme.css +++ b/src/public/stylesheets/ckeditor-theme.css @@ -4,6 +4,7 @@ body { --ck-color-base-text: var(--main-text-color); --ck-color-base-foreground: var(--accented-background-color); --ck-color-base-background: var(--main-background-color); + --ck-color-dialog-background: var(--ck-color-base-background); --ck-color-focus-border: var(--main-border-color); --ck-color-text: var(--main-text-color); --ck-color-shadow-drop: var(--main-background-color); diff --git a/src/services/backend_script_api.ts b/src/services/backend_script_api.ts index 5180e4739..0147cbec9 100644 --- a/src/services/backend_script_api.ts +++ b/src/services/backend_script_api.ts @@ -114,13 +114,13 @@ interface Api { /** * This is a powerful search method - you can search by attributes and their values, e.g.: - * "#dateModified =* MONTH AND #log". See {@link https://github.com/zadam/trilium/wiki/Search} for full documentation for all options + * "#dateModified =* MONTH AND #log". See {@link https://github.com/TriliumNext/Docs/blob/main/Wiki/search.md} for full documentation for all options */ searchForNotes(query: string, searchParams: SearchParams): BNote[]; /** * This is a powerful search method - you can search by attributes and their values, e.g.: - * "#dateModified =* MONTH AND #log". See {@link https://github.com/zadam/trilium/wiki/Search} for full documentation for all options + * "#dateModified =* MONTH AND #log". See {@link https://github.com/TriliumNext/Docs/blob/main/Wiki/search.md} for full documentation for all options */ searchForNote(query: string, searchParams: SearchParams): BNote | null; @@ -251,7 +251,7 @@ interface Api { */ sortNotes(parentNoteId: string, sortConfig: { /** 'title', 'dateCreated', 'dateModified' or a label name - * See {@link https://github.com/zadam/trilium/wiki/Sorting} for details. */ + * See {@link https://github.com/TriliumNext/Docs/blob/main/Wiki/sorting.md} for details. */ sortBy?: string; reverse?: boolean; foldersFirst?: boolean; diff --git a/src/services/build.ts b/src/services/build.ts index fab48b9d1..13b748851 100644 --- a/src/services/build.ts +++ b/src/services/build.ts @@ -1,4 +1,4 @@ export default { - buildDate: "2024-07-28T07:12:19Z", - buildRevision: "1d142b9e572bc5558997f0cf33523da3dbfce174" + buildDate: "2024-08-06T17:40:58Z", + buildRevision: "712ef92f7ca6b12cf19a8aa81b9735fd5f08cce8" }; diff --git a/src/services/notes.ts b/src/services/notes.ts index cee8a8f91..59031b8d9 100644 --- a/src/services/notes.ts +++ b/src/services/notes.ts @@ -817,7 +817,6 @@ function undeleteBranch(branchId: string, deleteId: string, taskContext: TaskCon for (const attributeRow of attributeRows) { // relation might point to a note which hasn't been undeleted yet and would thus throw up - // TODO: skipValidation is not used. new BAttribute(attributeRow).save({skipValidation: true}); } @@ -997,8 +996,7 @@ function duplicateSubtreeInner(origNote: BNote, origBranch: BBranch, newParentNo } // the relation targets may not be created yet, the mapping is pre-generated - // TODO: This used to be `attr.save({skipValidation: true});`, but skipValidation is in beforeSaving. - attr.save(); + attr.save({skipValidation: true}); } for (const childBranch of origNote.getChildBranches()) { diff --git a/src/services/sql.ts b/src/services/sql.ts index 0d92e6209..e352a7702 100644 --- a/src/services/sql.ts +++ b/src/services/sql.ts @@ -14,8 +14,21 @@ import ws from "./ws.js"; import becca_loader from "../becca/becca_loader.js"; import entity_changes from "./entity_changes.js"; -const dbConnection: DatabaseType = new Database(dataDir.DOCUMENT_PATH); -dbConnection.pragma('journal_mode = WAL'); +function buildDatabase(path: string) { + if (process.env.TRILIUM_INTEGRATION_TEST === "memory") { + // This allows a database that is read normally but is kept in memory and discards all modifications. + const dbBuffer = fs.readFileSync(path); + return new Database(dbBuffer); + } + + return new Database(dataDir.DOCUMENT_PATH); +} + +const dbConnection: DatabaseType = buildDatabase(dataDir.DOCUMENT_PATH); + +if (!process.env.TRILIUM_INTEGRATION_TEST) { + dbConnection.pragma('journal_mode = WAL'); +} const LOG_ALL_QUERIES = false; diff --git a/start-docker.sh b/start-docker.sh index 823b6eba9..ffdcb4148 100755 --- a/start-docker.sh +++ b/start-docker.sh @@ -4,4 +4,4 @@ [[ ! -z "${USER_GID}" ]] && groupmod -og ${USER_GID} node || echo "No USER_GID specified, leaving 1000" chown -R node:node /home/node -exec su-exec node node ./src/www +exec su -c "node ./src/www" node diff --git a/tests-examples/demo-todo-app.spec.ts b/tests-examples/demo-todo-app.spec.ts new file mode 100644 index 000000000..8641cb5f5 --- /dev/null +++ b/tests-examples/demo-todo-app.spec.ts @@ -0,0 +1,437 @@ +import { test, expect, type Page } from '@playwright/test'; + +test.beforeEach(async ({ page }) => { + await page.goto('https://demo.playwright.dev/todomvc'); +}); + +const TODO_ITEMS = [ + 'buy some cheese', + 'feed the cat', + 'book a doctors appointment' +] as const; + +test.describe('New Todo', () => { + test('should allow me to add todo items', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create 1st todo. + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + + // Make sure the list only has one todo item. + await expect(page.getByTestId('todo-title')).toHaveText([ + TODO_ITEMS[0] + ]); + + // Create 2nd todo. + await newTodo.fill(TODO_ITEMS[1]); + await newTodo.press('Enter'); + + // Make sure the list now has two todo items. + await expect(page.getByTestId('todo-title')).toHaveText([ + TODO_ITEMS[0], + TODO_ITEMS[1] + ]); + + await checkNumberOfTodosInLocalStorage(page, 2); + }); + + test('should clear text input field when an item is added', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create one todo item. + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + + // Check that input is empty. + await expect(newTodo).toBeEmpty(); + await checkNumberOfTodosInLocalStorage(page, 1); + }); + + test('should append new items to the bottom of the list', async ({ page }) => { + // Create 3 items. + await createDefaultTodos(page); + + // create a todo count locator + const todoCount = page.getByTestId('todo-count') + + // Check test using different methods. + await expect(page.getByText('3 items left')).toBeVisible(); + await expect(todoCount).toHaveText('3 items left'); + await expect(todoCount).toContainText('3'); + await expect(todoCount).toHaveText(/3/); + + // Check all items in one call. + await expect(page.getByTestId('todo-title')).toHaveText(TODO_ITEMS); + await checkNumberOfTodosInLocalStorage(page, 3); + }); +}); + +test.describe('Mark all as completed', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test.afterEach(async ({ page }) => { + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should allow me to mark all items as completed', async ({ page }) => { + // Complete all todos. + await page.getByLabel('Mark all as complete').check(); + + // Ensure all todos have 'completed' class. + await expect(page.getByTestId('todo-item')).toHaveClass(['completed', 'completed', 'completed']); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + }); + + test('should allow me to clear the complete state of all items', async ({ page }) => { + const toggleAll = page.getByLabel('Mark all as complete'); + // Check and then immediately uncheck. + await toggleAll.check(); + await toggleAll.uncheck(); + + // Should be no completed classes. + await expect(page.getByTestId('todo-item')).toHaveClass(['', '', '']); + }); + + test('complete all checkbox should update state when items are completed / cleared', async ({ page }) => { + const toggleAll = page.getByLabel('Mark all as complete'); + await toggleAll.check(); + await expect(toggleAll).toBeChecked(); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + + // Uncheck first todo. + const firstTodo = page.getByTestId('todo-item').nth(0); + await firstTodo.getByRole('checkbox').uncheck(); + + // Reuse toggleAll locator and make sure its not checked. + await expect(toggleAll).not.toBeChecked(); + + await firstTodo.getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 3); + + // Assert the toggle all is checked again. + await expect(toggleAll).toBeChecked(); + }); +}); + +test.describe('Item', () => { + + test('should allow me to mark items as complete', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create two items. + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + // Check first item. + const firstTodo = page.getByTestId('todo-item').nth(0); + await firstTodo.getByRole('checkbox').check(); + await expect(firstTodo).toHaveClass('completed'); + + // Check second item. + const secondTodo = page.getByTestId('todo-item').nth(1); + await expect(secondTodo).not.toHaveClass('completed'); + await secondTodo.getByRole('checkbox').check(); + + // Assert completed class. + await expect(firstTodo).toHaveClass('completed'); + await expect(secondTodo).toHaveClass('completed'); + }); + + test('should allow me to un-mark items as complete', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // Create two items. + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + const firstTodo = page.getByTestId('todo-item').nth(0); + const secondTodo = page.getByTestId('todo-item').nth(1); + const firstTodoCheckbox = firstTodo.getByRole('checkbox'); + + await firstTodoCheckbox.check(); + await expect(firstTodo).toHaveClass('completed'); + await expect(secondTodo).not.toHaveClass('completed'); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + await firstTodoCheckbox.uncheck(); + await expect(firstTodo).not.toHaveClass('completed'); + await expect(secondTodo).not.toHaveClass('completed'); + await checkNumberOfCompletedTodosInLocalStorage(page, 0); + }); + + test('should allow me to edit an item', async ({ page }) => { + await createDefaultTodos(page); + + const todoItems = page.getByTestId('todo-item'); + const secondTodo = todoItems.nth(1); + await secondTodo.dblclick(); + await expect(secondTodo.getByRole('textbox', { name: 'Edit' })).toHaveValue(TODO_ITEMS[1]); + await secondTodo.getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); + await secondTodo.getByRole('textbox', { name: 'Edit' }).press('Enter'); + + // Explicitly assert the new text value. + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2] + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); +}); + +test.describe('Editing', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should hide other controls when editing', async ({ page }) => { + const todoItem = page.getByTestId('todo-item').nth(1); + await todoItem.dblclick(); + await expect(todoItem.getByRole('checkbox')).not.toBeVisible(); + await expect(todoItem.locator('label', { + hasText: TODO_ITEMS[1], + })).not.toBeVisible(); + await checkNumberOfTodosInLocalStorage(page, 3); + }); + + test('should save edits on blur', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).dispatchEvent('blur'); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2], + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); + + test('should trim entered text', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill(' buy some sausages '); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter'); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + 'buy some sausages', + TODO_ITEMS[2], + ]); + await checkTodosInLocalStorage(page, 'buy some sausages'); + }); + + test('should remove the item if an empty text string was entered', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill(''); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter'); + + await expect(todoItems).toHaveText([ + TODO_ITEMS[0], + TODO_ITEMS[2], + ]); + }); + + test('should cancel edits on escape', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).dblclick(); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages'); + await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Escape'); + await expect(todoItems).toHaveText(TODO_ITEMS); + }); +}); + +test.describe('Counter', () => { + test('should display the current number of todo items', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + // create a todo count locator + const todoCount = page.getByTestId('todo-count') + + await newTodo.fill(TODO_ITEMS[0]); + await newTodo.press('Enter'); + + await expect(todoCount).toContainText('1'); + + await newTodo.fill(TODO_ITEMS[1]); + await newTodo.press('Enter'); + await expect(todoCount).toContainText('2'); + + await checkNumberOfTodosInLocalStorage(page, 2); + }); +}); + +test.describe('Clear completed button', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + }); + + test('should display the correct text', async ({ page }) => { + await page.locator('.todo-list li .toggle').first().check(); + await expect(page.getByRole('button', { name: 'Clear completed' })).toBeVisible(); + }); + + test('should remove completed items when clicked', async ({ page }) => { + const todoItems = page.getByTestId('todo-item'); + await todoItems.nth(1).getByRole('checkbox').check(); + await page.getByRole('button', { name: 'Clear completed' }).click(); + await expect(todoItems).toHaveCount(2); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); + }); + + test('should be hidden when there are no items that are completed', async ({ page }) => { + await page.locator('.todo-list li .toggle').first().check(); + await page.getByRole('button', { name: 'Clear completed' }).click(); + await expect(page.getByRole('button', { name: 'Clear completed' })).toBeHidden(); + }); +}); + +test.describe('Persistence', () => { + test('should persist its data', async ({ page }) => { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + for (const item of TODO_ITEMS.slice(0, 2)) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } + + const todoItems = page.getByTestId('todo-item'); + const firstTodoCheck = todoItems.nth(0).getByRole('checkbox'); + await firstTodoCheck.check(); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); + await expect(firstTodoCheck).toBeChecked(); + await expect(todoItems).toHaveClass(['completed', '']); + + // Ensure there is 1 completed item. + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + // Now reload. + await page.reload(); + await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]); + await expect(firstTodoCheck).toBeChecked(); + await expect(todoItems).toHaveClass(['completed', '']); + }); +}); + +test.describe('Routing', () => { + test.beforeEach(async ({ page }) => { + await createDefaultTodos(page); + // make sure the app had a chance to save updated todos in storage + // before navigating to a new view, otherwise the items can get lost :( + // in some frameworks like Durandal + await checkTodosInLocalStorage(page, TODO_ITEMS[0]); + }); + + test('should allow me to display active items', async ({ page }) => { + const todoItem = page.getByTestId('todo-item'); + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Active' }).click(); + await expect(todoItem).toHaveCount(2); + await expect(todoItem).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]); + }); + + test('should respect the back button', async ({ page }) => { + const todoItem = page.getByTestId('todo-item'); + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + + await test.step('Showing all items', async () => { + await page.getByRole('link', { name: 'All' }).click(); + await expect(todoItem).toHaveCount(3); + }); + + await test.step('Showing active items', async () => { + await page.getByRole('link', { name: 'Active' }).click(); + }); + + await test.step('Showing completed items', async () => { + await page.getByRole('link', { name: 'Completed' }).click(); + }); + + await expect(todoItem).toHaveCount(1); + await page.goBack(); + await expect(todoItem).toHaveCount(2); + await page.goBack(); + await expect(todoItem).toHaveCount(3); + }); + + test('should allow me to display completed items', async ({ page }) => { + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Completed' }).click(); + await expect(page.getByTestId('todo-item')).toHaveCount(1); + }); + + test('should allow me to display all items', async ({ page }) => { + await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check(); + await checkNumberOfCompletedTodosInLocalStorage(page, 1); + await page.getByRole('link', { name: 'Active' }).click(); + await page.getByRole('link', { name: 'Completed' }).click(); + await page.getByRole('link', { name: 'All' }).click(); + await expect(page.getByTestId('todo-item')).toHaveCount(3); + }); + + test('should highlight the currently applied filter', async ({ page }) => { + await expect(page.getByRole('link', { name: 'All' })).toHaveClass('selected'); + + //create locators for active and completed links + const activeLink = page.getByRole('link', { name: 'Active' }); + const completedLink = page.getByRole('link', { name: 'Completed' }); + await activeLink.click(); + + // Page change - active items. + await expect(activeLink).toHaveClass('selected'); + await completedLink.click(); + + // Page change - completed items. + await expect(completedLink).toHaveClass('selected'); + }); +}); + +async function createDefaultTodos(page: Page) { + // create a new todo locator + const newTodo = page.getByPlaceholder('What needs to be done?'); + + for (const item of TODO_ITEMS) { + await newTodo.fill(item); + await newTodo.press('Enter'); + } +} + +async function checkNumberOfTodosInLocalStorage(page: Page, expected: number) { + return await page.waitForFunction(e => { + return JSON.parse(localStorage['react-todos']).length === e; + }, expected); +} + +async function checkNumberOfCompletedTodosInLocalStorage(page: Page, expected: number) { + return await page.waitForFunction(e => { + return JSON.parse(localStorage['react-todos']).filter((todo: any) => todo.completed).length === e; + }, expected); +} + +async function checkTodosInLocalStorage(page: Page, title: string) { + return await page.waitForFunction(t => { + return JSON.parse(localStorage['react-todos']).map((todo: any) => todo.title).includes(t); + }, title); +}